v0.2.1 tui enhancement and better narrow layouts
This commit is contained in:
13 files changed
+577
-130
No files matched your search
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user