cat / 012
clone

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

Route menu, keys and help through one command registry

Dami committed 48ae55d
Every action is registered once with a title and description, reached
from the slash menu and a data-driven READY-mode keymap. Help lists
shortcuts from the keymap so it can't drift from behavior.
commit
48ae55d409f7747e9bb49cccb1ded8a0ce70690d
parent
45cb7f052a52336d2c1e00c94419a2892356960b
author
Dami <dami.etoile@gmail.com>
3 files changed, +150 -52
Ainternal/ui/commands.go105++++++++++++++++++++++++++++++
Minternal/ui/menu.go52++++++---------
Minternal/ui/model.go45+++++++------
A internal/ui/commands.go +105 -0
@@ -0,0 +1,105 @@
1+package ui2+3+import (4+ "slices"5+6+ tea "charm.land/bubbletea/v2"7+8+ "one23/internal/sheet"9+)10+11+// command is a user-facing action. Every action is registered once here12+// and reached from the slash menu, key bindings, help and the command13+// palette, so titles, descriptions and shortcuts stay consistent.14+type command struct {15+ id string16+ title string // shown in the palette and help, e.g. "File Save"17+ desc string // one line, shown on the third panel line18+ run func(m *Model) tea.Cmd19+}20+21+var commands = map[string]*command{}22+23+func register(cmds ...*command) {24+ for _, c := range cmds {25+ if _, dup := commands[c.id]; dup {26+ panic("duplicate command " + c.id)27+ }28+ commands[c.id] = c29+ }30+}31+32+// keymap binds READY-mode keys to command IDs. Movement and typing are33+// handled separately.34+var keymap = map[string]string{35+ "/": "menu",36+ "<": "menu",37+ "f1": "help",38+ "f2": "edit",39+ "f5": "goto",40+ "delete": "cell.erase",41+}42+43+// keysFor returns the shortcuts bound to a command, sorted.44+func keysFor(id string) []string {45+ var keys []string46+ for k, c := range keymap {47+ if c == id {48+ keys = append(keys, k)49+ }50+ }51+ slices.Sort(keys)52+ return keys53+}54+55+// runCommand runs a registered command by ID.56+func (m *Model) runCommand(id string) tea.Cmd {57+ c, ok := commands[id]58+ if !ok {59+ panic("unknown command " + id)60+ }61+ return c.run(m)62+}63+64+func init() {65+ register(66+ &command{id: "menu", title: "Menu", desc: "Open the slash menu", run: func(m *Model) tea.Cmd {67+ m.openMenu()68+ return nil69+ }},70+ &command{id: "help", title: "Help", desc: "Show keys and functions", run: func(m *Model) tea.Cmd {71+ m.mode = modeHelp72+ return nil73+ }},74+ &command{id: "edit", title: "Edit Cell", desc: "Edit the current cell's contents", run: (*Model).startEdit},75+ &command{id: "goto", title: "Go To", desc: "Move the cell pointer to an address", run: func(m *Model) tea.Cmd {76+ m.openGoto()77+ return nil78+ }},79+ &command{id: "cell.erase", title: "Erase Cell", desc: "Erase the current cell", run: func(m *Model) tea.Cmd {80+ m.set(m.cur, "")81+ return nil82+ }},83+ &command{id: "column.width", title: "Worksheet Column Set-Width", desc: "Specify a width for the current column", run: (*Model).openWidth},84+ &command{id: "column.reset", title: "Worksheet Column Reset-Width", desc: "Return the current column to the default width", run: func(m *Model) tea.Cmd {85+ m.sheet.SetColWidth(m.cur.Col, 0)86+ m.changed = true87+ return nil88+ }},89+ &command{id: "worksheet.erase", title: "Worksheet Erase", desc: "Erase the entire worksheet from memory", run: func(m *Model) tea.Cmd {90+ m.reset(sheet.New(), "")91+ return nil92+ }},93+ &command{id: "range.erase", title: "Range Erase", desc: "Erase the cell or range", run: func(m *Model) tea.Cmd {94+ m.openRange("Enter range to erase:", func(m *Model, r sheet.Rect) tea.Cmd {95+ m.sheet.EraseRange(r)96+ m.changed = true97+ return nil98+ })99+ return nil100+ }},101+ &command{id: "file.save", title: "File Save", desc: "Store the entire worksheet in a file", run: (*Model).openSave},102+ &command{id: "file.retrieve", title: "File Retrieve", desc: "Erase the current worksheet and display the selected file", run: (*Model).openRetrieve},103+ &command{id: "quit", title: "Quit", desc: "End the session", run: func(*Model) tea.Cmd { return tea.Quit }},104+ )105+}
M internal/ui/menu.go +22 -30
@@ -15,13 +15,14 @@ import (
1515 )1616 1717 // menuItem is one entry in the slash menu. An item either opens a submenu18-// (items) or runs an action.18+// (items) or runs a registered command (cmd). Descriptions come from the19+// command unless the item overrides them.1920 type menuItem struct {2021 name string2122 desc string2223 descFn func(m *Model) string // overrides desc when set2324 items []menuItem24- action func(m *Model) tea.Cmd25+ cmd string2526 }2627 2728 type menuLevel struct {
@@ -32,43 +33,27 @@ type menuLevel struct {
3233 var rootMenu = []menuItem{3334 {name: "Worksheet", items: []menuItem{3435 {name: "Column", items: []menuItem{35- {name: "Set-Width", desc: "Specify a width for the current column", action: (*Model).openWidth},36- {name: "Reset-Width", desc: "Return the current column to the default width", action: func(m *Model) tea.Cmd {37- m.sheet.SetColWidth(m.cur.Col, 0)38- m.changed = true39- return nil40- }},36+ {name: "Set-Width", cmd: "column.width"},37+ {name: "Reset-Width", cmd: "column.reset"},4138 }},4239 {name: "Erase", items: []menuItem{43- {name: "No", desc: "Do not erase the worksheet; return to READY mode", action: noop},44- {name: "Yes", descFn: unsavedWarning("Erase the entire worksheet from memory"), action: func(m *Model) tea.Cmd {45- m.reset(sheet.New(), "")46- return nil47- }},40+ {name: "No", desc: "Do not erase the worksheet; return to READY mode"},41+ {name: "Yes", descFn: unsavedWarning("Erase the entire worksheet from memory"), cmd: "worksheet.erase"},4842 }},4943 }},5044 {name: "Range", items: []menuItem{51- {name: "Erase", desc: "Erase the cell or range", action: func(m *Model) tea.Cmd {52- m.openRange("Enter range to erase:", func(m *Model, r sheet.Rect) tea.Cmd {53- m.sheet.EraseRange(r)54- m.changed = true55- return nil56- })57- return nil58- }},45+ {name: "Erase", cmd: "range.erase"},5946 }},6047 {name: "File", items: []menuItem{61- {name: "Save", desc: "Store the entire worksheet in a file", action: (*Model).openSave},62- {name: "Retrieve", desc: "Erase the current worksheet and display the selected file", action: (*Model).openRetrieve},48+ {name: "Save", cmd: "file.save"},49+ {name: "Retrieve", cmd: "file.retrieve"},6350 }},6451 {name: "Quit", items: []menuItem{65- {name: "No", desc: "Do not end the session; return to READY mode", action: noop},66- {name: "Yes", descFn: unsavedWarning("End the session"), action: func(*Model) tea.Cmd { return tea.Quit }},52+ {name: "No", desc: "Do not end the session; return to READY mode"},53+ {name: "Yes", descFn: unsavedWarning("End the session"), cmd: "quit"},6754 }},6855 }6956 70-func noop(*Model) tea.Cmd { return nil }71-7257 func unsavedWarning(desc string) func(m *Model) string {7358 return func(m *Model) string {7459 if m.changed {
@@ -88,8 +73,12 @@ func (it menuItem) description(m *Model) string {
8873 names[i] = sub.name8974 }9075 return strings.Join(names, " ")76+ case it.desc != "":77+ return it.desc78+ case it.cmd != "":79+ return commands[it.cmd].desc9180 }92- return it.desc81+ return ""9382 }9483 9584 func (m *Model) openMenu() {
@@ -137,7 +126,10 @@ func (m *Model) choose(it menuItem) tea.Cmd {
137126 }138127 m.menu = nil139128 m.mode = modeReady140- return it.action(m)129+ if it.cmd == "" {130+ return nil131+ }132+ return m.runCommand(it.cmd)141133 }142134 143135 type promptKind int
@@ -327,7 +319,7 @@ func (m *Model) fail(msg string) {
327319 }328320 329321 func (m *Model) reset(s *sheet.Sheet, filename string) {330- *m = Model{sheet: s, filename: filename, width: m.width, height: m.height}322+ *m = Model{sheet: s, filename: filename, width: m.width, height: m.height, th: m.th}331323 }332324 333325 func isDigits(s string) bool {
M internal/ui/model.go +23 -22
@@ -66,15 +66,18 @@ type Model struct {
6666 prompt *prompt6767 files []string // file list shown by File Retrieve6868 errMsg string69+70+ th theme6971 }7072 7173 // New returns a model editing s. filename may be empty.7274 func New(s *sheet.Sheet, filename string) *Model {73- return &Model{sheet: s, filename: filename, width: 80, height: 24}75+ return &Model{sheet: s, filename: filename, width: 80, height: 24, th: newTheme(true)}7476 }7577 76-// Init implements tea.Model.77-func (m *Model) Init() tea.Cmd { return nil }78+// Init implements tea.Model. It asks the terminal for its background color79+// so the theme can adapt to light terminals.80+func (m *Model) Init() tea.Cmd { return tea.RequestBackgroundColor }7881 7982 // Update implements tea.Model.8083 func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -82,6 +85,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8285 switch msg := msg.(type) {8386 case tea.WindowSizeMsg:8487 m.width, m.height = msg.Width, msg.Height88+ case tea.BackgroundColorMsg:89+ m.th = newTheme(msg.IsDark())8590 case tea.KeyPressMsg:8691 if msg.String() == "ctrl+c" {8792 return m, tea.Quit
@@ -129,25 +134,21 @@ func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {
129134 if m.navigate(k.String(), &m.cur) {130135 return nil131136 }132- switch k.String() {133- case "/", "<":134- m.openMenu()135- case "f1":136- m.mode = modeHelp137- case "f2":138- m.startEntry(modeEdit, "")139- if c := m.sheet.Cell(m.cur); c != nil {140- m.buf = []rune(c.Input)141- m.bufPos = len(m.buf)142- }143- case "f5":144- m.openGoto()145- case "delete":146- m.set(m.cur, "")147- default:148- if text := typed(k); text != "" {149- m.startEntry(entryMode(text), text)150- }137+ if id, ok := keymap[k.String()]; ok {138+ return m.runCommand(id)139+ }140+ if text := typed(k); text != "" {141+ m.startEntry(entryMode(text), text)142+ }143+ return nil144+}145+146+// startEdit enters EDIT mode on the current cell's contents.147+func (m *Model) startEdit() tea.Cmd {148+ m.startEntry(modeEdit, "")149+ if c := m.sheet.Cell(m.cur); c != nil {150+ m.buf = []rune(c.Input)151+ m.bufPos = len(m.buf)151152 }152153 return nil153154 }