cat / 012
clone

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

main
012 / internal / ui / commands.go
commands.go 9.7K
1package ui23import (4 "cmp"5 "log/slog"6 "slices"7 "strings"89 tea "charm.land/bubbletea/v2"1011 "src.rest/cat/012/internal/sheet"12 "src.rest/cat/012/internal/ui/theme"13)1415// command is a user-facing action. Every action is registered once here16// and reached from menus, key bindings, help and the command palette, so17// titles, descriptions and shortcuts stay consistent.18type command struct {19 id string20 title string // shown in menus, the palette and help, e.g. "Save"21 desc string // one line, shown on the third panel line22 run func(m *Model) tea.Cmd2324 // enabled, when set, reports whether the command can run right now.25 // Menus and the palette show unavailable commands dimmed.26 enabled func(m *Model) bool27 // hidden, when set, reports that the command doesn't apply to this28 // session at all, as sending to a pipeline outside one: menus and29 // the palette leave it out rather than dim it.30 hidden func(m *Model) bool3132 // checked, when set, makes the command a setting: menus show a ✓33 // while it is on.34 checked func(m *Model) bool3536 // edits, when set, is the range of the sheet shown that the command37 // changes. A command that would change a pivot table's results or38 // cells an array formula spills is refused, as Sheets refuses (see39 // pivot.go).40 edits func(m *Model) sheet.Rect41 // keepsSpills is set when the command moves the cells of edits42 // (inserting or deleting rows or columns) or changes only their43 // formatting: an array spilling there keeps its values, so only44 // pivots refuse it.45 keepsSpills bool4647 // changes, when set, is the range whose cells the command changes,48 // where that's narrower than edits, and false for none: inserting49 // rows moves the cells below without changing them. Protected ranges50 // ask before a change to them (see protect.go).51 changes func(m *Model) (sheet.Rect, bool)5253 // macro says how recording and scripts treat the command; see54 // macroUse.55 macro macroUse5657 // answer, when set, runs the command with the answer to the dialog58 // it opens, as a script's run(id, answer=...) does: the dialog's59 // choices as a macro records them, a JSON object, made as if picked60 // and applied. It checks what it needs itself, in place of enabled.61 answer func(m *Model, text string) (tea.Cmd, error)6263 // typed, when set, lets a key bound to the command type itself64 // where the command isn't available, as Space checks a checkbox and65 // starts an entry anywhere else.66 typed bool67}6869// macroUse is how macros treat a command.70type macroUse int7172const (73 // macroRecord commands change the workbook: a recording calls them74 // by id, run("format.bold"), and scripts may run them. The default.75 macroRecord macroUse = iota76 // macroView commands change only what's shown or selected, or open77 // something to look through: a recording keeps the selection they78 // leave, not the command, so replaying doesn't depend on the screen.79 // Scripts may run them.80 macroView81 // macroNever commands are about the session (files, menus, help,82 // undo, macros themselves): never recorded, and scripts can't run83 // them.84 macroNever85)8687// available reports whether the command can run in m's current state.88func (c *command) available(m *Model) bool {89 if m.sheet.IsSource() {90 if on, decided := sourceEnabled(m, c.id); decided {91 return on92 }93 }94 if m.sheet.IsNotebook() && (!notebookSafe(c.id) || gridTakes(c.id)) {95 if g := m.gridIn(); g != nil {96 return g.allows(c.id) && c.available(g.child) // an output's grid entered: nbgridcmd.go97 }98 if !notebookSafe(c.id) {99 return false100 }101 }102 return c.enabled == nil || c.enabled(m)103}104105var commands = map[string]*command{}106107func register(cmds ...*command) {108 for _, c := range cmds {109 if _, dup := commands[c.id]; dup {110 panic("duplicate command " + c.id)111 }112 commands[c.id] = c113 }114}115116// keymap binds READY-mode keys to command IDs, following Google Sheets117// where it has a shortcut. Movement and typing are handled separately.118var keymap = map[string]string{119 "ctrl+/": "help",120 "enter": "edit",121 "f2": "edit",122 "f5": "goto",123 "ctrl+g": "goto",124 "delete": "clear",125 "backspace": "clear",126 "esc": "select.none",127 "ctrl+a": "select.all",128 "ctrl+space": "select.columns",129 "shift+space": "select.rows",130 "ctrl+s": "file.save",131 "ctrl+o": "file.open",132 "ctrl+q": "quit",133}134135// keysFor returns the shortcuts bound to a command, the one to show first136// leading: Ctrl combinations (what Sheets shows), then function keys, then137// Alt combinations and named keys. With vim keys on, it leaves out the138// Sheets keys vim takes and adds the vim keys (dd, u) last.139func keysFor(id string, vim bool) []string {140 var keys []string141 for k, c := range keymap {142 if c == id && !(vim && vimShadows(k)) {143 keys = append(keys, k)144 }145 }146 slices.SortFunc(keys, func(a, b string) int {147 return cmp.Or(cmp.Compare(keyRank(a), keyRank(b)), cmp.Compare(a, b))148 })149 if vim {150 keys = append(keys, vimKeysFor(id)...)151 }152 return keys153}154155func keyRank(k string) int {156 switch {157 case strings.HasPrefix(k, "ctrl+"):158 return 0159 case len(k) >= 2 && k[0] == 'f' && k[1] >= '0' && k[1] <= '9':160 return 1161 case strings.HasPrefix(k, "alt+"):162 return 2163 case k == "delete":164 return 3165 }166 return 4167}168169// keyLabel is how a key is shown: "Ctrl+S", "Del", or a vim key as typed,170// e.g. "dd" or "G".171func keyLabel(k string) string {172 if isVimKey(k) {173 return k174 }175 return theme.KeyLabel(k)176}177178// shortcut is the key shown next to a command in menus and the palette,179// e.g. "Ctrl+S", or "" if it has none. It ignores vim keys; see180// Model.shortcut.181func shortcut(id string) string {182 if keys := keysFor(id, false); len(keys) > 0 {183 return keyLabel(keys[0])184 }185 return nbShortcut(id)186}187188// shortcut is the key shown for a command with the keys in use: with vim189// keys on, the vim key when Sheets' key is taken or there is none.190func (m *Model) shortcut(id string) string {191 if keys := keysFor(id, m.prefs.vim); len(keys) > 0 {192 return keyLabel(keys[0])193 }194 return nbShortcut(id)195}196197// runCommand runs a registered command by ID. It is the one place a198// command runs, whatever reached it (a key, a menu, the palette, a click199// on a tab or a chart), so a command log or macro recorder attaches here.200func (m *Model) runCommand(id string) tea.Cmd {201 c, ok := commands[id]202 if !ok {203 panic("unknown command " + id)204 }205 // Sort, filter, find, fill and the rest are all commands, so this206 // one span times every one of them, and holds what they cause: the207 // recalculation, a pivot refresh, a sort.208 span := m.spans.Start("command", slog.String("id", id))209 defer span.End()210 if m.out != nil {211 if cmd, done := m.out.command(id); done {212 return cmd // an output's grid refuses edits: nbgridcmd.go213 }214 }215 if cmd, done := m.sourceCommand(c); done {216 return cmd // a source's tab: srccmds.go217 }218 if m.sheet.IsNotebook() && (!notebookSafe(id) || gridTakes(id)) {219 if g := m.gridIn(); g != nil {220 return g.run(id)221 }222 if !notebookSafe(id) {223 m.note = c.title + " works on a sheet's cells: this tab is a notebook"224 return nil225 }226 }227 if c.edits != nil && m.refuseEdit(c.edits(m), c.keepsSpills) || edits(c) && !m.mayEdit() {228 return nil229 }230 if m.askCommand(c) {231 return nil232 }233 if c.edits != nil && m.askUndoCost(c.edits(m), func(m *Model) tea.Cmd { return m.runCommand(c.id) }) {234 return nil235 }236 if m.rec != nil {237 return m.recordingCommand(c) // macrorec.go238 }239 return c.run(m)240}241242func init() {243 register(244 &command{id: "edit", macro: macroView, title: "Edit cell", desc: "Edit the active cell's contents", run: (*Model).startEdit, edits: cellTarget},245 &command{id: "goto", macro: macroView, title: "Go to", desc: "Move to a cell address", run: func(m *Model) tea.Cmd {246 m.openGoto()247 return nil248 }},249 &command{id: "clear", title: "Clear", desc: "Clear the contents of the selected cells", edits: (*Model).selection, run: func(m *Model) tea.Cmd {250 m.sheet.EraseRange(m.selection())251 m.changed = true252 return nil253 }},254 &command{id: "select.none", macro: macroView, title: "Deselect", desc: "Collapse the selection and clear the copy marker", run: func(m *Model) tea.Cmd {255 m.clearSelection()256 m.copied.clearMark()257 return nil258 }},259 &command{id: "select.all", macro: macroView, title: "Select all", desc: "Select the data, then the whole sheet", run: (*Model).selectAll},260 &command{id: "select.columns", macro: macroView, title: "Select columns", desc: "Select the whole columns of the selection", run: (*Model).selectColumns},261 &command{id: "select.rows", macro: macroView, title: "Select rows", desc: "Select the whole rows of the selection", run: (*Model).selectRows},262 &command{id: "column.width", title: "Column width", desc: "Set the width of the selected columns", run: (*Model).openWidth},263 &command{id: "column.reset", title: "Reset column width", desc: "Return the selected columns to the default width", run: func(m *Model) tea.Cmd {264 r := m.selection()265 m.sheet.Batch(sheet.Change{Label: "reset column widths", Focus: r}, func() error {266 for c := range m.sheet.Widths() { // only the columns that have a width267 if c >= r.From.Col && c <= r.To.Col {268 m.sheet.SetColWidth(c, 0)269 }270 }271 return nil272 })273 m.changed = true274 return nil275 }},276 &command{id: "file.new", macro: macroNever, title: "New", desc: "Start a new, empty sheet", run: func(m *Model) tea.Cmd {277 m.reset(sheet.New(), "")278 return nil279 }},280 &command{id: "file.save", macro: macroNever, title: "Save", desc: "Save the sheet", run: (*Model).save},281 &command{id: "file.saveas", macro: macroNever, title: "Save as", desc: "Save the sheet under a new name", run: (*Model).openSave},282 &command{id: "file.open", macro: macroNever, title: "Open", desc: "Open a sheet, replacing this one", run: (*Model).openRetrieve},283 &command{id: "quit", macro: macroNever, title: "Quit", desc: "Close 012", run: (*Model).quit},284 )285}