v0.2.0 16 colors, default colors, /dev/tty, bracketed paste, themed form controls
This commit is contained in:
32 files changed
+1447
-424
No files matched your search
+54
-11
@@ -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.
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user