From de8c07cfd24a75b72decfb51f3bed5233ad5bcce Mon Sep 17 00:00:00 2001 From: citrons Date: Tue, 27 May 2025 02:45:36 -0500 Subject: TUI layout system --- tui/layout.go | 361 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 tui/layout.go (limited to 'tui/layout.go') diff --git a/tui/layout.go b/tui/layout.go new file mode 100644 index 0000000..1b3c3ed --- /dev/null +++ b/tui/layout.go @@ -0,0 +1,361 @@ +package tui + +import ( + "github.com/rivo/uniseg" + "strings" + "unicode" +) + +type Box struct { + Width, Height BoxSize + Dir Direction + Overflow bool + Scroll int + Style *Style + Margins [4]int + text []textRun + computedLines [][]textRun + children []*Box + computedPosition int + computedSize [2]int + computedRect rect +} + +type BoxSize int +const ( + Fill = -(iota + 1) + Children + TextSize +) + +func (s BoxSize) isFlexible() bool { + return s == Fill +} + +type Direction int +const ( + Down = iota + Up + Right + Left +) + +func (d Direction) axis() int { + switch d { + case Down, Up: + return 1 + default: + return 0 + } +} + +func (d Direction) reverse() bool { + switch d { + case Down, Right: + return false + default: + return true + } +} + +type textRun struct { + text string + style *Style +} + +type rect struct { + min [2]int + max [2]int +} + +type BoxEvent struct { +} + +type TextEvent struct { +} + +var layout struct { + front map[string]*Box + back map[string]*Box + stack []*Box +} + +func init() { + layout.front = make(map[string]*Box) + layout.back = make(map[string]*Box) +} + +func top() *Box { + return layout.stack[len(layout.stack) - 1] +} + +func Push(id string, box Box) BoxEvent { + if len(layout.stack) > 0 { + top().children = append(top().children, &box) + } + layout.stack = append(layout.stack, &box) + layout.front[id] = &box + for axis, value := range box.axes() { + if value >= 0 { + box.computedSize[axis] = int(value) + } + } + return BoxEvent {} +} + +func Text(text string, style *Style) TextEvent { + top().text = append(top().text, textRun {text, style}) + return TextEvent {} +} + +func Pop() { + if len(layout.stack) > 1 { + layout.stack = layout.stack[:len(layout.stack) - 1] + } +} + +func (b Box) axes() [2]BoxSize { + return [2]BoxSize {b.Width, b.Height} +} + +func (b *Box) marginsSize(axis int) int { + return b.Margins[axis * 2] + b.Margins[axis * 2 + 1] +} + +func (b *Box) computeFillSizes(axis int) { + for _, c := range b.children { + if c.axes()[axis] == Fill { + c.computedSize[axis] = b.computedSize[axis] + } + c.computeFillSizes(axis) + } +} + +func (b *Box) computeChildrenSizes(axis int) { + size := b.marginsSize(axis) + for _, c := range b.children { + c.computeChildrenSizes(axis) + if b.Dir.axis() == axis { + size += c.computedSize[axis] + } else { + size = max(size, c.computedSize[axis]) + } + } + if b.axes()[axis] == Children { + b.computedSize[axis] = size + } +} + +func (b *Box) solve(axis int) { + if b.Dir.axis() == axis && !b.Overflow { + size := b.marginsSize(axis) + nFlexible := 0 + for _, c := range b.children { + size += c.computedSize[axis] + if c.axes()[axis].isFlexible() { + nFlexible++ + } + } + excess := max(size - b.computedSize[axis], 0) + if nFlexible > 0 { + contribution := excess / nFlexible + for _, c := range b.children { + if c.axes()[axis].isFlexible() { + if contribution == excess - 1 { + contribution = excess + } + shave := min(c.computedSize[axis], contribution, excess) + c.computedSize[axis] -= shave + excess -= shave + } + } + } + for i := len(b.children) - 1; i >= 0; i-- { + c := b.children[i] + if excess == 0 { + break + } + shave := min(c.computedSize[axis], excess) + c.computedSize[axis] -= shave + excess -= shave + } + } else { + maxSize := b.computedSize[axis] - b.marginsSize(axis) + for _, c := range b.children { + c.computedSize[axis] = min(c.computedSize[axis], maxSize) + } + } + for _, c := range b.children { + c.solve(axis) + } +} + +func (b *Box) computeText(axis int) { + if axis == 0 { + maxLine := 0 + line := 0 + for _, t := range b.text { + g := uniseg.NewGraphemes(t.text) + for g.Next() { + line += g.Width() + if g.LineBreak() == uniseg.LineMustBreak { + maxLine = max(maxLine, line) + line = 0 + } + } + } + if b.Width == TextSize { + b.computedSize[axis] = maxLine + b.marginsSize(axis) + } + } else { + var ( + limit = b.computedSize[0] + line []textRun + text, word strings.Builder + lineWidth, wordWidth int + run textRun + ) + breakLine := func() { + if text.Len() != 0 { + line = append(line, textRun {text.String(), run.style}) + } + text.Reset() + b.computedLines = append(b.computedLines, line) + lineWidth = 0 + line = nil + } + flushWord := func() { + if lineWidth + wordWidth > limit { + breakLine() + } + g := uniseg.NewGraphemes(word.String()) + for g.Next() { + if g.Width() > limit { + continue + } + if lineWidth == 0 && g.Str() == " " { + continue + } + text.WriteString(g.Str()) + if g.Width() + lineWidth > limit { + breakLine() + } + lineWidth += g.Width() + } + wordWidth = 0 + word.Reset() + } + for _, run := range b.text { + g := uniseg.NewGraphemes(run.text) + for g.Next() { + if g.LineBreak() == uniseg.LineCanBreak { + flushWord() + } + if lineWidth != 0 || !unicode.IsSpace(g.Runes()[0]) { + word.WriteString(g.Str()) + wordWidth += g.Width() + } + _, end := g.Positions() + if end == len(run.text) { + flushWord() + break + } + if g.LineBreak() == uniseg.LineMustBreak { + flushWord() + breakLine() + } + } + if text.Len() != 0 { + line = append(line, textRun {text.String(), run.style}) + text.Reset() + } + } + if len(line) != 0 || text.Len() != 0 { + breakLine() + } + if b.Height == TextSize { + b.computedSize[axis] = len(b.computedLines) + b.marginsSize(axis) + } + } + for _, c := range b.children { + c.computeText(axis) + } +} + +func (b *Box) computePositions(axis int) { + if b.Dir.axis() == axis { + pos := 0 + b.Margins[axis * 2] + if b.Dir.reverse() { + pos = b.computedSize[axis] - b.Margins[axis * 2 + 1] + } + for _, c := range b.children { + if b.Dir.reverse() { + pos -= c.computedSize[axis] + } + c.computedPosition = pos + c.computedRect.min[axis] = b.computedRect.min[axis] + pos + c.computedRect.max[axis] = c.computedRect.min[axis] + c.computedSize[axis] + if !b.Dir.reverse() { + pos += c.computedSize[axis] + } + c.computePositions(axis) + } + } else { + for _, c := range b.children { + c.computedRect.min[axis] = b.computedRect.min[axis] + b.Margins[axis * 2] + c.computedRect.max[axis] = c.computedRect.min[axis] + c.computedSize[axis] + c.computePositions(axis) + } + } +} + +func (b *Box) drawComputed(parentStyle Style) { + style := parentStyle + if b.Style != nil { + style = *b.Style + for y := b.computedRect.min[1]; y < b.computedRect.max[1]; y++ { + for x := b.computedRect.min[0]; x < b.computedRect.max[0]; x++ { + WriteAt(x, y, " ", style) + } + } + } + for i, l := range b.computedLines { + x := b.computedRect.min[0] + b.Margins[0] + y := b.computedRect.min[1] + b.Margins[2] + i + if y > b.computedRect.max[1] - b.Margins[3] { + break + } + for _, t := range l { + var s Style + if t.style != nil { + s = *t.style + } else { + s = style + } + x += WriteAt(x, y, t.text, s) + } + } + for _, c := range b.children { + c.drawComputed(style) + } +} + +func DrawLayout() { + defer func() { + layout.back = layout.front + layout.front = make(map[string]*Box) + layout.stack = nil + }() + if len(layout.stack) == 0 { + return + } + b := layout.stack[0] + for axis := 0; axis < 2; axis++ { + b.computeFillSizes(axis) + b.computeText(axis) + b.computeChildrenSizes(axis) + b.solve(axis) + b.computePositions(axis) + } + b.drawComputed(DefaultStyle) +} -- cgit v1.2.3