cat / 012
clone

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

Shortcuts view: package shortcuts behind a four-method host

Dami committed 606abb1
The view scrolls rows the host builds from the key bindings and the
command registry; tested against a fake host.
commit
606abb1bdbaa929d9a5c9604d86feebb353b4450
parent
708ec6688ca52f2353b6fcd31d317903f4a16e22
author
Dami <dami.etoile@gmail.com>
kind
refactor risk score 1.4 of 3, Jev's estimate
7 files changed, +288 -150
Minternal/ui/format_test.go3+-
Minternal/ui/help.go147++-----------------------
Minternal/ui/help_test.go21++--
Minternal/ui/hosts.go2+
Ainternal/ui/shortcuts/shortcuts.go179++++++++++++++++++++++++++++++
Ainternal/ui/shortcuts/shortcuts_test.go82++++++++++++++
Minternal/ui/vim_test.go4+-
M internal/ui/format_test.go +2 -1
M internal/ui/help.go +9 -138
@@ -4,22 +4,19 @@ import (
44 "cmp"55 "runtime/debug"66 "slices"7- "strconv"87 "strings"98 109 tea "charm.land/bubbletea/v2"11- "github.com/charmbracelet/x/ansi"1210 1311 "github.com/FelineStateMachine/012/internal/sheet"14- "github.com/FelineStateMachine/012/internal/ui/overlay"1512 "github.com/FelineStateMachine/012/internal/ui/picker"16- "github.com/FelineStateMachine/012/internal/ui/theme"13+ "github.com/FelineStateMachine/012/internal/ui/shortcuts"1714 )1815 1916 func init() {2017 register(2118 &command{id: "help", macro: macroNever, title: "Keyboard shortcuts", desc: "Show every key, grouped by what it does", run: func(m *Model) tea.Cmd {22- m.openOverlay(&shortcuts{m: m})19+ m.openOverlay(shortcuts.New(m.host()))2320 return nil2421 }},2522 &command{id: "help.functions", macro: macroNever, title: "Function list", desc: "Search the functions formulas can use", run: func(m *Model) tea.Cmd {
@@ -171,139 +168,13 @@ func keyHelpRows(vim bool) []helpRow {
171168 return rows172169 }173170 174-// shortcuts is the keyboard shortcuts view: a scrollable box over the175-// grid, in two columns when the screen is wide enough.176-type shortcuts struct {177- m *Model // the model it acts on178- top int179-}180-181-const shortcutsID = "shortcuts"182-183-func (s *shortcuts) Indicator() string { return "HELP" }184-185-// lines renders the rows, as one or two columns, and returns their width.186-func (s *shortcuts) lines(m *Model) ([]string, int) {187- rows := helpRows(m.prefs.vim)188- keyW, actW := 0, 0189- for _, r := range rows {190- keyW = max(keyW, ansi.StringWidth(m.th.Chips(r.keys)))191- actW = max(actW, ansi.StringWidth(r.action))192- }193- // On narrow screens actions give way (truncated) so the keys fit.194- room := min(m.width-4, 160)195- if 1+keyW+2+actW > room {196- actW = max(room-3-keyW, 16)197- }198- colW := 1 + keyW + 2 + actW199- render := func(rows []helpRow) []string {200- var out []string201- for i, r := range rows {202- switch {203- case r.heading != "" && i > 0:204- out = append(out, "", m.th.Title.Render(" "+r.heading))205- case r.heading != "":206- out = append(out, m.th.Title.Render(" "+r.heading))207- default:208- out = append(out, " "+theme.PadRight(ansi.Truncate(r.action, actW, "…"), actW)+" "+m.th.Chips(r.keys))209- }210- }211- return out212- }213- if 2*colW+3 > room {214- return render(rows), min(colW, room)215- }216- // Two columns, split at the group boundary nearest the middle.217- split, best := 0, len(rows)171+// Shortcuts are the rows of the shortcuts view (package shortcuts),172+// with the keys in use.173+func (h host) Shortcuts() []shortcuts.Row {174+ rows := helpRows(h.m.prefs.vim)175+ out := make([]shortcuts.Row, len(rows))218176 for i, r := range rows {219- if d := abs(len(rows) - 2*i); r.heading != "" && d < best {220- split, best = i, d221- }222- }223- left, right := render(rows[:split]), render(rows[split:])224- out := make([]string, max(len(left), len(right)))225- for i := range out {226- var l, r string227- if i < len(left) {228- l = left[i]229- }230- if i < len(right) {231- r = right[i]232- }233- out[i] = theme.PadRight(l, colW) + " " + r234- }235- return out, 2*colW + 3236-}237-238-func abs(x int) int { return max(x, -x) }239-240-// visible is how many lines fit between the menu bar and the status line.241-func (s *shortcuts) visible(m *Model, total int) int {242- return max(min(total, m.height-2-2-1), 1)243-}244-245-func (s *shortcuts) Layout() []overlay.Box {246- m := s.m247- lines, w := s.lines(m)248- inner := w + 1249- n := s.visible(m, len(lines)+1)250- s.top = clamp(s.top, 0, max(len(lines)+1-n, 0))251- lines = append([]string{""}, lines...) // breathing room under the title252- rows := make([]string, n)253- for i := range rows {254- if j := s.top + i; j < len(lines) {255- rows[i] = theme.Cells(m.th.MenuBar, lines[j], inner)256- } else {257- rows[i] = strings.Repeat(" ", inner)258- }177+ out[i] = shortcuts.Row{Heading: r.heading, Keys: r.keys, Action: r.action}259178 }260- footer := ""261- if n < len(lines) {262- footer = strconv.Itoa(s.top+1) + "-" + strconv.Itoa(s.top+n) + " of " + strconv.Itoa(len(lines))263- }264- b := m.th.Frame(inner, "Keyboard shortcuts", footer, rows)265- w, h := ansi.StringWidth(b[0]), len(b)266- return []overlay.Box{{ID: shortcutsID, X: (m.width - w) / 2, Y: max(menuLine+1, (m.height-1-h)/2), Lines: b}}267-}268-269-func (s *shortcuts) Key(k tea.KeyPressMsg) tea.Cmd {270- m := s.m271- lines, _ := s.lines(m)272- page := s.visible(m, len(lines)+1)273- switch k.String() {274- case "up", "k":275- s.top--276- case "down", "j":277- s.top++278- case "pgup":279- s.top -= page280- case "pgdown", "space":281- s.top += page282- case "home":283- s.top = 0284- case "end":285- s.top = len(lines)286- case "esc", "enter", "q", "f1", "ctrl+/":287- m.closeOverlay()288- }289- s.top = clamp(s.top, 0, max(len(lines)+1-page, 0))290- return nil291-}292-293-func (s *shortcuts) Mouse(e overlay.MouseEvent) tea.Cmd {294- m := s.m295- switch {296- case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:297- s.top = max(s.top-3, 0)298- case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:299- s.top += 3 // layout clamps300- case e.Kind == overlay.MousePress && e.Box != shortcutsID:301- m.closeOverlay()302- }303- return nil304-}305-306-func (s *shortcuts) Status() (string, string) {307- m := s.m308- return "", m.th.KeyHints("Up/Down", "scroll", "Esc", "close")179+ return out309180 }
M internal/ui/help_test.go +11 -10
@@ -6,6 +6,7 @@ import (
66 77 tea "charm.land/bubbletea/v2"88 9+ "github.com/FelineStateMachine/012/internal/ui/shortcuts"910 "github.com/FelineStateMachine/012/internal/ui/theme"1011 )1112
@@ -31,7 +32,7 @@ func TestShortcutsListEveryBoundCommand(t *testing.T) {
3132 func TestShortcutsOverlay(t *testing.T) {3233 m := newModel()3334 press(t, m, "<f1>")34- s, ok := m.overlay.(*shortcuts)35+ s, ok := m.overlay.(*shortcuts.View)3536 if !ok || !strings.HasSuffix(line(m, menuLine), "HELP") {3637 t.Fatalf("F1 opened %T", m.overlay)3738 }
@@ -46,25 +47,25 @@ func TestShortcutsOverlay(t *testing.T) {
4647 t.Errorf("no scroll position in footer:\n%s", scr)4748 }4849 press(t, m, "<down>", "<down>")49- if s.top != 2 {50- t.Errorf("down scrolled to %d", s.top)50+ if s.Top() != 2 {51+ t.Errorf("down scrolled to %d", s.Top())5152 }5253 send(m, tea.MouseWheelMsg{X: 40, Y: 10, Button: tea.MouseWheelUp})53- if m.View(); s.top != 0 {54- t.Errorf("wheel up scrolled to %d", s.top)54+ if m.View(); s.Top() != 0 {55+ t.Errorf("wheel up scrolled to %d", s.Top())5556 }5657 press(t, m, "<end>")57- bottom := s.top58+ bottom := s.Top()5859 press(t, m, "<down>")59- if bottom == 0 || s.top != bottom {60- t.Errorf("End scrolled to %d, then Down to %d", bottom, s.top)60+ if bottom == 0 || s.Top() != bottom {61+ t.Errorf("End scrolled to %d, then Down to %d", bottom, s.Top())6162 }6263 press(t, m, "<esc>")6364 if m.overlay != nil || m.mode != modeReady {6465 t.Error("Esc did not close the shortcuts")6566 }6667 press(t, m, "<ctrl+/>")67- if _, ok := m.overlay.(*shortcuts); !ok {68+ if _, ok := m.overlay.(*shortcuts.View); !ok {6869 t.Error("Ctrl+/ did not open the shortcuts")6970 }7071 leftClick(m, 0, m.height-1)
@@ -77,7 +78,7 @@ func TestShortcutsTwoColumnsWhenWide(t *testing.T) {
7778 m := newModel()7879 m.Update(tea.WindowSizeMsg{Width: 200, Height: 50})7980 press(t, m, "<f1>")80- lines, w := m.overlay.(*shortcuts).lines(m)81+ lines, w := m.overlay.(*shortcuts.View).Lines()8182 if w < 100 || len(lines) > len(helpRows(false)) {8283 t.Errorf("width %d, %d lines for %d rows", w, len(lines), len(helpRows(false)))8384 }
M internal/ui/hosts.go +2 -0
A internal/ui/shortcuts/shortcuts.go +179 -0
@@ -0,0 +1,179 @@
1+// Package shortcuts is the keyboard shortcuts view: a scrollable box2+// over the grid listing every key by what it does, in two columns when3+// the screen is wide enough. The rows come from the host, which builds4+// them from the key bindings and the command registry. It knows the UI5+// only through Host.6+package shortcuts7+8+import (9+ "strconv"10+ "strings"11+12+ tea "charm.land/bubbletea/v2"13+ "github.com/charmbracelet/x/ansi"14+15+ "github.com/FelineStateMachine/012/internal/ui/overlay"16+ "github.com/FelineStateMachine/012/internal/ui/theme"17+)18+19+// Host is what the shortcuts view needs of the UI.20+type Host interface {21+ Theme() *theme.Theme22+ Size() (width, height int)23+ // Close closes the open overlay.24+ Close()25+ // Shortcuts are the rows to list, in order.26+ Shortcuts() []Row27+}28+29+// Row is a line of the view: a group heading, or keys and what they do.30+type Row struct {31+ Heading string32+ Keys []string // as shown, e.g. "Ctrl+G"33+ Action string34+}35+36+// View is the open shortcuts view.37+type View struct {38+ h Host39+ top int40+}41+42+// ID identifies the view's box in mouse events.43+const ID = "shortcuts"44+45+// New returns the view scrolled to the top.46+func New(h Host) *View { return &View{h: h} }47+48+// Top is the first line shown.49+func (s *View) Top() int { return s.top }50+51+func (s *View) Indicator() string { return "HELP" }52+53+// Lines renders the rows, as one or two columns, and returns their54+// width.55+func (s *View) Lines() ([]string, int) {56+ th := s.h.Theme()57+ width, _ := s.h.Size()58+ rows := s.h.Shortcuts()59+ keyW, actW := 0, 060+ for _, r := range rows {61+ keyW = max(keyW, ansi.StringWidth(th.Chips(r.Keys)))62+ actW = max(actW, ansi.StringWidth(r.Action))63+ }64+ // On narrow screens actions give way (truncated) so the keys fit.65+ room := min(width-4, 160)66+ if 1+keyW+2+actW > room {67+ actW = max(room-3-keyW, 16)68+ }69+ colW := 1 + keyW + 2 + actW70+ render := func(rows []Row) []string {71+ var out []string72+ for i, r := range rows {73+ switch {74+ case r.Heading != "" && i > 0:75+ out = append(out, "", th.Title.Render(" "+r.Heading))76+ case r.Heading != "":77+ out = append(out, th.Title.Render(" "+r.Heading))78+ default:79+ out = append(out, " "+theme.PadRight(ansi.Truncate(r.Action, actW, "…"), actW)+" "+th.Chips(r.Keys))80+ }81+ }82+ return out83+ }84+ if 2*colW+3 > room {85+ return render(rows), min(colW, room)86+ }87+ // Two columns, split at the group boundary nearest the middle.88+ split, best := 0, len(rows)89+ for i, r := range rows {90+ if d := abs(len(rows) - 2*i); r.Heading != "" && d < best {91+ split, best = i, d92+ }93+ }94+ left, right := render(rows[:split]), render(rows[split:])95+ out := make([]string, max(len(left), len(right)))96+ for i := range out {97+ var l, r string98+ if i < len(left) {99+ l = left[i]100+ }101+ if i < len(right) {102+ r = right[i]103+ }104+ out[i] = theme.PadRight(l, colW) + " " + r105+ }106+ return out, 2*colW + 3107+}108+109+func abs(x int) int { return max(x, -x) }110+111+// visible is how many lines fit between the menu bar and the status line.112+func (s *View) visible(total int) int {113+ _, height := s.h.Size()114+ return max(min(total, height-2-2-1), 1)115+}116+117+func (s *View) Layout() []overlay.Box {118+ th := s.h.Theme()119+ width, height := s.h.Size()120+ lines, w := s.Lines()121+ inner := w + 1122+ n := s.visible(len(lines) + 1)123+ s.top = min(max(s.top, 0), max(len(lines)+1-n, 0))124+ lines = append([]string{""}, lines...) // breathing room under the title125+ rows := make([]string, n)126+ for i := range rows {127+ if j := s.top + i; j < len(lines) {128+ rows[i] = theme.Cells(th.MenuBar, lines[j], inner)129+ } else {130+ rows[i] = strings.Repeat(" ", inner)131+ }132+ }133+ footer := ""134+ if n < len(lines) {135+ footer = strconv.Itoa(s.top+1) + "-" + strconv.Itoa(s.top+n) + " of " + strconv.Itoa(len(lines))136+ }137+ b := th.Frame(inner, "Keyboard shortcuts", footer, rows)138+ bw, bh := ansi.StringWidth(b[0]), len(b)139+ return []overlay.Box{{ID: ID, X: (width - bw) / 2, Y: max(overlay.MenuLine+1, (height-1-bh)/2), Lines: b}}140+}141+142+func (s *View) Key(k tea.KeyPressMsg) tea.Cmd {143+ lines, _ := s.Lines()144+ page := s.visible(len(lines) + 1)145+ switch k.String() {146+ case "up", "k":147+ s.top--148+ case "down", "j":149+ s.top++150+ case "pgup":151+ s.top -= page152+ case "pgdown", "space":153+ s.top += page154+ case "home":155+ s.top = 0156+ case "end":157+ s.top = len(lines)158+ case "esc", "enter", "q", "f1", "ctrl+/":159+ s.h.Close()160+ }161+ s.top = min(max(s.top, 0), max(len(lines)+1-page, 0))162+ return nil163+}164+165+func (s *View) Mouse(e overlay.MouseEvent) tea.Cmd {166+ switch {167+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:168+ s.top = max(s.top-3, 0)169+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:170+ s.top += 3 // layout clamps171+ case e.Kind == overlay.MousePress && e.Box != ID:172+ s.h.Close()173+ }174+ return nil175+}176+177+func (s *View) Status() (string, string) {178+ return "", s.h.Theme().KeyHints("Up/Down", "scroll", "Esc", "close")179+}
A internal/ui/shortcuts/shortcuts_test.go +82 -0
@@ -0,0 +1,82 @@
1+package shortcuts2+3+import (4+ "strconv"5+ "strings"6+ "testing"7+8+ tea "charm.land/bubbletea/v2"9+ "github.com/charmbracelet/x/ansi"10+11+ "github.com/FelineStateMachine/012/internal/ui/overlay"12+ "github.com/FelineStateMachine/012/internal/ui/theme"13+)14+15+// fakeHost is a screen with forty rows of shortcuts in four groups.16+type fakeHost struct {17+ th theme.Theme18+ w, h int19+ closed int20+}21+22+func (f *fakeHost) Theme() *theme.Theme { return &f.th }23+func (f *fakeHost) Size() (int, int) { return f.w, f.h }24+func (f *fakeHost) Close() { f.closed++ }25+func (f *fakeHost) Shortcuts() []Row {26+ var rows []Row27+ for g := range 4 {28+ rows = append(rows, Row{Heading: "Group " + strconv.Itoa(g)})29+ for i := range 9 {30+ rows = append(rows, Row{Keys: []string{"Ctrl+" + strconv.Itoa(i)}, Action: "Action " + strconv.Itoa(g*10+i) + " does something at some length"})31+ }32+ }33+ return rows34+}35+36+func text(v *View) string {37+ return ansi.Strip(strings.Join(v.Layout()[0].Lines, "\n"))38+}39+40+func TestScrollsAndCloses(t *testing.T) {41+ h := &fakeHost{th: theme.New(true), w: 80, h: 24}42+ v := New(h)43+ out := text(v)44+ for _, want := range []string{"Keyboard shortcuts", "Group 0", "Action 0", "Ctrl+0", "1-19 of 44"} {45+ if !strings.Contains(out, want) {46+ t.Errorf("view lacks %q:\n%s", want, out)47+ }48+ }49+ v.Key(tea.KeyPressMsg{Code: tea.KeyDown})50+ v.Key(tea.KeyPressMsg{Code: tea.KeyDown})51+ if v.Top() != 2 {52+ t.Errorf("down twice scrolled to %d", v.Top())53+ }54+ v.Key(tea.KeyPressMsg{Code: tea.KeyEnd})55+ if v.Top() != 44-19 {56+ t.Errorf("End scrolled to %d", v.Top())57+ }58+ v.Mouse(overlay.MouseEvent{Kind: overlay.MouseWheel, Button: tea.MouseWheelUp})59+ if v.Top() != 44-19-3 {60+ t.Errorf("the wheel scrolled to %d", v.Top())61+ }62+ v.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, Box: ID})63+ if h.closed != 0 {64+ t.Error("a click in the box closed it")65+ }66+ v.Mouse(overlay.MouseEvent{Kind: overlay.MousePress})67+ v.Key(tea.KeyPressMsg{Code: tea.KeyEscape})68+ if h.closed != 2 {69+ t.Errorf("a click outside and Esc closed it %d times", h.closed)70+ }71+}72+73+func TestTwoColumnsWhenWide(t *testing.T) {74+ h := &fakeHost{th: theme.New(true), w: 200, h: 50}75+ lines, w := New(h).Lines()76+ if len(lines) >= 40 || w < 40 {77+ t.Fatalf("%d lines %d wide", len(lines), w)78+ }79+ if first := ansi.Strip(lines[0]); !strings.Contains(first, "Group 0") || !strings.Contains(first, "Group 2") {80+ t.Errorf("the columns split elsewhere: %q", first)81+ }82+}
M internal/ui/vim_test.go +3 -1