cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Choice bars: package choicebar behind a three-method host

Dami committed dd5cb3a
The bar is the question and its choices, which run as closures over the
model; the model asks through ask(question). Tested against a fake host.
commit
dd5cb3abc4aef9480fdcddb1b297b075f4b47491
parent
9471af439d88bfe1f5f990ec1590e858a84452cb
author
Dami <dami.etoile@gmail.com>
kind
refactor risk score 1.7 of 3, Jev's estimate
22 files changed, +262 -111
Ainternal/ui/choicebar/choicebar.go124++++++++++++++++++++++++++++++
Ainternal/ui/choicebar/choicebar_test.go90++++++++++++++++++++++
Minternal/ui/dialog.go86+++------------------
Minternal/ui/download.go5+-
Minternal/ui/file.go3+-
Minternal/ui/help.go3+-
Minternal/ui/hosts.go2+
Minternal/ui/layout_test.go3+-
Minternal/ui/layoutfmt.go3+-
Minternal/ui/macro.go3+-
Minternal/ui/macrohostnav.go13+--
Minternal/ui/macrorec.go3+-
Minternal/ui/protect.go4+-
Minternal/ui/protect_test.go7+-
Minternal/ui/recovery.go3+-
Minternal/ui/saveas_test.go3+-
Minternal/ui/serve_test.go3+-
Minternal/ui/tabs.go2+-
Minternal/ui/transfer.go3+-
Minternal/ui/transfer_test.go3+-
Minternal/ui/undocost.go4+-
Minternal/ui/undocost_test.go3+-
A internal/ui/choicebar/choicebar.go +124 -0
@@ -0,0 +1,124 @@
1+// Package choicebar is a small question asked on the context line and2+// answered with a key, the way terminal programs confirm things, e.g.3+// "You have unsaved changes. Enter Save and quit D Discard Esc Cancel".4+// It takes the keyboard like an overlay but draws no box, and knows the5+// UI only through Host.6+package choicebar7+8+import (9+ "strings"10+11+ tea "charm.land/bubbletea/v2"12+ "github.com/charmbracelet/x/ansi"13+14+ "github.com/FelineStateMachine/012/internal/ui/overlay"15+ "github.com/FelineStateMachine/012/internal/ui/theme"16+)17+18+// Host is what a choice bar needs of the UI.19+type Host interface {20+ Theme() *theme.Theme21+ // Close closes the open overlay.22+ Close()23+ // RecordAnswer tells a macro recording the key chosen, for the24+ // command that asked; Esc cancels it.25+ RecordAnswer(answer string, cancelled bool)26+}27+28+// Choice is one answer: a key as tea reports it ("enter", "d"), its29+// label, and what choosing it does, after the bar has closed.30+type Choice struct {31+ Key string32+ Label string33+ Run func() tea.Cmd34+}35+36+// Bar is the question and its choices.37+type Bar struct {38+ h Host39+ Msg string40+ Warn bool // the message is a warning, e.g. about losing work41+ Desc string // more on the status line, when the question needs it42+ Choices []Choice43+}44+45+// New returns a bar asking msg.46+func New(h Host, msg string, warn bool, desc string, choices []Choice) *Bar {47+ return &Bar{h: h, Msg: msg, Warn: warn, Desc: desc, Choices: choices}48+}49+50+func (b *Bar) Indicator() string { return "MENU" }51+func (b *Bar) Layout() []overlay.Box { return nil }52+func (b *Bar) Status() (string, string) { return b.Desc, "" }53+54+func (b *Bar) Key(k tea.KeyPressMsg) tea.Cmd {55+ key := strings.ToLower(k.String())56+ for i, ch := range b.Choices {57+ if key == ch.Key {58+ return b.Choose(i)59+ }60+ }61+ return nil62+}63+64+// Find is the index of the choice with key or label answer, ignoring65+// case, or -1.66+func (b *Bar) Find(answer string) int {67+ for i, ch := range b.Choices {68+ if strings.EqualFold(ch.Key, answer) || strings.EqualFold(ch.Label, answer) {69+ return i70+ }71+ }72+ return -173+}74+75+// Choose closes the bar and runs choice i.76+func (b *Bar) Choose(i int) tea.Cmd {77+ ch := b.Choices[i]78+ b.h.Close()79+ cmd := ch.Run()80+ b.h.RecordAnswer(ch.Key, ch.Key == "esc")81+ return cmd82+}83+84+// Mouse runs a choice when its key chip or label is clicked. A click85+// anywhere else cancels, like Esc.86+func (b *Bar) Mouse(e overlay.MouseEvent) tea.Cmd {87+ if e.Kind != overlay.MousePress {88+ return nil89+ }90+ if e.Y == overlay.ContextLine {91+ x := ansi.StringWidth(b.prefix())92+ for i, ch := range b.Choices {93+ w := ansi.StringWidth(b.item(ch))94+ if e.X >= x && e.X < x+w {95+ return b.Choose(i)96+ }97+ x += w + len(gap)98+ }99+ }100+ b.h.Close()101+ return nil102+}103+104+const gap = " "105+106+func (b *Bar) prefix() string {107+ if b.Warn {108+ return b.h.Theme().Warning.Render(b.Msg) + gap109+ }110+ return b.Msg + gap111+}112+113+func (b *Bar) item(ch Choice) string {114+ return b.h.Theme().Chip(theme.KeyLabel(ch.Key)) + " " + ch.Label115+}116+117+// ContextLine renders the bar for the context line.118+func (b *Bar) ContextLine() (string, string) {119+ items := make([]string, len(b.Choices))120+ for i, ch := range b.Choices {121+ items[i] = b.item(ch)122+ }123+ return b.prefix() + strings.Join(items, gap), ""124+}
A internal/ui/choicebar/choicebar_test.go +90 -0
@@ -0,0 +1,90 @@
1+package choicebar2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/ui/overlay"11+ "github.com/FelineStateMachine/012/internal/ui/theme"12+)13+14+// fakeHost records what the bar did.15+type fakeHost struct {16+ th theme.Theme17+ closed int18+ answers []string19+}20+21+func (f *fakeHost) Theme() *theme.Theme { return &f.th }22+func (f *fakeHost) Close() { f.closed++ }23+24+func (f *fakeHost) RecordAnswer(answer string, cancelled bool) {25+ if cancelled {26+ answer = "(cancelled)"27+ }28+ f.answers = append(f.answers, answer)29+}30+31+// newBar asks about unsaved changes, noting the choice made in *ran.32+func newBar(ran *string) (*Bar, *fakeHost) {33+ h := &fakeHost{th: theme.New(true)}34+ run := func(what string) func() tea.Cmd { return func() tea.Cmd { *ran = what; return nil } }35+ return New(h, "You have unsaved changes.", true, "More", []Choice{36+ {Key: "enter", Label: "Save and quit", Run: run("save")},37+ {Key: "d", Label: "Discard", Run: run("discard")},38+ {Key: "esc", Label: "Cancel", Run: run("cancel")},39+ }), h40+}41+42+func TestKeysChoose(t *testing.T) {43+ var ran string44+ b, h := newBar(&ran)45+ b.Key(tea.KeyPressMsg{Code: 'x', Text: "x"})46+ if ran != "" || h.closed != 0 {47+ t.Fatalf("a key of no choice acted: %q, closed %d", ran, h.closed)48+ }49+ b.Key(tea.KeyPressMsg{Code: 'D', Text: "D", Mod: tea.ModShift})50+ if ran != "discard" || h.closed != 1 || strings.Join(h.answers, ",") != "d" {51+ t.Fatalf("D: ran %q, closed %d, answers %v", ran, h.closed, h.answers)52+ }53+ b.Key(tea.KeyPressMsg{Code: tea.KeyEscape})54+ if ran != "cancel" || h.answers[1] != "(cancelled)" {55+ t.Fatalf("Esc: ran %q, answers %v", ran, h.answers)56+ }57+}58+59+func TestFindByKeyOrLabel(t *testing.T) {60+ var ran string61+ b, _ := newBar(&ran)62+ for answer, want := range map[string]int{"enter": 0, "DISCARD": 1, "Esc": 2, "nope": -1} {63+ if got := b.Find(answer); got != want {64+ t.Errorf("Find(%q) = %d, want %d", answer, got, want)65+ }66+ }67+}68+69+func TestClickChoosesOrCancels(t *testing.T) {70+ var ran string71+ b, h := newBar(&ran)72+ line, _ := b.ContextLine()73+ plain := ansi.Strip(line)74+ if !strings.HasPrefix(plain, "You have unsaved changes. ") || !strings.Contains(plain, "Discard ") {75+ t.Fatalf("context line %q", plain)76+ }77+ x := strings.Index(plain, "Discard")78+ b.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, X: x, Y: overlay.ContextLine})79+ if ran != "discard" {80+ t.Fatalf("clicking Discard ran %q", ran)81+ }82+ ran = ""83+ b.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, X: 0, Y: overlay.ContextLine + 3})84+ if ran != "" || h.closed != 2 {85+ t.Fatalf("a click elsewhere should only close: ran %q, closed %d", ran, h.closed)86+ }87+ if desc, _ := b.Status(); desc != "More" {88+ t.Fatalf("status %q", desc)89+ }90+}
M internal/ui/dialog.go +11 -75
@@ -1,21 +1,15 @@
11 package ui22 33 import (4- "strings"5-64 tea "charm.land/bubbletea/v2"7- "github.com/charmbracelet/x/ansi"85 9- "github.com/FelineStateMachine/012/internal/ui/overlay"10- "github.com/FelineStateMachine/012/internal/ui/theme"6+ "github.com/FelineStateMachine/012/internal/ui/choicebar"117 )128 13-// choiceBar is a small question asked on the context line and answered14-// with a key, the way terminal programs confirm things, e.g.9+// question is a choice bar (package choicebar) as the model asks it:10+// a small question on the context line answered with a key, e.g.1511 // "You have unsaved changes. Enter Save and quit D Discard Esc Cancel".16-// It takes the keyboard like an overlay but draws no box.17-type choiceBar struct {18- m *Model // the model it acts on12+type question struct {1913 msg string2014 warn bool // the message is a warning, e.g. about losing work2115 desc string // more on the status line, when the question needs it
@@ -28,70 +22,13 @@ type choice struct {
2822 run func(m *Model) tea.Cmd2923 }3024 31-func (c *choiceBar) Indicator() string { return "MENU" }32-func (c *choiceBar) Layout() []overlay.Box { return nil }33-func (c *choiceBar) Status() (string, string) { return c.desc, "" }34-35-func (c *choiceBar) Key(k tea.KeyPressMsg) tea.Cmd {36- m := c.m37- key := strings.ToLower(k.String())38- for _, ch := range c.choices {39- if key == ch.key {40- return c.choose(m, ch)41- }42- }43- return nil44-}45-46-func (c *choiceBar) choose(m *Model, ch choice) tea.Cmd {47- m.closeOverlay()48- cmd := ch.run(m)49- m.recordAnswer(ch.key, ch.key == "esc")50- return cmd51-}52-53-// Mouse runs a choice when its key chip or label is clicked. A click54-// anywhere else cancels, like Esc.55-func (c *choiceBar) Mouse(e overlay.MouseEvent) tea.Cmd {56- m := c.m57- if e.Kind != overlay.MousePress {58- return nil59- }60- if e.Y == contextLine {61- x := ansi.StringWidth(c.prefix(m))62- for _, ch := range c.choices {63- w := ansi.StringWidth(c.item(m, ch))64- if e.X >= x && e.X < x+w {65- return c.choose(m, ch)66- }67- x += w + len(choiceGap)68- }69- }70- m.closeOverlay()71- return nil72-}73-74-const choiceGap = " "75-76-func (c *choiceBar) prefix(m *Model) string {77- if c.warn {78- return m.th.Warning.Render(c.msg) + choiceGap79- }80- return c.msg + choiceGap81-}82-83-func (c *choiceBar) item(m *Model, ch choice) string {84- return m.th.Chip(theme.KeyLabel(ch.key)) + " " + ch.label85-}86-87-// ContextLine renders the bar for the context line.88-func (c *choiceBar) ContextLine() (string, string) {89- m := c.m90- items := make([]string, len(c.choices))91- for i, ch := range c.choices {92- items[i] = c.item(m, ch)25+// ask opens a choice bar with q's question.26+func (m *Model) ask(q question) {27+ chs := make([]choicebar.Choice, len(q.choices))28+ for i, ch := range q.choices {29+ chs[i] = choicebar.Choice{Key: ch.key, Label: ch.label, Run: func() tea.Cmd { return ch.run(m) }}9330 }94- return c.prefix(m) + strings.Join(items, choiceGap), ""31+ m.openOverlay(choicebar.New(m.host(), q.msg, q.warn, q.desc, chs))9532 }9633 9734 // quit exits, asking first when there are unsaved changes. Enter saves
@@ -100,8 +37,7 @@ func (m *Model) quit() tea.Cmd {
10037 if !m.changed {10138 return m.exit()10239 }103- m.openOverlay(&choiceBar{104- m: m,40+ m.ask(question{10541 msg: "You have unsaved changes.",10642 warn: true,10743 choices: []choice{
M internal/ui/download.go +2 -3
M internal/ui/file.go +1 -2
M internal/ui/help.go +1 -2
M internal/ui/hosts.go +2 -0
M internal/ui/layout_test.go +2 -1
M internal/ui/layoutfmt.go +2 -1
M internal/ui/macro.go +1 -2
M internal/ui/macrohostnav.go +5 -8
@@ -3,11 +3,11 @@ package ui
33 import (44 "errors"55 "fmt"6- "slices"76 "strconv"87 "strings"98 109 "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/ui/choicebar"1111 "github.com/FelineStateMachine/012/internal/ui/picker"1212 )1313
@@ -320,17 +320,14 @@ func (h scriptHost) choose(c *command, answer *string) error {
320320 if p, ok := m.overlay.(*picker.Picker); ok && p.Answers {321321 return h.pickAnswer(c, p, answer)322322 }323- bar, ok := m.overlay.(*choiceBar)323+ bar, ok := m.overlay.(*choicebar.Bar)324324 if !ok {325325 m.closeOverlay()326326 return fmt.Errorf("%s opens a dialog; it can't run in a macro", c.title)327327 }328328 if answer != nil {329- i := slices.IndexFunc(bar.choices, func(ch choice) bool {330- return strings.EqualFold(ch.key, *answer) || strings.EqualFold(ch.label, *answer)331- })332- if i >= 0 {333- m.macros.cmds = append(m.macros.cmds, bar.choose(m, bar.choices[i]))329+ if i := bar.Find(*answer); i >= 0 {330+ m.macros.cmds = append(m.macros.cmds, bar.Choose(i))334331 if m.mode == modeError {335332 return h.failure()336333 }
@@ -338,7 +335,7 @@ func (h scriptHost) choose(c *command, answer *string) error {
338335 }339336 }340337 m.closeOverlay()341- return fmt.Errorf("%s asks %q: give run(%q, answer=...) with one of its keys", c.title, bar.msg, c.id)338+ return fmt.Errorf("%s asks %q: give run(%q, answer=...) with one of its keys", c.title, bar.Msg, c.id)342339 }343340 344341 // pickAnswer answers a picker that asks a command's question with the
M internal/ui/macrorec.go +2 -1
M internal/ui/protect.go +2 -2
M internal/ui/protect_test.go +4 -3
M internal/ui/recovery.go +1 -2
M internal/ui/saveas_test.go +2 -1
M internal/ui/serve_test.go +2 -1
M internal/ui/tabs.go +1 -1
M internal/ui/transfer.go +1 -2
M internal/ui/transfer_test.go +2 -1
M internal/ui/undocost.go +2 -2
M internal/ui/undocost_test.go +2 -1