v0.2.0 16 colors, default colors, /dev/tty, bracketed paste, themed form controls

This commit is contained in:
lixen committed 2026-10-09 14:15:46 -04:00
1 parent 070131c7fd
commit 2b5d127e84
32 files changed
+1447 -424

No files matched your search

+54 -11
View File
@@ -39,15 +39,15 @@ list.Selection = 0
for {
root := tui.NewRegion(cells, w, 0, 0, w, h)
root.Fill(terminal.Gunmetal)
root.Fill(color.Gunmetal)
root.Box(tui.LineRounded, terminal.SteelBlue)
root.Box(tui.LineRounded, color.SteelBlue)
content := root.Inset(1)
content.List(buildItems(items), list.Selection, list.Offset, tui.ListOpts{
CursorBg: terminal.DarkSlate,
CursorBg: color.DarkSlate,
})
content.ScrollBar(content.W-1, list.Offset, list.Visible, list.Total,
terminal.IronGray)
color.IronGray)
term.Flush(cells, w, h)
@@ -114,18 +114,59 @@ rounded, heavy line types), `List`, `Table`, `Tree`, `TabBar`, `KeyValue` /
Representative patterns below; remaining widgets follow the same
opts-struct + state-struct shape — read the source for full options.
### Theme and form controls
A `Theme` is one `Style` per role (`Text`, `Muted`, `Accent`, `Selected`,
`Input`, `Cursor`, `Error`, `Border`) plus the `Glyphs` widgets draw. A
role's `Attr` may carry color bits, a palette index or the terminal's own
color, so an application builds one theme per color tier and every control
draws alike in each: `DefaultTheme` is dark true color, `Theme16` uses the
ANSI 16 on the terminal's own background, `MonoTheme` uses attributes
alone, and `ThemeFor(term.ColorMode())` picks among them. A selection always changes a glyph (brackets, a pointer,
the focus mark), so it reads without color. `GlyphsUnicode`, `GlyphsCP437`
(text consoles) and `GlyphsASCII` (outside UTF-8) are the glyph sets.
```go
th := tui.DefaultTheme
root.FillStyle(th.Text) // the panel the controls draw on
value, rows := form.Field(y, 10, tui.Field{Label: "port", Required: true,
Help: "TCP port to listen on", Error: portErr}, focus.Index == 0, th)
value.TextInput(port, "8080", focus.Index == 0, th) // the default as placeholder
y += rows
value, rows = form.Field(y, 10, tui.Field{Label: "format"}, focus.Index == 1, th)
value.Choice(0, 0, []string{"raw", "txt", "json"}, format, th) // raw [txt] json
y += rows
form.Group(y, "tls", "on, pinned", tlsOpen, focus.Index == 2, th)
```
- `Focus` is the focus ring: `HandleKey` moves it on Tab and Shift+Tab.
- `Field` draws the focus mark, the label, the required mark, and the error,
or the help while focused, and returns the region for the value.
- `TextInput` draws a `TextFieldState`, its placeholder muted while empty.
`TextFieldState.Accept` filters typed and pasted runes (`AcceptInteger`,
`AcceptNumber`); `Paste` inserts an `EventPaste`'s text with line breaks
as spaces and control characters dropped.
- `Choice` draws a segmented choice; `StepChoice` moves it on Left and Right.
- `Toggle` draws an on/off switch.
- `Group` draws a foldable group's header, and its summary while folded.
- `OptionListState` and `OptionList` filter options by what is typed,
matching name or hint regardless of case.
### Scrollable list with scrollbar
```go
items := make([]tui.ListItem, 0, len(files))
for _, f := range files {
items = append(items, tui.ListItem{
Icon: '▸', IconFg: terminal.Amber,
Icon: '▸', IconFg: color.Amber,
Text: f.Name, TextStyle: tui.Style{Fg: terminal.LightGray},
})
}
r.List(items, state.Selection, state.Offset, tui.ListOpts{CursorBg: terminal.DarkSlate})
r.ScrollBar(r.W-1, state.Offset, state.Visible, state.Total, terminal.IronGray)
r.List(items, state.Selection, state.Offset, tui.ListOpts{CursorBg: color.DarkSlate})
r.ScrollBar(r.W-1, state.Offset, state.Visible, state.Total, color.IronGray)
```
### Modal dialog
@@ -135,11 +176,11 @@ dlg := tui.Center(root, 50, 12)
content := dlg.Modal(tui.ModalOpts{
Title: "Settings",
Border: tui.LineDouble,
BorderFg: terminal.SteelBlue,
BorderFg: color.SteelBlue,
TitleFg: terminal.White,
Bg: terminal.DarkSlate,
Bg: color.DarkSlate,
})
content.TextBlock(0, 0, body, fg, terminal.DarkSlate, terminal.AttrNone)
content.TextBlock(0, 0, body, fg, color.DarkSlate, terminal.AttrNone)
```
`Modal` fills, borders, titles, and returns the content region. `Overlay`
@@ -201,6 +242,8 @@ Pure logic, no rendering — usable independently:
- Width calculations count runes, not terminal columns; East Asian wide
characters and combining marks are not width-aware.
- Zero-value `color.RGB` in style fields generally means "inherit"
(widget default or row background) — check specific widget docs.
(widget default or row background) — check specific widget docs. A zero
background with no Bg color bits is transparent: the cell keeps the
background beneath it, bits and all.
- Mouse hit testing: `TabBar` returns `[]TabBounds`; other widgets require
application-side geometry from the regions used.
+42
View File
@@ -0,0 +1,42 @@
package tui
import "github.com/lixenwraith/terminal"
// Choice draws options in a row at (x, y), the chosen one bracketed in the
// accent style and the rest muted ("raw [txt] json"), and returns the
// columns it drew
func (r Region) Choice(x, y int, options []string, chosen int, th Theme) int {
start := x
for i, o := range options {
open, close, style := " ", " ", th.Muted
if i == chosen {
open, close, style = "[", "]", th.Accent
}
r.TextStyled(x, y, open+o+close, style)
x += RuneLen(o) + 2
}
return x - start
}
// StepChoice moves a choice among n options on Left or Right (h, l),
// stopping at either end, and reports whether it moved
func StepChoice(key terminal.Key, r rune, chosen, n int) (int, bool) {
switch {
case (key == terminal.KeyLeft || key == terminal.KeyRune && r == 'h') && chosen > 0:
return chosen - 1, true
case (key == terminal.KeyRight || key == terminal.KeyRune && r == 'l') && chosen < n-1:
return chosen + 1, true
}
return chosen, false
}
// Toggle draws a switch at (x, y): the On glyph and "on" in the accent
// style, or the Off glyph and "off" muted, and returns the columns it drew
func (r Region) Toggle(x, y int, on bool, th Theme) int {
label, style := string(th.Glyphs.Off)+" off", th.Muted
if on {
label, style = string(th.Glyphs.On)+" on", th.Accent
}
r.TextStyled(x, y, label, style)
return RuneLen(label)
}
+115
View File
@@ -0,0 +1,115 @@
package tui
import (
"github.com/lixenwraith/terminal"
)
// Focus is a focus ring over a form's controls, numbered 0 to Len-1
type Focus struct {
Index, Len int
}
// HandleKey moves focus on Tab and Shift+Tab, wrapping, and reports whether
// it moved
func (f *Focus) HandleKey(key terminal.Key, mod terminal.Modifier) bool {
if f.Len < 1 {
return false
}
switch {
case key == terminal.KeyTab && mod&terminal.ModShift == 0:
f.Index = (f.Index + 1) % f.Len
case key == terminal.KeyTab || key == terminal.KeyBacktab:
f.Index = (f.Index - 1 + f.Len) % f.Len
default:
return false
}
return true
}
// Field labels one form row
type Field struct {
Label string
Required bool
Help string // shown under the field while it has focus
Error string // shown under the field, in place of Help
}
// Field draws a form row at y: the focus mark, the label in a column labelW
// wide with the required mark, and below, the error, or the help while
// focused. It returns the region for the value beside the label, where the
// caller draws a TextInput, Choice or Toggle, and the rows it used.
func (r Region) Field(y, labelW int, f Field, focused bool, th Theme) (Region, int) {
label := th.Muted
if focused {
label = th.Text
r.TextStyled(0, y, string(th.Glyphs.Focus), th.Accent)
}
name := f.Label
if f.Required {
name = Truncate(name, labelW-2)
r.TextStyled(2+RuneLen(name)+1, y, string(th.Glyphs.Required), th.Accent)
}
r.TextStyled(2, y, Truncate(name, labelW), label)
x := 2 + labelW + 1
note, style := f.Error, th.Error
if note == "" && focused {
note, style = f.Help, th.Muted
}
if note == "" {
return r.Sub(x, y, r.W-x, 1), 1
}
r.TextStyled(x, y+1, Truncate(note, r.W-x), style)
return r.Sub(x, y, r.W-x, 1), 2
}
// TextInput draws state's text on r's first row, or, empty, the placeholder
// muted: a default shows what an empty value means. Focused, it draws on the
// Input background with the cursor, scrolling to keep the cursor in view.
func (r Region) TextInput(state *TextFieldState, placeholder string, focused bool, th Theme) {
if r.W < 1 || r.H < 1 {
return
}
var bg Style
if focused {
bg = th.Input
}
state.AdjustScroll(r.W)
text, style := []rune(placeholder), th.Muted.On(bg)
if len(state.Text) > 0 {
text, style = state.Text[state.Scroll:], th.Text.On(bg)
}
for x := 0; x < r.W; x++ {
ch := ' '
if x < len(text) {
ch = text[x]
}
r.Cell(x, 0, ch, style.Fg, style.Bg, style.Attr)
}
if focused {
x, ch := state.Cursor-state.Scroll, ' '
if x < len(text) {
ch = text[x]
}
r.Cell(x, 0, ch, th.Cursor.Fg, th.Cursor.Bg, th.Cursor.Attr)
}
}
// Group draws a foldable group's header at y: the focus mark, the Folded or
// Unfolded glyph and the title, and while folded, a muted summary of what
// it holds
func (r Region) Group(y int, title, summary string, open, focused bool, th Theme) {
mark, style := th.Glyphs.Folded, th.Muted
if open {
mark = th.Glyphs.Unfolded
}
if focused {
style = th.Text
r.TextStyled(0, y, string(th.Glyphs.Focus), th.Accent)
}
r.TextStyled(2, y, string(mark)+" "+title, style)
if !open && summary != "" {
x := 2 + 2 + RuneLen(title) + 2
r.TextStyled(x, y, Truncate(summary, r.W-x), th.Muted)
}
}
+116
View File
@@ -0,0 +1,116 @@
package tui
import (
"strings"
"github.com/lixenwraith/terminal"
)
// Option is one entry of an option list
type Option struct {
Name string // what is chosen
Hint string // a line about it
}
// OptionListState filters options by the text typed into it, matching name
// or hint regardless of case, and keeps a cursor on the matches
type OptionListState struct {
Options []Option
Filter TextFieldState
Cursor int // index into Matches
Scroll int // first match shown
}
// NewOptionListState creates a list showing every option
func NewOptionListState(options []Option) *OptionListState {
return &OptionListState{Options: options}
}
// Matches returns the indices of the options the filter matches, in order,
// keeping the cursor on one of them
func (s *OptionListState) Matches() []int {
filter := strings.ToLower(s.Filter.Value())
var m []int
for i, o := range s.Options {
if strings.Contains(strings.ToLower(o.Name), filter) || strings.Contains(strings.ToLower(o.Hint), filter) {
m = append(m, i)
}
}
s.Cursor = max(0, min(s.Cursor, len(m)-1))
return m
}
// Chosen returns the index in Options of the option under the cursor, and
// false when nothing matches
func (s *OptionListState) Chosen() (int, bool) {
m := s.Matches()
if len(m) == 0 {
return 0, false
}
return m[s.Cursor], true
}
// HandleKey moves the cursor on Up and Down, or edits the filter, and
// reports whether anything changed
func (s *OptionListState) HandleKey(key terminal.Key, r rune, mod terminal.Modifier) bool {
filter := s.Filter.Value()
switch {
case key == terminal.KeyUp:
s.Cursor--
case key == terminal.KeyDown:
s.Cursor++
case !s.Filter.HandleKey(key, r, mod):
return false
case s.Filter.Value() != filter:
s.Cursor = 0
}
s.Matches() // keeps the cursor on a match
return true
}
// Paste adds pasted text to the filter
func (s *OptionListState) Paste(text string) bool {
if !s.Filter.Paste(text) {
return false
}
s.Cursor = 0
return true
}
// OptionList draws the filter on the first row and the matches below it,
// the cursor row marked with the pointer on the Selected background, each
// hint muted in a column after the names
func (r Region) OptionList(s *OptionListState, th Theme) {
if r.W < 4 || r.H < 2 {
return
}
r.TextStyled(0, 0, "/", th.Muted)
r.Sub(2, 0, r.W-2, 1).TextInput(&s.Filter, "type to filter", true, th)
m := s.Matches()
rows := r.H - 1
if len(m) == 0 {
r.TextStyled(2, 1, "no match", th.Muted)
return
}
s.Scroll = max(0, min(s.Scroll, s.Cursor, len(m)-rows))
s.Scroll = max(s.Scroll, s.Cursor-rows+1)
nameW := 0
for _, i := range m {
nameW = max(nameW, RuneLen(s.Options[i].Name))
}
nameW = min(nameW, r.W/2)
for y := 0; y < rows && s.Scroll+y < len(m); y++ {
o := s.Options[m[s.Scroll+y]]
row := r.Sub(0, 1+y, r.W, 1)
var bg Style
if s.Scroll+y == s.Cursor {
bg = th.Selected
row.FillStyle(th.Text.On(bg))
row.TextStyled(0, 0, string(th.Glyphs.Pointer), th.Accent.On(bg))
}
row.TextStyled(2, 0, Truncate(o.Name, nameW), th.Text.On(bg))
row.TextStyled(2+nameW+2, 0, Truncate(o.Hint, r.W-nameW-4), th.Muted.On(bg))
}
}
+14 -7
View File
@@ -66,10 +66,10 @@ func (r Region) Inset(n int) Region {
}
// Cell sets a single cell with bounds checking.
// A zero-value bg (color.RGB{}) is transparent: the existing cell's
// background is preserved. Establish a base background first (Fill, or
// pre-initialized cell buffer). For a literal black background use
// CellOpaque or Fill(color.RGB{}).
// A zero-value bg (color.RGB{}) with no Bg color bits in attr is
// transparent: the existing cell's background is preserved, bits and all.
// Establish a base background first (Fill, or pre-initialized cell buffer).
// For a literal black background use CellOpaque or Fill(color.RGB{}).
func (r Region) Cell(x, y int, ch rune, fg, bg color.RGB, attr terminal.Attr) {
if x < 0 || x >= r.W || y < 0 || y >= r.H {
return
@@ -85,8 +85,9 @@ func (r Region) Cell(x, y int, ch rune, fg, bg color.RGB, attr terminal.Attr) {
idx := absY*r.TotalW + absX
// Single bounds check for the backing slice
if uint(idx) < uint(len(r.Cells)) {
if bg == (color.RGB{}) {
bg = r.Cells[idx].Bg // transparent: inherit current background
if bg == (color.RGB{}) && attr&terminal.AttrBgColor == 0 {
// transparent: inherit current background
bg, attr = r.Cells[idx].Bg, attr|r.Cells[idx].Attrs&terminal.AttrBgColor
}
r.Cells[idx] = terminal.Cell{Rune: ch, Fg: fg, Bg: bg, Attrs: attr}
}
@@ -115,9 +116,15 @@ func (r Region) CellOpaque(x, y int, ch rune, fg, bg color.RGB, attr terminal.At
// Opaque by definition: Fill establishes the base layer, so bg is written
// verbatim (including zero/black) rather than treated as transparent.
func (r Region) Fill(bg color.RGB) {
r.FillStyle(Style{Bg: bg})
}
// FillStyle fills the region with spaces in s, opaque as Fill is; a theme's
// Text fills the panel its widgets draw on
func (r Region) FillStyle(s Style) {
for y := 0; y < r.H; y++ {
for x := 0; x < r.W; x++ {
r.CellOpaque(x, y, ' ', color.RGB{}, bg, terminal.AttrNone)
r.CellOpaque(x, y, ' ', s.Fg, s.Bg, s.Attr)
}
}
}
+12 -1
View File
@@ -5,7 +5,9 @@ import (
"github.com/lixenwraith/terminal"
)
// Style bundles foreground, background, and attributes for text rendering
// Style bundles foreground, background, and attributes for text rendering.
// Attr's color bits say how to read Fg and Bg: terminal.AttrFg256 a palette
// index in R, terminal.AttrFgDefault the terminal's own color.
type Style struct {
Fg color.RGB
Bg color.RGB
@@ -22,3 +24,12 @@ func (s Style) IsZero() bool {
return s.Fg == (color.RGB{}) && s.Bg == (color.RGB{}) && s.Attr == terminal.AttrNone
}
// On returns s drawn on bg: s's foreground, bg's background (s's own where
// bg's is transparent), and the attributes of both
func (s Style) On(bg Style) Style {
out := Style{Fg: s.Fg, Bg: s.Bg, Attr: s.Attr | bg.Attr&terminal.AttrStyle}
if bg.Bg != (color.RGB{}) || bg.Attr&terminal.AttrBgColor != 0 {
out.Bg, out.Attr = bg.Bg, out.Attr&^terminal.AttrBgColor|bg.Attr&terminal.AttrBgColor
}
return out
}
+48 -2
View File
@@ -1,6 +1,7 @@
package tui
import (
"strings"
"unicode"
"github.com/lixenwraith/terminal"
@@ -16,8 +17,18 @@ type TextFieldState struct {
Text []rune
Cursor int // Positions before which cursor sits (0 = before first char)
Scroll int // First visible rune index
// Accept, when set, filters the runes typed or pasted: AcceptInteger
// and AcceptNumber make a typed field
Accept func(rune) bool
}
// AcceptInteger takes the runes of a decimal integer
func AcceptInteger(r rune) bool { return r >= '0' && r <= '9' || r == '-' }
// AcceptNumber takes the runes of a decimal number, exponent included
func AcceptNumber(r rune) bool { return AcceptInteger(r) || strings.ContainsRune(".eE+", r) }
// NewTextFieldState creates initialized text field state
func NewTextFieldState(initial string) *TextFieldState {
runes := []rune(initial)
@@ -51,23 +62,48 @@ func (t *TextFieldState) Clear() {
// --- Character insertion ---
// clampCursor keeps Cursor within Text, which a caller may have replaced
func (t *TextFieldState) clampCursor() {
t.Cursor = max(0, min(t.Cursor, len(t.Text)))
}
// Insert adds rune at cursor position
func (t *TextFieldState) Insert(r rune) {
t.clampCursor()
t.Text = append(t.Text[:t.Cursor], append([]rune{r}, t.Text[t.Cursor:]...)...)
t.Cursor++
}
// InsertString adds string at cursor position
func (t *TextFieldState) InsertString(s string) {
t.clampCursor()
runes := []rune(s)
t.Text = append(t.Text[:t.Cursor], append(runes, t.Text[t.Cursor:]...)...)
t.Cursor += len(runes)
}
// Paste inserts pasted text at the cursor: a line break or tab becomes a
// space, and other control characters and runes Accept refuses are dropped.
// It reports whether anything was inserted.
func (t *TextFieldState) Paste(s string) bool {
var runes []rune
for _, r := range strings.ReplaceAll(s, "\r\n", "\n") {
if r == '\n' || r == '\r' || r == '\t' {
r = ' '
}
if !unicode.IsControl(r) && (t.Accept == nil || t.Accept(r)) {
runes = append(runes, r)
}
}
t.InsertString(string(runes))
return len(runes) > 0
}
// --- Character deletion ---
// DeleteBackward removes rune before cursor
func (t *TextFieldState) DeleteBackward() bool {
t.clampCursor()
if t.Cursor > 0 {
t.Text = append(t.Text[:t.Cursor-1], t.Text[t.Cursor:]...)
t.Cursor--
@@ -78,6 +114,7 @@ func (t *TextFieldState) DeleteBackward() bool {
// DeleteForward removes rune at cursor
func (t *TextFieldState) DeleteForward() bool {
t.clampCursor()
if t.Cursor < len(t.Text) {
t.Text = append(t.Text[:t.Cursor], t.Text[t.Cursor+1:]...)
return true
@@ -89,6 +126,7 @@ func (t *TextFieldState) DeleteForward() bool {
// DeleteWordBackward removes word before cursor
func (t *TextFieldState) DeleteWordBackward() bool {
t.clampCursor()
if t.Cursor == 0 {
return false
}
@@ -112,6 +150,7 @@ func (t *TextFieldState) DeleteWordBackward() bool {
// DeleteWordForward removes word after cursor
func (t *TextFieldState) DeleteWordForward() bool {
t.clampCursor()
if t.Cursor >= len(t.Text) {
return false
}
@@ -133,6 +172,7 @@ func (t *TextFieldState) DeleteWordForward() bool {
// DeleteToEnd removes from cursor to end
func (t *TextFieldState) DeleteToEnd() bool {
t.clampCursor()
if t.Cursor < len(t.Text) {
t.Text = t.Text[:t.Cursor]
return true
@@ -142,6 +182,7 @@ func (t *TextFieldState) DeleteToEnd() bool {
// DeleteToStart removes from start to cursor
func (t *TextFieldState) DeleteToStart() bool {
t.clampCursor()
if t.Cursor > 0 {
t.Text = t.Text[t.Cursor:]
t.Cursor = 0
@@ -155,6 +196,7 @@ func (t *TextFieldState) DeleteToStart() bool {
// MoveLeft moves cursor left
func (t *TextFieldState) MoveLeft() {
t.clampCursor()
if t.Cursor > 0 {
t.Cursor--
}
@@ -162,6 +204,7 @@ func (t *TextFieldState) MoveLeft() {
// MoveRight moves cursor right
func (t *TextFieldState) MoveRight() {
t.clampCursor()
if t.Cursor < len(t.Text) {
t.Cursor++
}
@@ -171,6 +214,7 @@ func (t *TextFieldState) MoveRight() {
// MoveWordLeft moves cursor to previous word boundary
func (t *TextFieldState) MoveWordLeft() {
t.clampCursor()
if t.Cursor == 0 {
return
}
@@ -186,6 +230,7 @@ func (t *TextFieldState) MoveWordLeft() {
// MoveWordRight moves cursor to next word boundary
func (t *TextFieldState) MoveWordRight() {
t.clampCursor()
if t.Cursor >= len(t.Text) {
return
}
@@ -218,6 +263,7 @@ func (t *TextFieldState) AdjustScroll(viewportW int) {
if viewportW <= 0 {
return
}
t.clampCursor()
if t.Cursor < t.Scroll {
t.Scroll = t.Cursor
}
@@ -271,10 +317,10 @@ func (t *TextFieldState) HandleKey(key terminal.Key, r rune, mod terminal.Modifi
case terminal.KeyCtrlW:
return t.DeleteWordBackward()
case terminal.KeyRune:
if r >= 32 { // Printable
if !unicode.IsControl(r) && (t.Accept == nil || t.Accept(r)) {
t.Insert(r)
return true
}
}
return false
}
}
+89 -54
View File
@@ -2,65 +2,100 @@ package tui
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// Theme defines semantic colors for TUI components
// Theme styles the widgets that take one. Each role is a Style whose Attr may
// carry color bits (a palette index, the terminal's own color), so one theme
// per color tier, mono included, draws every widget alike. A role with a zero
// Bg and no Bg bits draws on whatever is beneath it.
type Theme struct {
Bg color.RGB
Fg color.RGB
FocusBg color.RGB
CursorBg color.RGB
Selected color.RGB
Unselected color.RGB
Partial color.RGB
Error color.RGB
Warning color.RGB
Border color.RGB
HeaderBg color.RGB
HeaderFg color.RGB
StatusFg color.RGB
HintFg color.RGB
InputBg color.RGB
DirFg color.RGB
FileFg color.RGB
SymbolFg color.RGB
SyntaxComment color.RGB
SyntaxString color.RGB
SyntaxKeyword color.RGB
SyntaxType color.RGB
SyntaxNumber color.RGB
SyntaxSymbol color.RGB
Text Style // values and body text; its Bg is the panel's
Muted Style // labels, hints, placeholders and defaults, help lines
Accent Style // marks: focus, the chosen option, a required field
Selected Style // the cursor row of a list
Input Style // the background of the field being edited
Cursor Style // the text cursor
Error Style // errors
Border Style // boxes and rules
Glyphs Glyphs
}
// DefaultTheme provides reasonable defaults
// Glyphs are the marks widgets draw; a selection always changes a glyph, so
// it reads without color. A text console's font takes GlyphsCP437, a
// terminal outside UTF-8 GlyphsASCII.
type Glyphs struct {
Focus rune // beside the focused control
Pointer rune // the cursor row of a list
Folded rune // a closed group
Unfolded rune // an open group
On, Off rune // a toggle
Required rune // after a required field's label
}
var (
GlyphsUnicode = Glyphs{Focus: '▌', Pointer: '▸', Folded: '▸', Unfolded: '▾', On: '●', Off: '○', Required: '*'}
GlyphsCP437 = Glyphs{Focus: '▌', Pointer: '►', Folded: '►', Unfolded: '▼', On: '•', Off: '○', Required: '*'}
GlyphsASCII = Glyphs{Focus: '>', Pointer: '>', Folded: '+', Unfolded: '-', On: '*', Off: '-', Required: '*'}
)
// DefaultTheme is a dark theme for true color and 256-color terminals
var DefaultTheme = Theme{
Bg: color.RGB{R: 20, G: 20, B: 30},
Fg: color.RGB{R: 200, G: 200, B: 200},
FocusBg: color.RGB{R: 30, G: 35, B: 45},
CursorBg: color.RGB{R: 50, G: 50, B: 70},
Selected: color.RGB{R: 80, G: 200, B: 80},
Unselected: color.RGB{R: 100, G: 100, B: 100},
Partial: color.RGB{R: 80, G: 160, B: 220},
Error: color.RGB{R: 255, G: 80, B: 80},
Warning: color.RGB{R: 255, G: 80, B: 80},
Border: color.RGB{R: 60, G: 80, B: 100},
HeaderBg: color.RGB{R: 40, G: 60, B: 90},
HeaderFg: color.RGB{R: 255, G: 255, B: 255},
StatusFg: color.RGB{R: 140, G: 140, B: 140},
HintFg: color.RGB{R: 100, G: 180, B: 200},
InputBg: color.RGB{R: 30, G: 30, B: 50},
DirFg: color.RGB{R: 130, G: 170, B: 220},
FileFg: color.RGB{R: 200, G: 200, B: 200},
SymbolFg: color.RGB{R: 180, G: 220, B: 220},
SyntaxComment: color.RGB{R: 100, G: 110, B: 120},
SyntaxString: color.RGB{R: 180, G: 220, B: 140},
SyntaxKeyword: color.RGB{R: 180, G: 140, B: 220},
SyntaxType: color.RGB{R: 80, G: 200, B: 200},
SyntaxNumber: color.RGB{R: 220, G: 180, B: 120},
SyntaxSymbol: color.RGB{R: 220, G: 180, B: 80},
Text: Style{Fg: color.RGB{R: 200, G: 200, B: 200}, Bg: color.RGB{R: 20, G: 20, B: 30}},
Muted: Style{Fg: color.RGB{R: 120, G: 125, B: 140}},
Accent: Style{Fg: color.RGB{R: 80, G: 160, B: 220}, Attr: terminal.AttrBold},
Selected: Style{Bg: color.RGB{R: 40, G: 45, B: 65}},
Input: Style{Bg: color.RGB{R: 30, G: 30, B: 50}},
Cursor: Style{Fg: color.RGB{R: 20, G: 20, B: 30}, Bg: color.RGB{R: 200, G: 200, B: 200}},
Error: Style{Fg: color.RGB{R: 255, G: 80, B: 80}},
Border: Style{Fg: color.RGB{R: 60, G: 80, B: 100}},
Glyphs: GlyphsUnicode,
}
// MonoTheme draws with attributes alone in the terminal's own colors: the
// theme for ColorModeNone, and one that reads on any terminal
var MonoTheme = Theme{
Text: Style{Attr: own | terminal.AttrBgDefault},
Muted: Style{Attr: own | terminal.AttrDim},
Accent: Style{Attr: own | terminal.AttrBold},
Selected: Style{Attr: own | terminal.AttrReverse},
Input: Style{Attr: own | terminal.AttrUnderline},
Cursor: Style{Attr: own | terminal.AttrReverse},
Error: Style{Attr: own | terminal.AttrBold | terminal.AttrUnderline},
Border: Style{Attr: own},
Glyphs: GlyphsUnicode,
}
// Theme16 uses the ANSI 16 colors alone, in the terminal's own shades, on
// its own background: the theme for ColorMode16, a text console's
var Theme16 = Theme{
Text: Style{Attr: own | terminal.AttrBgDefault},
Muted: ansi(color.ANSIBrightBlack, 0),
Accent: ansi(color.ANSICyan, terminal.AttrBold),
Selected: Style{Attr: own | terminal.AttrReverse},
Input: Style{Attr: own | terminal.AttrUnderline},
Cursor: Style{Attr: own | terminal.AttrReverse},
Error: ansi(color.ANSIRed, terminal.AttrBold),
Border: Style{Attr: own},
Glyphs: GlyphsUnicode,
}
// ThemeFor returns the theme for a color mode: DefaultTheme with 256 colors
// or more, Theme16 with 16, MonoTheme with none
func ThemeFor(mode terminal.ColorMode) Theme {
switch mode {
case terminal.ColorMode16:
return Theme16
case terminal.ColorModeNone:
return MonoTheme
}
return DefaultTheme
}
// own is the terminal's own foreground
const own = terminal.AttrFgDefault
// ansi is a foreground of the ANSI 16, in the terminal's shade of it
func ansi(i uint8, attr terminal.Attr) Style {
return Style{Fg: color.RGB{R: i}, Attr: terminal.AttrFg256 | attr}
}
+1 -2
View File
@@ -98,7 +98,7 @@ func (r Region) Tree(nodes []TreeNode, cursor, scroll int, opts TreeOpts) int {
lineFg := opts.LineFg
if lineFg == (color.RGB{}) {
lineFg = DefaultTheme.Border
lineFg = DefaultTheme.Border.Fg
}
rendered := 0
@@ -338,4 +338,3 @@ func FindPrevSiblingIndex(nodes []TreeNode, idx int) int {
}
return -1
}
+250
View File
@@ -0,0 +1,250 @@
package tui
import (
"strings"
"testing"
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// canvas returns a w x h region filled with th's panel
func canvas(w, h int, th Theme) Region {
r := NewRegion(make([]terminal.Cell, w*h), w, 0, 0, w, h)
r.FillStyle(th.Text)
return r
}
// row returns the runes of row y, trailing spaces trimmed
func row(r Region, y int) string {
var b strings.Builder
for x := 0; x < r.W; x++ {
b.WriteRune(r.Cells[y*r.TotalW+x].Rune)
}
return strings.TrimRight(b.String(), " ")
}
// A transparent background keeps the kind of the one beneath it: text drawn,
// or a style put, on the terminal's own background or a palette color stays
// on it
func TestTransparentBackgroundKeepsItsKind(t *testing.T) {
for _, panel := range []Style{MonoTheme.Text, {Bg: color.RGB{R: 4}, Attr: terminal.AttrBg256}} {
r := canvas(4, 1, Theme{Text: panel})
r.Text(0, 0, "ab", color.RGB{R: 1}, color.RGB{}, terminal.AttrBold)
if c := r.Cells[0]; c.Bg != panel.Bg || c.Attrs != terminal.AttrBold|panel.Attr&terminal.AttrBgColor {
t.Errorf("on %+v: %+v", panel, c)
}
if on := panel.On(Style{Attr: terminal.AttrReverse}); on.Bg != panel.Bg || on.Attr != panel.Attr|terminal.AttrReverse {
t.Errorf("%+v on reverse: %+v", panel, on)
}
}
}
// Every widget marks its selection with a glyph, so it reads with no color
func TestSelectionChangesAGlyph(t *testing.T) {
for _, th := range []Theme{DefaultTheme, Theme16, MonoTheme} {
selectionChangesAGlyph(t, th)
}
}
func selectionChangesAGlyph(t *testing.T, th Theme) {
options := NewOptionListState([]Option{{Name: "file"}, {Name: "tcp"}})
for name, draw := range map[string]func(r Region, on bool){
"choice": func(r Region, on bool) { r.Choice(0, 0, []string{"raw", "txt"}, map[bool]int{true: 1}[on], th) },
"toggle": func(r Region, on bool) { r.Toggle(0, 0, on, th) },
"field": func(r Region, on bool) { r.Field(0, 6, Field{Label: "port"}, on, th) },
"group": func(r Region, on bool) { r.Group(0, "tls", "", on, false, th) },
"option list": func(r Region, on bool) {
options.Cursor = map[bool]int{true: 1}[on]
r.OptionList(options, th)
},
} {
off, on := canvas(20, 3, th), canvas(20, 3, th)
draw(off, false)
draw(on, true)
if row(off, 0)+row(off, 1)+row(off, 2) == row(on, 0)+row(on, 1)+row(on, 2) {
t.Errorf("%s: %q either way", name, row(on, 0)+"|"+row(on, 1))
}
}
}
// The 16-color theme names only ANSI colors and the terminal's own, which a
// text console shows as it shows everything else; ThemeFor picks it
func TestTheme16NamesOnlyANSIColors(t *testing.T) {
ansiOrOwn := func(c color.RGB, attr, palette, own terminal.Attr, transparent bool) bool {
return attr&own != 0 || attr&palette != 0 && c.R < 16 || transparent && c == (color.RGB{}) && attr&(palette|own) == 0
}
for _, s := range []Style{Theme16.Text, Theme16.Muted, Theme16.Accent, Theme16.Selected, Theme16.Input, Theme16.Cursor, Theme16.Error, Theme16.Border} {
if !ansiOrOwn(s.Fg, s.Attr, terminal.AttrFg256, terminal.AttrFgDefault, false) || !ansiOrOwn(s.Bg, s.Attr, terminal.AttrBg256, terminal.AttrBgDefault, true) {
t.Errorf("%+v", s)
}
}
if ThemeFor(terminal.ColorMode16).Muted != Theme16.Muted || ThemeFor(terminal.ColorModeNone).Muted != MonoTheme.Muted ||
ThemeFor(terminal.ColorModeTrueColor).Muted != DefaultTheme.Muted {
t.Error("ThemeFor")
}
}
// The option list matches name or hint regardless of case, a changed filter
// puts the cursor on the first match, and the cursor stays on the matches
func TestOptionListFilters(t *testing.T) {
s := NewOptionListState([]Option{{Name: "file", Hint: "tail files"}, {Name: "Console", Hint: "stdin or stdout"},
{Name: "tcp", Hint: "listen on TCP"}, {Name: "http", Hint: "Serve HTTP"}})
for range 5 {
s.HandleKey(terminal.KeyDown, 0, 0)
}
if i, _ := s.Chosen(); i != 3 {
t.Fatalf("past the end: %d", i)
}
s.Paste("t")
if m := s.Matches(); len(m) != 4 || s.Cursor != 0 {
t.Fatalf("t: %v cursor %d", m, s.Cursor)
}
s.HandleKey(terminal.KeyDown, 0, 0)
if s.HandleKey(terminal.KeyLeft, 0, 0); s.Cursor != 1 {
t.Fatalf("filter cursor moved: list cursor %d", s.Cursor)
}
if s.HandleKey(terminal.KeyBackspace, 0, 0); s.Filter.Value() != "t" || s.Cursor != 1 {
t.Fatalf("nothing before the filter cursor: %q cursor %d", s.Filter.Value(), s.Cursor)
}
s.HandleKey(terminal.KeyRight, 0, 0)
if s.HandleKey(terminal.KeyBackspace, 0, 0); s.Filter.Value() != "" || s.Cursor != 0 {
t.Fatalf("erased: %q cursor %d", s.Filter.Value(), s.Cursor)
}
for filter, want := range map[string]int{"TCP": 2, "cons": 1, "serve h": 3} {
s.Filter.SetValue(filter)
if m := s.Matches(); len(m) != 1 || m[0] != want {
t.Errorf("%s: %v", filter, m)
}
}
s.Filter.SetValue("udp")
if _, ok := s.Chosen(); ok {
t.Fatal("udp matched")
}
}
// A field holds no control character, which the terminal or a file it is
// saved to would act on: pasted line breaks and tabs become spaces, other
// controls, pasted or typed, are dropped
func TestFieldHoldsNoControls(t *testing.T) {
var f TextFieldState
f.Paste("a\x1b[31mb\r\nc\td\u009b")
f.HandleKey(terminal.KeyRune, 0x9b, 0)
f.HandleKey(terminal.KeyRune, 0x85, 0)
if got := f.Value(); got != "a[31mb c d" {
t.Fatalf("%q", got)
}
}
// A field whose text the caller replaced draws and takes input without
// panicking: the cursor stays within the text
func TestTextInputSurvivesReplacedText(t *testing.T) {
f := NewTextFieldState("abcdefghij")
canvas(4, 1, DefaultTheme).TextInput(f, "", true, DefaultTheme)
f.Text = []rune("ab")
canvas(4, 1, DefaultTheme).TextInput(f, "", true, DefaultTheme)
f.Cursor = 9
f.Paste("x")
type press struct {
key terminal.Key
mod terminal.Modifier
}
ctrl := terminal.ModCtrl
for cursor, keys := range map[int][]press{
9: {{terminal.KeyBackspace, 0}, {terminal.KeyCtrlW, 0}, {terminal.KeyCtrlU, 0}, {terminal.KeyLeft, ctrl}, {terminal.KeyRight, 0}},
-1: {{terminal.KeyDelete, 0}, {terminal.KeyDelete, ctrl}, {terminal.KeyRight, ctrl}, {terminal.KeyLeft, 0}, {terminal.KeyCtrlK, 0}},
} {
for _, k := range keys {
f.Text, f.Cursor = []rune("a b"), cursor
f.HandleKey(k.key, 0, k.mod)
}
}
f.Text, f.Cursor = []rune("a b"), -1
f.HandleKey(terminal.KeyCtrlK, 0, 0)
if got := f.Value(); got != "" {
t.Fatalf("%q", got)
}
}
// A typed field takes only its runes, typed or pasted
func TestTypedFieldTakesItsRunes(t *testing.T) {
f := TextFieldState{Accept: AcceptInteger}
for _, r := range "4a2" {
f.HandleKey(terminal.KeyRune, r, 0)
}
f.Paste("1 0x8")
if got := f.Value(); got != "42108" {
t.Fatalf("%q", got)
}
}
// Tab and Shift+Tab move focus around the ring, wrapping either way
func TestFocusRingWraps(t *testing.T) {
f := Focus{Len: 3}
f.HandleKey(terminal.KeyBacktab, terminal.ModShift)
back := f.Index
f.HandleKey(terminal.KeyTab, terminal.ModShift)
shifted := f.Index
for range 2 {
f.HandleKey(terminal.KeyTab, 0)
}
if back != 2 || shifted != 1 || f.Index != 0 || f.HandleKey(terminal.KeyEnter, 0) {
t.Fatalf("back %d, shifted %d, then %d", back, shifted, f.Index)
}
}
// A field shows its error whether or not it has focus, its help only with
// focus, and its value beside the label column
func TestFieldShowsErrorOrHelp(t *testing.T) {
f := Field{Label: "port", Required: true, Help: "listen port"}
r := canvas(30, 2, DefaultTheme)
value, rows := r.Field(0, 6, f, false, DefaultTheme)
if rows != 1 || value.X != 9 || row(r, 0) != " port *" {
t.Fatalf("unfocused: %d rows, value at %d, %q", rows, value.X, row(r, 0))
}
r = canvas(30, 2, DefaultTheme)
if _, rows = r.Field(0, 6, f, true, DefaultTheme); rows != 2 || row(r, 1) != " listen port" {
t.Fatalf("focused: %d rows, %q", rows, row(r, 1))
}
f.Error = "port: want 1-65535"
r = canvas(30, 2, DefaultTheme)
if _, rows = r.Field(0, 6, f, false, DefaultTheme); rows != 2 || !strings.HasSuffix(row(r, 1), "want 1-65535") {
t.Fatalf("error: %d rows, %q", rows, row(r, 1))
}
}
// An empty input shows its default, muted, with the cursor over it; typed
// text replaces it
func TestTextInputShowsTheDefaultUntilTyped(t *testing.T) {
var f TextFieldState
r := canvas(10, 1, DefaultTheme)
r.TextInput(&f, "8080", true, DefaultTheme)
if c := r.Cells[1]; row(r, 0) != "8080" || c.Fg != DefaultTheme.Muted.Fg || r.Cells[0].Bg != DefaultTheme.Cursor.Bg {
t.Fatalf("empty: %q %+v", row(r, 0), c)
}
f.HandleKey(terminal.KeyRune, '9', 0)
r.TextInput(&f, "8080", true, DefaultTheme)
if c := r.Cells[0]; row(r, 0) != "9" || c.Fg != DefaultTheme.Text.Fg || c.Bg != DefaultTheme.Input.Bg {
t.Fatalf("typed: %q %+v", row(r, 0), c)
}
}
// A choice moves with Left and Right, or h and l, and stops at either end
func TestChoiceStopsAtTheEnds(t *testing.T) {
i, _ := StepChoice(terminal.KeyRune, 'l', 0, 2)
j, moved := StepChoice(terminal.KeyRight, 0, i, 2)
k, _ := StepChoice(terminal.KeyLeft, 0, j, 2)
if i != 1 || j != 1 || moved || k != 0 {
t.Fatalf("%d %d %v %d", i, j, moved, k)
}
}
// A folded group says what it holds; an open one shows it instead
func TestGroupSummarisesWhileFolded(t *testing.T) {
folded, open := canvas(30, 1, MonoTheme), canvas(30, 1, MonoTheme)
folded.Group(0, "tls", "on, pinned", false, false, MonoTheme)
open.Group(0, "tls", "on, pinned", true, false, MonoTheme)
if row(folded, 0) != " ▸ tls on, pinned" || row(open, 0) != " ▾ tls" {
t.Fatalf("%q %q", row(folded, 0), row(open, 0))
}
}