diff --git a/go.mod b/go.mod index b385354..7adebf0 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,9 @@ module github.com/lixenwraith/terminal -go 1.27.1 +go 1.27.2 require ( github.com/lixenwraith/color v0.1.3 - golang.org/x/sys v0.48.0 - golang.org/x/term v0.46.0 + golang.org/x/sys v0.49.0 + golang.org/x/term v0.47.0 ) diff --git a/go.sum b/go.sum index ea6ed8a..7f70a7a 100644 --- a/go.sum +++ b/go.sum @@ -4,5 +4,9 @@ github.com/lixenwraith/color v0.1.3 h1:vSuvtU4zh1mIZS/7UGHmMdsTr+a7eCjUYGbou7kzF github.com/lixenwraith/color v0.1.3/go.mod h1:L/I3Vxp6pqslAIzWyZGgKCm2lyStHCA4vKaUoMUm7bU= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= diff --git a/tui/README.md b/tui/README.md index 43b9b32..56d2360 100644 --- a/tui/README.md +++ b/tui/README.md @@ -94,7 +94,8 @@ r.TextStyled(x, y, s, tui.Style{Fg: fg, Bg: bg, Attr: attr}) String utilities operate on rune counts: `RuneLen`, `Truncate` / `TruncateLeft` / `TruncateMiddle` (ellipsis variants), `PadLeft` / `PadRight` / -`PadCenter`, `WrapText`. +`PadCenter`, `WrapText` (at line breaks and spaces; a path or key longer than +the line breaks after `.`, `,`, `:`, `/`, `[` or `]`). `Style{Fg, Bg, Attr}` bundles cell appearance; most widget option structs accept it. @@ -105,7 +106,7 @@ Widgets are stateless render functions (mostly `Region` methods). Application state lives in plain structs passed by pointer. Available renderers: boxes and lines (`Box`, `BoxFilled`, `HLine`, `VLine` — single, double, -rounded, heavy line types), `List`, `Table`, `Tree`, `TabBar`, `KeyValue` / +rounded, heavy, dashed and ASCII line types), `List`, `Table`, `Tree`, `TabBar`, `KeyValue` / `KeyValueWrap`, `Progress` / `ProgressV` / `Gauge` / `Spinner`, `ProgressOverlay`, `Sparkline` / `SparklineV`, `Input` / `TextField`, `Editor`, `Modal` / `Overlay` / `ConfirmDialog`, `ScrollBar` / @@ -122,9 +123,11 @@ role's `Attr` may carry color bits, a palette index or the terminal's own color, so an application builds one theme per color tier and every control draws alike in each: `DefaultTheme` is dark true color, `Theme16` uses the ANSI 16 on the terminal's own background, `MonoTheme` uses attributes -alone, and `ThemeFor(term.ColorMode())` picks among them. A selection always changes a glyph (brackets, a pointer, -the focus mark), so it reads without color. `GlyphsUnicode`, `GlyphsCP437` -(text consoles) and `GlyphsASCII` (outside UTF-8) are the glyph sets. +alone, and `ThemeFor(term.ColorMode())` picks among them. A selection +always changes a glyph (a radio mark, a pointer, the focus mark), so it reads +without color. `GlyphsUnicode`, `GlyphsCP437` (text consoles) and +`GlyphsASCII` (outside UTF-8) are the glyph sets; `Glyphs.Line` is the line +type of frames, rules and wires. ```go th := tui.DefaultTheme @@ -136,7 +139,7 @@ value.TextInput(port, "8080", focus.Index == 0, th) // the default as placeholde y += rows value, rows = form.Field(y, 10, tui.Field{Label: "format"}, focus.Index == 1, th) -value.Choice(0, 0, []string{"raw", "txt", "json"}, format, th) // raw [txt] json +value.Radio(0, 0, []string{"raw", "txt", "json"}, format, th) // ○ raw ● txt ○ json y += rows form.Group(y, "tls", "on, pinned", tlsOpen, focus.Index == 2, th) @@ -144,16 +147,29 @@ form.Group(y, "tls", "on, pinned", tlsOpen, focus.Index == 2, th) - `Focus` is the focus ring: `HandleKey` moves it on Tab and Shift+Tab. - `Field` draws the focus mark, the label, the required mark, and the error, - or the help while focused, and returns the region for the value. -- `TextInput` draws a `TextFieldState`, its placeholder muted while empty. + or the help while focused, wrapped, and returns the region for the value. + The label leaves the value at least half the row; label width 0 stacks the + label above the value, for narrow screens. +- `TextInput` draws a `TextFieldState`, its placeholder muted while empty, + scrolled to the cursor with a muted `…` where text is out of view. `TextFieldState.Accept` filters typed and pasted runes (`AcceptInteger`, `AcceptNumber`); `Paste` inserts an `EventPaste`'s text with line breaks as spaces and control characters dropped. -- `Choice` draws a segmented choice; `StepChoice` moves it on Left and Right. +- `Radio` draws radio buttons in a row, the chosen one marked; too wide, it + shows the chosen one and its neighbours. `StepChoice` moves it on Left and + Right. - `Toggle` draws an on/off switch. - `Group` draws a foldable group's header, and its summary while folded. - `OptionListState` and `OptionList` filter options by what is typed, - matching name or hint regardless of case. + matching name or hint regardless of case. Hints sit beside the names, or, + narrow, the cursor's hint wraps below them; `Rows(w)` is the height to + size a dialog by. With `Menu` there is no filter and `Pick` finds an + option by its `Key`, shown before its name. +- `Frame` fills a region, borders it with its title, and returns the inside; + sized with `Center` to what it holds, a dialog ends on its last line. +- `Rule` draws a line with a title and a hint, dropping the hint first. +- `Wires` lay lines on a grid by the arms that meet in each cell (`H`, `V`), + and `DrawWires` draws every junction, tee and crossing joined. ### Scrollable list with scrollbar @@ -229,7 +245,9 @@ Pure logic, no rendering — usable independently: - `ScrollState` — item-index scrolling with selection (`SelectNext/Prev`, `EnsureVisible`, `PageUp/Down`, `AtTop/AtBottom`) - `ViewportScroll` — row-based content scrolling with viewport clipping - (`ClipToViewport` maps content rows to visible rows) + (`ClipToViewport` maps content rows to visible rows, `EnsureRange` keeps a + range in view); `Region.Window` draws content taller than the region off + screen and shows the rows in view - `TreeState` + `TreeExpansion` + `TreeBuilder` — cursor/scroll, expand/collapse keyed state, hierarchical → flat visible-node list - `EditorState`, `TextFieldState` — text content, cursor, scroll, key handling diff --git a/tui/border.go b/tui/border.go index e96147e..6cf8eb4 100644 --- a/tui/border.go +++ b/tui/border.go @@ -1,6 +1,8 @@ package tui import ( + "strings" + "github.com/lixenwraith/color" "github.com/lixenwraith/terminal" ) @@ -14,26 +16,39 @@ const ( LineRounded // ╭─╮│╰╯ LineHeavy // ┏━┓┃┗┛ LineNone // spaces (invisible border with padding) + LineASCII // +-+|++, for a terminal outside UTF-8 + LineDashed // ╭╌╮╎╰╯ ) -// boxChars contains box drawing character sets indexed by LineType -var boxChars = [...][6]rune{ - LineSingle: {'┌', '─', '┐', '│', '└', '┘'}, - LineDouble: {'╔', '═', '╗', '║', '╚', '╝'}, - LineRounded: {'╭', '─', '╮', '│', '╰', '╯'}, - LineHeavy: {'┏', '━', '┓', '┃', '┗', '┛'}, - LineNone: {' ', ' ', ' ', ' ', ' ', ' '}, +// lineChars are each LineType's runes by the arms that meet in a cell: up 1, +// right 2, down 4, left 8, so corners, tees and crossings all join +var lineChars = [...]string{ + LineSingle: " │─└││┌├─┘─┴┐┤┬┼", + LineDouble: " ║═╚║║╔╠═╝═╩╗╣╦╬", + LineRounded: " │─╰││╭├─╯─┴╮┤┬┼", + LineHeavy: " ┃━┗┃┃┏┣━┛━┻┓┫┳╋", + LineNone: " ", + LineASCII: " |-+||++-+-+++++", + LineDashed: " ╎╌╰╎╎╭├╌╯╌┴╮┤┬┼", } const ( - boxTL = 0 // top-left - boxH = 1 // horizontal - boxTR = 2 // top-right - boxV = 3 // vertical - boxBL = 4 // bottom-left - boxBR = 5 // bottom-right + boxTL = 6 // top-left + boxH = 10 // horizontal + boxTR = 12 // top-right + boxV = 5 // vertical + boxBL = 3 // bottom-left + boxBR = 9 // bottom-right ) +// runes returns the line's runes by arms; an unknown type draws single +func (l LineType) runes() []rune { + if l >= LineType(len(lineChars)) { + l = LineSingle + } + return []rune(lineChars[l]) +} + // --- Box Rendering --- // Box draws border around region edge @@ -45,37 +60,35 @@ func (r Region) Box(line LineType, fg color.RGB) { // with off rows scrolled past its top edge. Rows outside the box are skipped, // so a partially visible box keeps its sides and grows no false edges. func (r Region) BoxClipped(line LineType, fg color.RGB, totalH, off int) { + r.boxStyled(line, Style{Fg: fg}, totalH, off) +} + +// BoxStyle draws a border around the region's edge in a theme's style +func (r Region) BoxStyle(line LineType, s Style) { + r.boxStyled(line, s, r.H, 0) +} + +func (r Region) boxStyled(line LineType, s Style, totalH, off int) { if r.W < 2 || r.H < 1 || totalH < 2 { return } - if line >= LineType(len(boxChars)) { - line = LineSingle + chars := line.runes() + edge := func(y int, left, right rune) { + r.TextStyled(0, y, string(left), s) + r.TextStyled(r.W-1, y, string(right), s) } - - chars := boxChars[line] - bg := color.RGB{} // Transparent (use existing bg) - for y := range r.H { - c := off + y - if c < 0 || c >= totalH { - continue - } - switch c { - case 0: - r.Cell(0, y, chars[boxTL], fg, bg, terminal.AttrNone) - r.Cell(r.W-1, y, chars[boxTR], fg, bg, terminal.AttrNone) - for x := 1; x < r.W-1; x++ { - r.Cell(x, y, chars[boxH], fg, bg, terminal.AttrNone) - } - case totalH - 1: - r.Cell(0, y, chars[boxBL], fg, bg, terminal.AttrNone) - r.Cell(r.W-1, y, chars[boxBR], fg, bg, terminal.AttrNone) - for x := 1; x < r.W-1; x++ { - r.Cell(x, y, chars[boxH], fg, bg, terminal.AttrNone) + switch c := off + y; { + case c < 0 || c >= totalH: + case c == 0 || c == totalH-1: + left, right := chars[boxTL], chars[boxTR] + if c > 0 { + left, right = chars[boxBL], chars[boxBR] } + r.TextStyled(1, y, strings.Repeat(string(chars[boxH]), max(0, r.W-2)), s) + edge(y, left, right) default: - r.Cell(0, y, chars[boxV], fg, bg, terminal.AttrNone) - r.Cell(r.W-1, y, chars[boxV], fg, bg, terminal.AttrNone) + edge(y, chars[boxV], chars[boxV]) } } } @@ -99,10 +112,7 @@ func (r Region) HLine(y int, line LineType, fg color.RGB) { if y < 0 || y >= r.H { return } - if line >= LineType(len(boxChars)) { - line = LineSingle - } - ch := boxChars[line][boxH] + ch := line.runes()[boxH] for x := 0; x < r.W; x++ { r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone) } @@ -113,10 +123,7 @@ func (r Region) VLine(x int, line LineType, fg color.RGB) { if x < 0 || x >= r.W { return } - if line >= LineType(len(boxChars)) { - line = LineSingle - } - ch := boxChars[line][boxV] + ch := line.runes()[boxV] for y := 0; y < r.H; y++ { r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone) } @@ -127,11 +134,7 @@ func (r Region) Divider(y int, label string, line LineType, fg color.RGB) { if y < 0 || y >= r.H { return } - if line >= LineType(len(boxChars)) { - line = LineSingle - } - - hChar := boxChars[line][boxH] + hChar := line.runes()[boxH] // Fill with horizontal line for x := 0; x < r.W; x++ { @@ -171,3 +174,77 @@ func (r Region) Card(title string, line LineType, fg color.RGB) Region { return r.Inset(1) } + +// --- Themed frames, rules and wires --- + +// Frame fills the region with the theme's text style and draws its border +// in Glyphs.Line, the title on the top edge, and returns the inside with a +// column of padding each side. Callers size it to its content, with Center. +func (r Region) Frame(title string, th Theme) Region { + r.FillStyle(th.Text) + r.BoxStyle(th.Glyphs.Line, th.Border) + if title != "" && r.W > 6 { + r.TextStyled(2, 0, " "+Truncate(title, r.W-6)+" ", th.Accent) + } + return r.Sub(2, 1, r.W-4, r.H-2) +} + +// Rule draws a line across row y, the title at its third column and the +// hint muted at its end. The title keeps its room: the hint is dropped where +// both do not fit. +func (r Region) Rule(y int, title, hint string, th Theme) { + r.TextStyled(0, y, strings.Repeat(string(th.Glyphs.Line.runes()[boxH]), max(0, r.W)), th.Border) + if title != "" && r.W > 6 { + title = " " + Truncate(title, r.W-6) + " " + r.TextStyled(2, y, title, th.Text) + } + if hint != "" && RuneLen(title)+RuneLen(hint)+6 <= r.W { + r.TextStyled(r.W-RuneLen(hint)-3, y, " "+hint+" ", th.Muted) + } +} + +// Wires are lines on a grid, each cell holding the arms that meet there: up +// 1, right 2, down 4, left 8. Wherever lines branch or cross, the cell draws +// the junction, whatever order they were laid in. +type Wires struct { + W int + Arms []uint8 +} + +// NewWires creates an empty grid w by h +func NewWires(w, h int) Wires { + return Wires{W: w, Arms: make([]uint8, max(0, w*h))} +} + +// H lays a wire along row y between columns x0 and x1 +func (g Wires) H(y, x0, x1 int) { + for x := min(x0, x1); x < max(x0, x1); x++ { + g.arm(x, y, 2) + g.arm(x+1, y, 8) + } +} + +// V lays a wire down column x between rows y0 and y1 +func (g Wires) V(x, y0, y1 int) { + for y := min(y0, y1); y < max(y0, y1); y++ { + g.arm(x, y, 4) + g.arm(x, y+1, 1) + } +} + +// arm adds an arm to a cell; one off the grid is dropped +func (g Wires) arm(x, y int, a uint8) { + if i := y*g.W + x; x >= 0 && x < g.W && y >= 0 && i < len(g.Arms) { + g.Arms[i] |= a + } +} + +// DrawWires draws every cell of the grid that has arms, in the line type +func (r Region) DrawWires(g Wires, line LineType, s Style) { + chars := line.runes() + for i, arms := range g.Arms { + if arms != 0 { + r.TextStyled(i%g.W, i/g.W, string(chars[arms&15]), s) + } + } +} diff --git a/tui/choice.go b/tui/choice.go index fec898e..1af9c78 100644 --- a/tui/choice.go +++ b/tui/choice.go @@ -2,20 +2,51 @@ package tui import "github.com/lixenwraith/terminal" -// Choice draws options in a row at (x, y), the chosen one bracketed in the -// accent style and the rest muted ("raw [txt] json"), and returns the -// columns it drew -func (r Region) Choice(x, y int, options []string, chosen int, th Theme) int { - start := x - for i, o := range options { - open, close, style := " ", " ", th.Muted - if i == chosen { - open, close, style = "[", "]", th.Accent +// Radio draws options as radio buttons in a row at (x, y): the chosen one +// with the On glyph in the accent style, the rest with Off, muted ("○ raw +// ● txt ○ json"); chosen -1 marks none. Where the row does not fit, it shows +// the options around the chosen one, a muted '…' where others are hidden. +// It returns the columns it drew. +func (r Region) Radio(x, y int, options []string, chosen int, th Theme) int { + width := func(lo, hi int) int { // the window [lo, hi), its '…' marks included + w := -2 + for _, o := range options[lo:hi] { + w += RuneLen(o) + 4 } - r.TextStyled(x, y, open+o+close, style) - x += RuneLen(o) + 2 + return w + 2*min(lo, 1) + 3*min(len(options)-hi, 1) } - return x - start + lo, hi := 0, len(options) + if width(lo, hi) > r.W-x && hi > 0 { + lo = max(0, min(chosen, hi-1)) + hi = lo + 1 + for grown := true; grown; { + grown = false + if hi < len(options) && width(lo, hi+1) <= r.W-x { + hi, grown = hi+1, true + } + if lo > 0 && width(lo-1, hi) <= r.W-x { + lo, grown = lo-1, true + } + } + } + start := x + if lo > 0 { + r.TextStyled(x, y, "…", th.Muted) + x += 2 + } + for i := lo; i < hi; i++ { + mark, style := th.Glyphs.Off, th.Muted + if i == chosen { + mark, style = th.Glyphs.On, th.Accent + } + r.TextStyled(x, y, Truncate(string(mark)+" "+options[i], max(0, r.W-x)), style) + x += RuneLen(options[i]) + 4 + } + if hi < len(options) { + r.TextStyled(x, y, "…", th.Muted) + x += 3 + } + return max(0, min(x-2, r.W)-start) } // StepChoice moves a choice among n options on Left or Right (h, l), diff --git a/tui/document.go b/tui/document.go index 308ca12..2703905 100644 --- a/tui/document.go +++ b/tui/document.go @@ -203,11 +203,7 @@ func (r Region) docSection(y, x, w int, label string, opts DocOpts) { r.TextStyled(x, y, label, opts.HeaderStyle) return } - line := opts.Rule - if line >= LineType(len(boxChars)) { - line = LineSingle - } - ch := boxChars[line][boxH] + ch := opts.Rule.runes()[boxH] for i := range w { r.Cell(x+i, y, ch, opts.RuleStyle.Fg, opts.RuleStyle.Bg, opts.RuleStyle.Attr) } diff --git a/tui/field.go b/tui/field.go index 73cf5e1..ec40979 100644 --- a/tui/field.go +++ b/tui/field.go @@ -36,36 +36,49 @@ type Field struct { // Field draws a form row at y: the focus mark, the label in a column labelW // wide with the required mark, and below, the error, or the help while -// focused. It returns the region for the value beside the label, where the -// caller draws a TextInput, Choice or Toggle, and the rows it used. +// focused, wrapped. The label column leaves the value half the row; labelW 0 +// stacks the label on its own row and the value under it, indented. It +// returns the region for the value, where the caller draws a TextInput, +// Radio or Toggle, and the rows it used. func (r Region) Field(y, labelW int, f Field, focused bool, th Theme) (Region, int) { label := th.Muted if focused { label = th.Text r.TextStyled(0, y, string(th.Glyphs.Focus), th.Accent) } + x, rows := 4, 2 // stacked: the label's own row, the value under it + if labelW > 0 { + labelW = min(labelW, max(1, (r.W-3)/2)) + x, rows = 2+labelW+1, 1 + } else { + labelW = r.W - 2 + } name := f.Label if f.Required { name = Truncate(name, labelW-2) r.TextStyled(2+RuneLen(name)+1, y, string(th.Glyphs.Required), th.Accent) } r.TextStyled(2, y, Truncate(name, labelW), label) + value := r.Sub(x, y+rows-1, r.W-x, 1) - x := 2 + labelW + 1 note, style := f.Error, th.Error if note == "" && focused { note, style = f.Help, th.Muted } if note == "" { - return r.Sub(x, y, r.W-x, 1), 1 + return value, rows } - r.TextStyled(x, y+1, Truncate(note, r.W-x), style) - return r.Sub(x, y, r.W-x, 1), 2 + lines := WrapText(note, r.W-x) + for i, l := range lines { + r.TextStyled(x, y+rows+i, l, style) + } + return value, rows + len(lines) } // TextInput draws state's text on r's first row, or, empty, the placeholder // muted: a default shows what an empty value means. Focused, it draws on the -// Input background with the cursor, scrolling to keep the cursor in view. +// Input background with the cursor, scrolling to keep the cursor in view; a +// muted '…' marks text out of view. func (r Region) TextInput(state *TextFieldState, placeholder string, focused bool, th Theme) { if r.W < 1 || r.H < 1 { return @@ -75,7 +88,7 @@ func (r Region) TextInput(state *TextFieldState, placeholder string, focused boo bg = th.Input } state.AdjustScroll(r.W) - text, style := []rune(placeholder), th.Muted.On(bg) + text, style := []rune(Truncate(placeholder, r.W)), th.Muted.On(bg) if len(state.Text) > 0 { text, style = state.Text[state.Scroll:], th.Text.On(bg) } @@ -86,6 +99,14 @@ func (r Region) TextInput(state *TextFieldState, placeholder string, focused boo } r.Cell(x, 0, ch, style.Fg, style.Bg, style.Attr) } + // Text scrolled out of view on either side is marked, never under the cursor + hidden := th.Muted.On(bg) + if state.Scroll > 0 && (state.Cursor != state.Scroll || !focused) { + r.Cell(0, 0, '…', hidden.Fg, hidden.Bg, hidden.Attr) + } + if end := r.W - 1; len(text) > r.W && (state.Cursor-state.Scroll != end || !focused) { + r.Cell(end, 0, '…', hidden.Fg, hidden.Bg, hidden.Attr) + } if focused { x, ch := state.Cursor-state.Scroll, ' ' if x < len(text) { diff --git a/tui/optionlist.go b/tui/optionlist.go index ef8ef89..d6757c3 100644 --- a/tui/optionlist.go +++ b/tui/optionlist.go @@ -10,17 +10,24 @@ import ( type Option struct { Name string // what is chosen Hint string // a line about it + Key rune // in a menu, the key that picks it at once } // OptionListState filters options by the text typed into it, matching name -// or hint regardless of case, and keeps a cursor on the matches +// or hint regardless of case, and keeps a cursor on the matches. A menu has +// no filter: each option's Key picks it. type OptionListState struct { Options []Option Filter TextFieldState - Cursor int // index into Matches - Scroll int // first match shown + Cursor int // index into Matches + Scroll int // first match shown + Menu bool // no filter; Pick finds an option by its Key } +// minHintW is the narrowest hint column beside the names; narrower, the +// cursor's hint wraps below the list instead +const minHintW = 16 + // NewOptionListState creates a list showing every option func NewOptionListState(options []Option) *OptionListState { return &OptionListState{Options: options} @@ -59,7 +66,7 @@ func (s *OptionListState) HandleKey(key terminal.Key, r rune, mod terminal.Modif s.Cursor-- case key == terminal.KeyDown: s.Cursor++ - case !s.Filter.HandleKey(key, r, mod): + case s.Menu || !s.Filter.HandleKey(key, r, mod): return false case s.Filter.Value() != filter: s.Cursor = 0 @@ -68,49 +75,102 @@ func (s *OptionListState) HandleKey(key terminal.Key, r rune, mod terminal.Modif return true } +// Pick returns the index in Options of the option whose Key is r +func (s *OptionListState) Pick(r rune) (int, bool) { + for i, o := range s.Options { + if o.Key == r && r != 0 { + return i, true + } + } + return 0, false +} + // Paste adds pasted text to the filter func (s *OptionListState) Paste(text string) bool { - if !s.Filter.Paste(text) { + if s.Menu || !s.Filter.Paste(text) { return false } s.Cursor = 0 return true } -// OptionList draws the filter on the first row and the matches below it, -// the cursor row marked with the pointer on the Selected background, each -// hint muted in a column after the names +// layout is how the list fits w columns: the names' width, the column the +// names start at, and the rows below the list that hold the cursor's hint +// where hints do not fit beside the names +func (s *OptionListState) layout(w int) (nameW, nameX, hintRows int) { + nameX = 2 + if s.Menu { + nameX = 4 + } + for _, o := range s.Options { + nameW = max(nameW, RuneLen(o.Name)) + } + nameW = min(nameW, (w-nameX)/2) + if w-nameX-nameW-2 >= minHintW { + return nameW, nameX, 0 + } + nameW = w - nameX + for _, o := range s.Options { + if o.Hint != "" { + hintRows = max(hintRows, min(3, len(WrapText(o.Hint, max(1, w-2))))) + } + } + return nameW, nameX, hintRows +} + +// Rows returns the rows the list takes at width w to show every option: the +// filter, a row each, and the cursor's hint where it does not fit beside them +func (s *OptionListState) Rows(w int) int { + _, _, hintRows := s.layout(w) + return 1 - boolToInt(s.Menu) + len(s.Options) + hintRows +} + +// OptionList draws the filter on the first row, a menu none, and the +// matches below, the cursor row marked with the pointer on the Selected +// background and a menu's keys before the names in the accent style. Hints +// sit muted beside the names or, where that column would be narrower than +// 16, the cursor's hint wraps onto the list's last rows. func (r Region) OptionList(s *OptionListState, th Theme) { if r.W < 4 || r.H < 2 { return } - r.TextStyled(0, 0, "/", th.Muted) - r.Sub(2, 0, r.W-2, 1).TextInput(&s.Filter, "type to filter", true, th) - + top := 1 + if s.Menu { + top = 0 + } else { + r.TextStyled(0, 0, "/", th.Muted) + r.Sub(2, 0, r.W-2, 1).TextInput(&s.Filter, "type to filter", true, th) + } m := s.Matches() - rows := r.H - 1 if len(m) == 0 { - r.TextStyled(2, 1, "no match", th.Muted) + r.TextStyled(2, top, "no match", th.Muted) return } - s.Scroll = max(0, min(s.Scroll, s.Cursor, len(m)-rows)) - s.Scroll = max(s.Scroll, s.Cursor-rows+1) - - nameW := 0 - for _, i := range m { - nameW = max(nameW, RuneLen(s.Options[i].Name)) - } - nameW = min(nameW, r.W/2) + nameW, nameX, hintRows := s.layout(r.W) + rows := max(1, r.H-top-hintRows) + s.Scroll = AdjustScroll(s.Cursor, ClampScroll(s.Scroll, rows, len(m)), rows, len(m)) for y := 0; y < rows && s.Scroll+y < len(m); y++ { o := s.Options[m[s.Scroll+y]] - row := r.Sub(0, 1+y, r.W, 1) + row := r.Sub(0, top+y, r.W, 1) var bg Style if s.Scroll+y == s.Cursor { bg = th.Selected row.FillStyle(th.Text.On(bg)) row.TextStyled(0, 0, string(th.Glyphs.Pointer), th.Accent.On(bg)) } - row.TextStyled(2, 0, Truncate(o.Name, nameW), th.Text.On(bg)) - row.TextStyled(2+nameW+2, 0, Truncate(o.Hint, r.W-nameW-4), th.Muted.On(bg)) + if s.Menu && o.Key != 0 { + row.TextStyled(2, 0, string(o.Key), th.Accent.On(bg)) + } + row.TextStyled(nameX, 0, Truncate(o.Name, nameW), th.Text.On(bg)) + if hintRows == 0 { + row.TextStyled(nameX+nameW+2, 0, Truncate(o.Hint, r.W-nameX-nameW-2), th.Muted.On(bg)) + } + } + hint, n := WrapText(s.Options[m[s.Cursor]].Hint, max(1, r.W-2)), min(hintRows, r.H-top-rows) + if len(hint) > n && n > 0 { // the last row shown ends in '…' + hint = append(hint[:n-1], Truncate(hint[n-1]+" "+hint[n], r.W-2)) + } + for y, l := range hint[:min(n, len(hint))] { // on the last rows, so the list ends on text + r.TextStyled(2, r.H-min(n, len(hint))+y, l, th.Muted) } } diff --git a/tui/text.go b/tui/text.go index 8b7a048..0b41389 100644 --- a/tui/text.go +++ b/tui/text.go @@ -1,6 +1,7 @@ package tui import ( + "strings" "unicode/utf8" ) @@ -118,13 +119,23 @@ func PadCenter(s string, width int) string { // --- Text wrapping --- -// WrapText wraps text at word boundaries to fit width -// Returns slice of lines, each no longer than width +// WrapText wraps text to fit width: at each line break, at spaces, or, in a +// word longer than a line, after its last '.', ',', ':', '/', '[' or ']' +// that fits (a path or a key such as a[0].b); a word without one is cut. +// Returns slice of lines, each no longer than width. func WrapText(s string, width int) []string { if width <= 0 { return nil } + var lines []string + for line := range strings.SplitSeq(s, "\n") { + lines = append(lines, wrapLine(line, width)...) + } + return lines +} +// wrapLine wraps one line, s holding no line break +func wrapLine(s string, width int) []string { runes := []rune(s) if len(runes) == 0 { return []string{""} @@ -132,7 +143,7 @@ func WrapText(s string, width int) []string { var lines []string lineStart := 0 - lastSpace := -1 + lastSpace, lastMark := -1, -1 for i := 0; i <= len(runes); i++ { // Check if we need to wrap @@ -147,9 +158,11 @@ func WrapText(s string, width int) []string { // Need to wrap wrapAt := i - if lastSpace > lineStart { - // Wrap at last space + switch { + case lastSpace > lineStart: wrapAt = lastSpace + case lastMark > lineStart: + wrapAt = lastMark } lines = append(lines, string(runes[lineStart:wrapAt])) @@ -160,13 +173,16 @@ func WrapText(s string, width int) []string { } else { lineStart = wrapAt } - lastSpace = -1 + lastSpace, lastMark = -1, -1 } - // Track spaces for word wrapping + // Track spaces for word wrapping, and the marks a long word breaks after if i < len(runes) && runes[i] == ' ' { lastSpace = i } + if i < len(runes) && strings.ContainsRune(".,:/[]", runes[i]) { + lastMark = i + 1 + } } if len(lines) == 0 { @@ -189,4 +205,3 @@ func RepeatRune(r rune, n int) string { } return string(runes) } - diff --git a/tui/textfield_state.go b/tui/textfield_state.go index 5a7387b..141023b 100644 --- a/tui/textfield_state.go +++ b/tui/textfield_state.go @@ -258,12 +258,14 @@ func (t *TextFieldState) MoveToEnd() { // --- Scroll management --- -// AdjustScroll updates scroll to keep cursor visible within viewport width +// AdjustScroll updates scroll to keep the cursor visible within viewport +// width, and the viewport filled up to the end of the text func (t *TextFieldState) AdjustScroll(viewportW int) { if viewportW <= 0 { return } t.clampCursor() + t.Scroll = min(t.Scroll, max(0, len(t.Text)+1-viewportW)) if t.Cursor < t.Scroll { t.Scroll = t.Cursor } diff --git a/tui/theme.go b/tui/theme.go index 9130ed5..483f2e2 100644 --- a/tui/theme.go +++ b/tui/theme.go @@ -25,18 +25,19 @@ type Theme struct { // it reads without color. A text console's font takes GlyphsCP437, a // terminal outside UTF-8 GlyphsASCII. type Glyphs struct { - Focus rune // beside the focused control - Pointer rune // the cursor row of a list - Folded rune // a closed group - Unfolded rune // an open group - On, Off rune // a toggle - Required rune // after a required field's label + Focus rune // beside the focused control + Pointer rune // the cursor row of a list + Folded rune // a closed group + Unfolded rune // an open group + On, Off rune // a toggle + Required rune // after a required field's label + Line LineType // frames, rules and wires } var ( GlyphsUnicode = Glyphs{Focus: '▌', Pointer: '▸', Folded: '▸', Unfolded: '▾', On: '●', Off: '○', Required: '*'} GlyphsCP437 = Glyphs{Focus: '▌', Pointer: '►', Folded: '►', Unfolded: '▼', On: '•', Off: '○', Required: '*'} - GlyphsASCII = Glyphs{Focus: '>', Pointer: '>', Folded: '+', Unfolded: '-', On: '*', Off: '-', Required: '*'} + GlyphsASCII = Glyphs{Focus: '>', Pointer: '>', Folded: '+', Unfolded: '-', On: '*', Off: '-', Required: '*', Line: LineASCII} ) // DefaultTheme is a dark theme for true color and 256-color terminals diff --git a/tui/viewport.go b/tui/viewport.go index 7730f95..3104631 100644 --- a/tui/viewport.go +++ b/tui/viewport.go @@ -1,5 +1,7 @@ package tui +import "github.com/lixenwraith/terminal" + // ViewportScroll manages row-based scroll for content regions // Distinct from ScrollState which is item-index based type ViewportScroll struct { @@ -117,3 +119,24 @@ func (v *ViewportScroll) EnsureRange(y, h int) { } v.clamp() } + +// Window shows content h rows tall through the region, from v's offset: +// draw paints a buffer as wide as the region and h tall, filled with bg, and +// the rows in view are copied. Window sets v's heights; a caller keeping a +// row in view sets them first and calls EnsureRange. +func (r Region) Window(h int, v *ViewportScroll, bg Style, draw func(Region)) { + h = max(0, h) + v.SetDimensions(h, r.H) + if r.W <= 0 || r.H <= 0 { + return + } + cells := make([]terminal.Cell, r.W*h) + full := NewRegion(cells, r.W, 0, 0, r.W, h) + full.FillStyle(bg) + draw(full) + shown := min(r.H, h-v.Offset) + for y := range shown { + copy(r.Cells[(r.Y+y)*r.TotalW+r.X:][:r.W], cells[(v.Offset+y)*r.W:][:r.W]) + } + r.Sub(0, shown, r.W, r.H-shown).FillStyle(bg) +} diff --git a/tui/widget_test.go b/tui/widget_test.go index 21f4ff5..7b1530f 100644 --- a/tui/widget_test.go +++ b/tui/widget_test.go @@ -1,6 +1,9 @@ package tui import ( + "fmt" + "runtime" + "slices" "strings" "testing" @@ -50,7 +53,7 @@ func TestSelectionChangesAGlyph(t *testing.T) { func selectionChangesAGlyph(t *testing.T, th Theme) { options := NewOptionListState([]Option{{Name: "file"}, {Name: "tcp"}}) for name, draw := range map[string]func(r Region, on bool){ - "choice": func(r Region, on bool) { r.Choice(0, 0, []string{"raw", "txt"}, map[bool]int{true: 1}[on], th) }, + "radio": func(r Region, on bool) { r.Radio(0, 0, []string{"raw", "txt"}, map[bool]int{true: 1}[on], th) }, "toggle": func(r Region, on bool) { r.Toggle(0, 0, on, th) }, "field": func(r Region, on bool) { r.Field(0, 6, Field{Label: "port"}, on, th) }, "group": func(r Region, on bool) { r.Group(0, "tls", "", on, false, th) }, @@ -248,3 +251,199 @@ func TestGroupSummarisesWhileFolded(t *testing.T) { t.Fatalf("%q %q", row(folded, 0), row(open, 0)) } } + +// A radio row too wide for its region shows the chosen option and those +// around it, a '…' on each side where others are hidden, and reports the +// columns drawn +func TestRadioKeepsTheChosenInView(t *testing.T) { + options := []string{"alpha", "beta", "gamma", "delta"} + for _, c := range []struct { + chosen, w int + want string + }{ + {3, 40, "○ alpha ○ beta ○ gamma ● delta"}, + {3, 30, "… ○ beta ○ gamma ● delta"}, + {3, 16, "… ● delta"}, + {3, 7, "… ● de…"}, + {0, 17, "● alpha …"}, + {1, 19, "○ alpha ● beta …"}, + {1, 25, "… ● beta ○ gamma …"}, + } { + r := canvas(c.w, 1, MonoTheme) + if n := r.Radio(0, 0, options, c.chosen, MonoTheme); row(r, 0) != c.want || n != min(c.w, RuneLen(c.want)) { + t.Errorf("%d chosen in %d columns: %q, %d drawn; want %q", c.chosen, c.w, row(r, 0), n, c.want) + } + } +} + +// A field's view pulls back when its text shrinks, so no column is left +// blank while text is hidden, and a muted '…' marks text out of view, never +// under the cursor +func TestTextInputPullsBackAndMarksHiddenText(t *testing.T) { + f := NewTextFieldState(strings.Repeat("abcdefghij", 2)) + draw := func(focused bool) string { + r := canvas(10, 1, MonoTheme) + r.TextInput(f, "", focused, MonoTheme) + return row(r, 0) + } + if got := draw(true); got != "…cdefghij" { + t.Fatalf("the cursor after 20 in 10 columns: %q", got) + } + for range 8 { + f.HandleKey(terminal.KeyBackspace, 0, 0) + } + if got := draw(true); got != "…efghijab" { + t.Fatalf("12 left: %q, scroll %d", got, f.Scroll) + } + f.HandleKey(terminal.KeyHome, 0, 0) + if got := draw(true); got != "abcdefghi…" { + t.Fatalf("the cursor at the start: %q", got) + } +} + +// A field's label leaves the value at least half the row; label width 0 +// stacks the label on its own row, whole, and the value under it +func TestFieldLeavesTheValueHalfTheRow(t *testing.T) { + for _, w := range []int{12, 30, 80} { + r := canvas(w, 3, MonoTheme) + if v, rows := r.Field(0, 40, Field{Label: "password_file"}, false, MonoTheme); v.W < (w-3)/2 || rows != 1 { + t.Errorf("%d columns: value %d wide, %d rows", w, v.W, rows) + } + r = canvas(w, 3, MonoTheme) + v, rows := r.Field(0, 0, Field{Label: "password_file"}, false, MonoTheme) + if label := Truncate("password_file", w-2); row(r, 0) != " "+label || v.Y != 1 || v.W != w-4 || rows != 2 { + t.Errorf("%d columns stacked: %q, value at row %d, %d wide, %d rows", w, row(r, 0), v.Y, v.W, rows) + } + } +} + +// Text wraps at each line break, keeping an empty line, and a word longer +// than the line breaks after a '.', ',', ':', '/', '[' or ']' +func TestWrapTextBreaksAtLinesAndMarks(t *testing.T) { + for text, want := range map[string][]string{ + "ab\ncd ef\n\ngh": {"ab", "cd ef", "", "gh"}, + "key: /etc/logwisp/very/long/path.pem": {"key:", "/etc/logwisp/very/", "long/path.pem"}, + } { + if got := WrapText(text, map[bool]int{true: 5, false: 20}[strings.HasPrefix(text, "ab")]); !slices.Equal(got, want) { + t.Errorf("%q: %q, want %q", text, got, want) + } + } +} + +// Wrapping is linear in the lines: 4000 lines allocate under 1 MiB +func TestWrapTextIsLinearInLines(t *testing.T) { + text := strings.Repeat("x\n", 4000) + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + lines := WrapText(text, 80) + runtime.ReadMemStats(&after) + if len(lines) != 4001 || after.TotalAlloc-before.TotalAlloc > 1<<20 { + t.Fatalf("%d lines, %d bytes allocated", len(lines), after.TotalAlloc-before.TotalAlloc) + } +} + +// Where the hint column would be narrow, the list shows names alone and the +// cursor's hint wraps below them, in rows Rows counts; wide, hints sit beside +func TestNarrowOptionListShowsTheCursorsHint(t *testing.T) { + s := NewOptionListState([]Option{{Name: "pipe", Hint: "standard input to standard output"}, {Name: "tail", Hint: "follow files"}}) + s.Cursor = 1 + for w, want := range map[int][]string{ + 60: {"/ type to filter", " pipe standard input to standard output", "▸ tail follow files"}, + 20: {"/ type to filter", " pipe", "▸ tail", "", " follow files"}, + } { + r := canvas(w, s.Rows(w), MonoTheme) + r.OptionList(s, MonoTheme) + var got []string + for y := range r.H { + got = append(got, row(r, y)) + } + if !slices.Equal(got, want) { + t.Errorf("%d columns: %q, want %q", w, got, want) + } + } +} + +// A menu has no filter: a key picks its option, and typing filters nothing +func TestMenuPicksByKey(t *testing.T) { + s := NewOptionListState([]Option{{Name: "run", Key: 'r'}, {Name: "command", Key: 'c'}}) + s.Menu = true + if i, ok := s.Pick('c'); !ok || i != 1 { + t.Fatalf("c: %d, %v", i, ok) + } + if _, ok := s.Pick('x'); ok || s.HandleKey(terminal.KeyRune, 'x', 0) || s.Paste("r") || s.Filter.Value() != "" { + t.Fatalf("x picked or filtered: %q", s.Filter.Value()) + } + r := canvas(20, s.Rows(20), MonoTheme) + r.OptionList(s, MonoTheme) + if r.H != 2 || row(r, 0) != "▸ r run" || row(r, 1) != " c command" { + t.Fatalf("%d rows: %q %q", r.H, row(r, 0), row(r, 1)) + } +} + +// A frame's inside is the region within its border and padding, so a frame +// sized to what it holds ends on it; the border takes the glyphs' line type +func TestFrameHoldsItsInside(t *testing.T) { + ascii := MonoTheme + ascii.Glyphs = GlyphsASCII + for _, c := range []struct { + th Theme + want [3]string + }{{MonoTheme, [3]string{"┌─ T ───┐", "│ abcde │", "└───────┘"}}, {ascii, [3]string{"+- T ---+", "| abcde |", "+-------+"}}} { + r := canvas(9, 3, c.th) + in := r.Frame("T", c.th) + in.TextStyled(0, 0, "abcde", c.th.Text) + if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != c.want || in.W != 5 || in.H != 1 { + t.Errorf("%q, inside %dx%d", got, in.W, in.H) + } + } +} + +// A rule keeps its title; the hint at its end goes first when both do not fit +func TestRuleKeepsTheTitleBeforeTheHint(t *testing.T) { + for w, want := range map[int]string{40: "── file · source ────────────── ⏎ edit ─", 24: "── file · source ───────"} { + r := canvas(w, 1, MonoTheme) + if r.Rule(0, "file · source", "⏎ edit", MonoTheme); row(r, 0) != want { + t.Errorf("%d columns: %q", w, row(r, 0)) + } + } +} + +// Wires join where they meet: each cell draws the arms laid through it, +// whatever order they were laid in +func TestWiresJoinWhereTheyMeet(t *testing.T) { + g := NewWires(5, 3) + g.V(4, 1, 2) + g.H(1, 0, 4) + g.V(2, 0, 2) + g.H(9, 0, 9) // off the grid + for line, want := range map[LineType][3]string{LineSingle: {" │", "──┼─┐", " │ │"}, LineASCII: {" |", "--+-+", " | |"}} { + r := canvas(5, 3, MonoTheme) + r.DrawWires(g, line, MonoTheme.Border) + if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != want { + t.Errorf("%d: %q, want %q", line, got, want) + } + } +} + +// A window shows the content's rows from the scroll offset, and fills what +// the content does not reach; a window with no columns draws nothing +func TestWindowShowsTheRowsInView(t *testing.T) { + r := canvas(6, 3, MonoTheme) + var v ViewportScroll + v.SetDimensions(10, r.H) + v.EnsureRange(7, 1) + content := func(c Region) { + for y := range c.H { + c.TextStyled(0, y, fmt.Sprintf("row %d", y), MonoTheme.Text) + } + } + r.Window(10, &v, MonoTheme.Text, content) + if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != [3]string{"row 5", "row 6", "row 7"} { + t.Fatalf("rows 5-7: %q", got) + } + r.Window(2, &v, MonoTheme.Text, content) + if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != [3]string{"row 0", "row 1", ""} { + t.Fatalf("two rows of content: %q", got) + } + r.Sub(8, 0, 4, 3).Window(10, &v, MonoTheme.Text, content) // no columns, past the right edge +}