v0.2.1 tui enhancement and better narrow layouts

This commit is contained in:
lixen committed 2026-10-10 11:50:56 -04:00
1 parent 2b5d127e84
commit 7d1c3b24a0
13 files changed
+577 -130

No files matched your search

+3 -3
View File
@@ -1,9 +1,9 @@
module github.com/lixenwraith/terminal module github.com/lixenwraith/terminal
go 1.27.1 go 1.27.2
require ( require (
github.com/lixenwraith/color v0.1.3 github.com/lixenwraith/color v0.1.3
golang.org/x/sys v0.48.0 golang.org/x/sys v0.49.0
golang.org/x/term v0.46.0 golang.org/x/term v0.47.0
) )
+4
View File
@@ -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= 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 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= 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 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= 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=
+29 -11
View File
@@ -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` / String utilities operate on rune counts: `RuneLen`, `Truncate` /
`TruncateLeft` / `TruncateMiddle` (ellipsis variants), `PadLeft` / `PadRight` / `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 `Style{Fg, Bg, Attr}` bundles cell appearance; most widget option structs
accept it. 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: state lives in plain structs passed by pointer. Available renderers:
boxes and lines (`Box`, `BoxFilled`, `HLine`, `VLine` — single, double, 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`, `KeyValueWrap`, `Progress` / `ProgressV` / `Gauge` / `Spinner`,
`ProgressOverlay`, `Sparkline` / `SparklineV`, `Input` / `TextField`, `ProgressOverlay`, `Sparkline` / `SparklineV`, `Input` / `TextField`,
`Editor`, `Modal` / `Overlay` / `ConfirmDialog`, `ScrollBar` / `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 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 draws alike in each: `DefaultTheme` is dark true color, `Theme16` uses the
ANSI 16 on the terminal's own background, `MonoTheme` uses attributes 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, alone, and `ThemeFor(term.ColorMode())` picks among them. A selection
the focus mark), so it reads without color. `GlyphsUnicode`, `GlyphsCP437` always changes a glyph (a radio mark, a pointer, the focus mark), so it reads
(text consoles) and `GlyphsASCII` (outside UTF-8) are the glyph sets. 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 ```go
th := tui.DefaultTheme th := tui.DefaultTheme
@@ -136,7 +139,7 @@ value.TextInput(port, "8080", focus.Index == 0, th) // the default as placeholde
y += rows y += rows
value, rows = form.Field(y, 10, tui.Field{Label: "format"}, focus.Index == 1, th) 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 y += rows
form.Group(y, "tls", "on, pinned", tlsOpen, focus.Index == 2, th) 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. - `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, - `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. or the help while focused, wrapped, and returns the region for the value.
- `TextInput` draws a `TextFieldState`, its placeholder muted while empty. 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`, `TextFieldState.Accept` filters typed and pasted runes (`AcceptInteger`,
`AcceptNumber`); `Paste` inserts an `EventPaste`'s text with line breaks `AcceptNumber`); `Paste` inserts an `EventPaste`'s text with line breaks
as spaces and control characters dropped. 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. - `Toggle` draws an on/off switch.
- `Group` draws a foldable group's header, and its summary while folded. - `Group` draws a foldable group's header, and its summary while folded.
- `OptionListState` and `OptionList` filter options by what is typed, - `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 ### Scrollable list with scrollbar
@@ -229,7 +245,9 @@ Pure logic, no rendering — usable independently:
- `ScrollState` — item-index scrolling with selection - `ScrollState` — item-index scrolling with selection
(`SelectNext/Prev`, `EnsureVisible`, `PageUp/Down`, `AtTop/AtBottom`) (`SelectNext/Prev`, `EnsureVisible`, `PageUp/Down`, `AtTop/AtBottom`)
- `ViewportScroll` — row-based content scrolling with viewport clipping - `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 - `TreeState` + `TreeExpansion` + `TreeBuilder` — cursor/scroll, expand/collapse
keyed state, hierarchical → flat visible-node list keyed state, hierarchical → flat visible-node list
- `EditorState`, `TextFieldState` — text content, cursor, scroll, key handling - `EditorState`, `TextFieldState` — text content, cursor, scroll, key handling
+127 -50
View File
@@ -1,6 +1,8 @@
package tui package tui
import ( import (
"strings"
"github.com/lixenwraith/color" "github.com/lixenwraith/color"
"github.com/lixenwraith/terminal" "github.com/lixenwraith/terminal"
) )
@@ -14,26 +16,39 @@ const (
LineRounded // ╭─╮│╰╯ LineRounded // ╭─╮│╰╯
LineHeavy // ┏━┓┃┗┛ LineHeavy // ┏━┓┃┗┛
LineNone // spaces (invisible border with padding) LineNone // spaces (invisible border with padding)
LineASCII // +-+|++, for a terminal outside UTF-8
LineDashed // ╭╌╮╎╰╯
) )
// boxChars contains box drawing character sets indexed by LineType // lineChars are each LineType's runes by the arms that meet in a cell: up 1,
var boxChars = [...][6]rune{ // right 2, down 4, left 8, so corners, tees and crossings all join
LineSingle: {'┌', '─', '┐', '│', '└', '┘'}, var lineChars = [...]string{
LineDouble: {'╔', '═', '╗', '║', '╚', '╝'}, LineSingle: " │─└││┌├─┘─┴┐┤┬┼",
LineRounded: {'╭', '─', '╮', '│', '╰', '╯'}, LineDouble: " ║═╚║║╔╠═╝═╩╗╣╦╬",
LineHeavy: {'┏', '━', '┓', '┃', '┗', '┛'}, LineRounded: " │─╰││╭├─╯─┴╮┤┬┼",
LineNone: {' ', ' ', ' ', ' ', ' ', ' '}, LineHeavy: " ┃━┗┃┃┏┣━┛━┻┓┫┳╋",
LineNone: " ",
LineASCII: " |-+||++-+-+++++",
LineDashed: " ╎╌╰╎╎╭├╌╯╌┴╮┤┬┼",
} }
const ( const (
boxTL = 0 // top-left boxTL = 6 // top-left
boxH = 1 // horizontal boxH = 10 // horizontal
boxTR = 2 // top-right boxTR = 12 // top-right
boxV = 3 // vertical boxV = 5 // vertical
boxBL = 4 // bottom-left boxBL = 3 // bottom-left
boxBR = 5 // bottom-right 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 Rendering ---
// Box draws border around region edge // 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, // 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. // 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) { 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 { if r.W < 2 || r.H < 1 || totalH < 2 {
return return
} }
if line >= LineType(len(boxChars)) { chars := line.runes()
line = LineSingle 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 { for y := range r.H {
c := off + y switch c := off + y; {
if c < 0 || c >= totalH { case c < 0 || c >= totalH:
continue case c == 0 || c == totalH-1:
} left, right := chars[boxTL], chars[boxTR]
switch c { if c > 0 {
case 0: left, right = chars[boxBL], chars[boxBR]
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)
} }
r.TextStyled(1, y, strings.Repeat(string(chars[boxH]), max(0, r.W-2)), s)
edge(y, left, right)
default: default:
r.Cell(0, y, chars[boxV], fg, bg, terminal.AttrNone) edge(y, chars[boxV], chars[boxV])
r.Cell(r.W-1, y, chars[boxV], fg, bg, terminal.AttrNone)
} }
} }
} }
@@ -99,10 +112,7 @@ func (r Region) HLine(y int, line LineType, fg color.RGB) {
if y < 0 || y >= r.H { if y < 0 || y >= r.H {
return return
} }
if line >= LineType(len(boxChars)) { ch := line.runes()[boxH]
line = LineSingle
}
ch := boxChars[line][boxH]
for x := 0; x < r.W; x++ { for x := 0; x < r.W; x++ {
r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone) 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 { if x < 0 || x >= r.W {
return return
} }
if line >= LineType(len(boxChars)) { ch := line.runes()[boxV]
line = LineSingle
}
ch := boxChars[line][boxV]
for y := 0; y < r.H; y++ { for y := 0; y < r.H; y++ {
r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone) 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 { if y < 0 || y >= r.H {
return return
} }
if line >= LineType(len(boxChars)) { hChar := line.runes()[boxH]
line = LineSingle
}
hChar := boxChars[line][boxH]
// Fill with horizontal line // Fill with horizontal line
for x := 0; x < r.W; x++ { 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) 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)
}
}
}
+43 -12
View File
@@ -2,20 +2,51 @@ package tui
import "github.com/lixenwraith/terminal" import "github.com/lixenwraith/terminal"
// Choice draws options in a row at (x, y), the chosen one bracketed in the // Radio draws options as radio buttons in a row at (x, y): the chosen one
// accent style and the rest muted ("raw [txt] json"), and returns the // with the On glyph in the accent style, the rest with Off, muted ("○ raw
// columns it drew // ● txt ○ json"); chosen -1 marks none. Where the row does not fit, it shows
func (r Region) Choice(x, y int, options []string, chosen int, th Theme) int { // the options around the chosen one, a muted '…' where others are hidden.
start := x // It returns the columns it drew.
for i, o := range options { func (r Region) Radio(x, y int, options []string, chosen int, th Theme) int {
open, close, style := " ", " ", th.Muted width := func(lo, hi int) int { // the window [lo, hi), its '…' marks included
if i == chosen { w := -2
open, close, style = "[", "]", th.Accent for _, o := range options[lo:hi] {
w += RuneLen(o) + 4
} }
r.TextStyled(x, y, open+o+close, style) return w + 2*min(lo, 1) + 3*min(len(options)-hi, 1)
x += RuneLen(o) + 2
} }
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), // StepChoice moves a choice among n options on Left or Right (h, l),
+1 -5
View File
@@ -203,11 +203,7 @@ func (r Region) docSection(y, x, w int, label string, opts DocOpts) {
r.TextStyled(x, y, label, opts.HeaderStyle) r.TextStyled(x, y, label, opts.HeaderStyle)
return return
} }
line := opts.Rule ch := opts.Rule.runes()[boxH]
if line >= LineType(len(boxChars)) {
line = LineSingle
}
ch := boxChars[line][boxH]
for i := range w { for i := range w {
r.Cell(x+i, y, ch, opts.RuleStyle.Fg, opts.RuleStyle.Bg, opts.RuleStyle.Attr) r.Cell(x+i, y, ch, opts.RuleStyle.Fg, opts.RuleStyle.Bg, opts.RuleStyle.Attr)
} }
+29 -8
View File
@@ -36,36 +36,49 @@ type Field struct {
// Field draws a form row at y: the focus mark, the label in a column labelW // 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 // 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 // focused, wrapped. The label column leaves the value half the row; labelW 0
// caller draws a TextInput, Choice or Toggle, and the rows it used. // 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) { func (r Region) Field(y, labelW int, f Field, focused bool, th Theme) (Region, int) {
label := th.Muted label := th.Muted
if focused { if focused {
label = th.Text label = th.Text
r.TextStyled(0, y, string(th.Glyphs.Focus), th.Accent) 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 name := f.Label
if f.Required { if f.Required {
name = Truncate(name, labelW-2) name = Truncate(name, labelW-2)
r.TextStyled(2+RuneLen(name)+1, y, string(th.Glyphs.Required), th.Accent) r.TextStyled(2+RuneLen(name)+1, y, string(th.Glyphs.Required), th.Accent)
} }
r.TextStyled(2, y, Truncate(name, labelW), label) 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 note, style := f.Error, th.Error
if note == "" && focused { if note == "" && focused {
note, style = f.Help, th.Muted note, style = f.Help, th.Muted
} }
if note == "" { 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) lines := WrapText(note, r.W-x)
return r.Sub(x, y, r.W-x, 1), 2 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 // 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 // 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) { func (r Region) TextInput(state *TextFieldState, placeholder string, focused bool, th Theme) {
if r.W < 1 || r.H < 1 { if r.W < 1 || r.H < 1 {
return return
@@ -75,7 +88,7 @@ func (r Region) TextInput(state *TextFieldState, placeholder string, focused boo
bg = th.Input bg = th.Input
} }
state.AdjustScroll(r.W) 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 { if len(state.Text) > 0 {
text, style = state.Text[state.Scroll:], th.Text.On(bg) 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) 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 { if focused {
x, ch := state.Cursor-state.Scroll, ' ' x, ch := state.Cursor-state.Scroll, ' '
if x < len(text) { if x < len(text) {
+84 -24
View File
@@ -10,17 +10,24 @@ import (
type Option struct { type Option struct {
Name string // what is chosen Name string // what is chosen
Hint string // a line about it 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 // 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 { type OptionListState struct {
Options []Option Options []Option
Filter TextFieldState Filter TextFieldState
Cursor int // index into Matches Cursor int // index into Matches
Scroll int // first match shown 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 // NewOptionListState creates a list showing every option
func NewOptionListState(options []Option) *OptionListState { func NewOptionListState(options []Option) *OptionListState {
return &OptionListState{Options: options} return &OptionListState{Options: options}
@@ -59,7 +66,7 @@ func (s *OptionListState) HandleKey(key terminal.Key, r rune, mod terminal.Modif
s.Cursor-- s.Cursor--
case key == terminal.KeyDown: case key == terminal.KeyDown:
s.Cursor++ s.Cursor++
case !s.Filter.HandleKey(key, r, mod): case s.Menu || !s.Filter.HandleKey(key, r, mod):
return false return false
case s.Filter.Value() != filter: case s.Filter.Value() != filter:
s.Cursor = 0 s.Cursor = 0
@@ -68,49 +75,102 @@ func (s *OptionListState) HandleKey(key terminal.Key, r rune, mod terminal.Modif
return true 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 // Paste adds pasted text to the filter
func (s *OptionListState) Paste(text string) bool { func (s *OptionListState) Paste(text string) bool {
if !s.Filter.Paste(text) { if s.Menu || !s.Filter.Paste(text) {
return false return false
} }
s.Cursor = 0 s.Cursor = 0
return true return true
} }
// OptionList draws the filter on the first row and the matches below it, // layout is how the list fits w columns: the names' width, the column the
// the cursor row marked with the pointer on the Selected background, each // names start at, and the rows below the list that hold the cursor's hint
// hint muted in a column after the names // 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) { func (r Region) OptionList(s *OptionListState, th Theme) {
if r.W < 4 || r.H < 2 { if r.W < 4 || r.H < 2 {
return return
} }
r.TextStyled(0, 0, "/", th.Muted) top := 1
r.Sub(2, 0, r.W-2, 1).TextInput(&s.Filter, "type to filter", true, th) 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() m := s.Matches()
rows := r.H - 1
if len(m) == 0 { if len(m) == 0 {
r.TextStyled(2, 1, "no match", th.Muted) r.TextStyled(2, top, "no match", th.Muted)
return return
} }
s.Scroll = max(0, min(s.Scroll, s.Cursor, len(m)-rows)) nameW, nameX, hintRows := s.layout(r.W)
s.Scroll = max(s.Scroll, s.Cursor-rows+1) rows := max(1, r.H-top-hintRows)
s.Scroll = AdjustScroll(s.Cursor, ClampScroll(s.Scroll, rows, len(m)), rows, len(m))
nameW := 0
for _, i := range m {
nameW = max(nameW, RuneLen(s.Options[i].Name))
}
nameW = min(nameW, r.W/2)
for y := 0; y < rows && s.Scroll+y < len(m); y++ { for y := 0; y < rows && s.Scroll+y < len(m); y++ {
o := s.Options[m[s.Scroll+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 var bg Style
if s.Scroll+y == s.Cursor { if s.Scroll+y == s.Cursor {
bg = th.Selected bg = th.Selected
row.FillStyle(th.Text.On(bg)) row.FillStyle(th.Text.On(bg))
row.TextStyled(0, 0, string(th.Glyphs.Pointer), th.Accent.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)) if s.Menu && o.Key != 0 {
row.TextStyled(2+nameW+2, 0, Truncate(o.Hint, r.W-nameW-4), th.Muted.On(bg)) 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)
} }
} }
+23 -8
View File
@@ -1,6 +1,7 @@
package tui package tui
import ( import (
"strings"
"unicode/utf8" "unicode/utf8"
) )
@@ -118,13 +119,23 @@ func PadCenter(s string, width int) string {
// --- Text wrapping --- // --- Text wrapping ---
// WrapText wraps text at word boundaries to fit width // WrapText wraps text to fit width: at each line break, at spaces, or, in a
// Returns slice of lines, each no longer than width // 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 { func WrapText(s string, width int) []string {
if width <= 0 { if width <= 0 {
return nil 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) runes := []rune(s)
if len(runes) == 0 { if len(runes) == 0 {
return []string{""} return []string{""}
@@ -132,7 +143,7 @@ func WrapText(s string, width int) []string {
var lines []string var lines []string
lineStart := 0 lineStart := 0
lastSpace := -1 lastSpace, lastMark := -1, -1
for i := 0; i <= len(runes); i++ { for i := 0; i <= len(runes); i++ {
// Check if we need to wrap // Check if we need to wrap
@@ -147,9 +158,11 @@ func WrapText(s string, width int) []string {
// Need to wrap // Need to wrap
wrapAt := i wrapAt := i
if lastSpace > lineStart { switch {
// Wrap at last space case lastSpace > lineStart:
wrapAt = lastSpace wrapAt = lastSpace
case lastMark > lineStart:
wrapAt = lastMark
} }
lines = append(lines, string(runes[lineStart:wrapAt])) lines = append(lines, string(runes[lineStart:wrapAt]))
@@ -160,13 +173,16 @@ func WrapText(s string, width int) []string {
} else { } else {
lineStart = wrapAt 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] == ' ' { if i < len(runes) && runes[i] == ' ' {
lastSpace = i lastSpace = i
} }
if i < len(runes) && strings.ContainsRune(".,:/[]", runes[i]) {
lastMark = i + 1
}
} }
if len(lines) == 0 { if len(lines) == 0 {
@@ -189,4 +205,3 @@ func RepeatRune(r rune, n int) string {
} }
return string(runes) return string(runes)
} }
+3 -1
View File
@@ -258,12 +258,14 @@ func (t *TextFieldState) MoveToEnd() {
// --- Scroll management --- // --- 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) { func (t *TextFieldState) AdjustScroll(viewportW int) {
if viewportW <= 0 { if viewportW <= 0 {
return return
} }
t.clampCursor() t.clampCursor()
t.Scroll = min(t.Scroll, max(0, len(t.Text)+1-viewportW))
if t.Cursor < t.Scroll { if t.Cursor < t.Scroll {
t.Scroll = t.Cursor t.Scroll = t.Cursor
} }
+8 -7
View File
@@ -25,18 +25,19 @@ type Theme struct {
// it reads without color. A text console's font takes GlyphsCP437, a // it reads without color. A text console's font takes GlyphsCP437, a
// terminal outside UTF-8 GlyphsASCII. // terminal outside UTF-8 GlyphsASCII.
type Glyphs struct { type Glyphs struct {
Focus rune // beside the focused control Focus rune // beside the focused control
Pointer rune // the cursor row of a list Pointer rune // the cursor row of a list
Folded rune // a closed group Folded rune // a closed group
Unfolded rune // an open group Unfolded rune // an open group
On, Off rune // a toggle On, Off rune // a toggle
Required rune // after a required field's label Required rune // after a required field's label
Line LineType // frames, rules and wires
} }
var ( var (
GlyphsUnicode = Glyphs{Focus: '▌', Pointer: '▸', Folded: '▸', Unfolded: '▾', On: '●', Off: '○', Required: '*'} GlyphsUnicode = Glyphs{Focus: '▌', Pointer: '▸', Folded: '▸', Unfolded: '▾', On: '●', Off: '○', Required: '*'}
GlyphsCP437 = 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 // DefaultTheme is a dark theme for true color and 256-color terminals
+23
View File
@@ -1,5 +1,7 @@
package tui package tui
import "github.com/lixenwraith/terminal"
// ViewportScroll manages row-based scroll for content regions // ViewportScroll manages row-based scroll for content regions
// Distinct from ScrollState which is item-index based // Distinct from ScrollState which is item-index based
type ViewportScroll struct { type ViewportScroll struct {
@@ -117,3 +119,24 @@ func (v *ViewportScroll) EnsureRange(y, h int) {
} }
v.clamp() 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)
}
+200 -1
View File
@@ -1,6 +1,9 @@
package tui package tui
import ( import (
"fmt"
"runtime"
"slices"
"strings" "strings"
"testing" "testing"
@@ -50,7 +53,7 @@ func TestSelectionChangesAGlyph(t *testing.T) {
func selectionChangesAGlyph(t *testing.T, th Theme) { func selectionChangesAGlyph(t *testing.T, th Theme) {
options := NewOptionListState([]Option{{Name: "file"}, {Name: "tcp"}}) options := NewOptionListState([]Option{{Name: "file"}, {Name: "tcp"}})
for name, draw := range map[string]func(r Region, on bool){ 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) }, "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) }, "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) }, "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)) 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
}