cat / 012
clone

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

Add optional vim keys with a : command line

Dami committed 67089f7
File > Settings > Vim keys turns READY mode into NORMAL: hjkl, w/b, gg/G,
0/$, H/M/L, Ctrl+D/U and counts move; i, a and = start entries; o/O,
dd, yy, p/P, x, u and Ctrl+R act through registered commands; v and V
select cells or rows (VISUAL); / with n and N finds; : opens a command
line (COMMAND) for cells, rows, :w, :q, :wq, :x, :e and any command by
ID or title, completed from the command registry. Sheets keys vim
doesn't take keep working, and help, the palette and menus show the
keys in use.

The setting lives on the model (prefs.go) with two seams,
initialPrefs and prefsChanged, for the user configuration to fill in.
commit
67089f72a65bfcb7c31a992ac5282fe0a70a32bf
parent
344feb4ccacc405c304e8bf589cf4e2e70a78eef
author
Dami <dami.etoile@gmail.com>
19 files changed, +1587 -49
Minternal/ui/charts.go2+-
Ainternal/ui/cmdline.go371+++++++++++++++++++++++++++++
Minternal/ui/commands.go35++-
Minternal/ui/file.go2+-
Minternal/ui/filter.go2+-
Minternal/ui/find.go44+++
Minternal/ui/help.go18+-
Minternal/ui/help_test.go6+-
Minternal/ui/menubar.go2+-
Minternal/ui/menuoverlay.go2+-
Minternal/ui/model.go13+-
Minternal/ui/palette.go2+-
Minternal/ui/panel.go11+-
Ainternal/ui/prefs.go48++++
Ainternal/ui/rows.go103++++++++
Minternal/ui/selection.go59+++--
Ainternal/ui/vim.go287++++++++++++++++++++++
Ainternal/ui/vim_test.go390++++++++++++++++++++++++++++++
Ainternal/ui/vimkeys.go239++++++++++++++++++
M internal/ui/charts.go +1 -1
A internal/ui/cmdline.go +371 -0
@@ -0,0 +1,371 @@
1+package ui2+3+import (4+ "cmp"5+ "path/filepath"6+ "slices"7+ "strconv"8+ "strings"9+10+ tea "charm.land/bubbletea/v2"11+ "github.com/charmbracelet/x/ansi"12+13+ "github.com/FelineStateMachine/012/internal/fileio"14+ "github.com/FelineStateMachine/012/internal/sheet"15+ "github.com/FelineStateMachine/012/internal/ui/theme"16+)17+18+// The command line (: with vim keys, or from the palette) takes vim's19+// file commands (:w, :q, :wq, :x, :e file), a cell, range, named range or20+// row number to go to (:B12, :Sheet2!A1, :40), or any registered command21+// by its ID or title (:edit.fill_down, :fill down). Completions come from22+// the command registry as you type, in a box under the context line, so23+// there's no second list of commands to keep up.24+25+type cmdLine struct {26+ list27+ shown []cmdItem28+ tabbed bool // the text is a completion Tab put there; Tab again moves on29+}30+31+// cmdItem is a completion: a file command or a registered command.32+type cmdItem struct {33+ word string // what Tab puts on the line: "w", or a command's ID34+ title string35+ desc string36+ key string // the command's shortcut37+ off bool // unavailable right now38+}39+40+// fileWords are vim's file commands.41+var fileWords = []cmdItem{42+ {word: "w", title: "Write", desc: "Save the sheet; :w name saves it as name, :w name.csv downloads it"},43+ {word: "q", title: "Quit", desc: "Close 012, asking about unsaved changes"},44+ {word: "q!", title: "Quit without saving", desc: "Close 012, discarding unsaved changes"},45+ {word: "wq", title: "Write and quit", desc: "Save the sheet, then close 012"},46+ {word: "x", title: "Write if changed and quit", desc: "Save the sheet if it changed, then close 012"},47+ {word: "e", title: "Edit a file", desc: "Open a sheet or import a file: :e name"},48+}49+50+const cmdLineID = "cmdline"51+52+func init() {53+ register(&command{id: "vim.command", title: "Command line",54+ desc: "Type a command: a cell to go to (B12), w, q, wq, e file, or any command by name",55+ run: func(m *Model) tea.Cmd {56+ c := &cmdLine{}57+ m.line.clear()58+ m.openOverlay(c)59+ c.changed(m)60+ return nil61+ }})62+}63+64+func (c *cmdLine) indicator() string { return "COMMAND" }65+66+// changed completes what's typed: file commands, then commands whose ID67+// or title starts with it, then those that contain it. Nothing is68+// completed once a file command's argument follows.69+func (c *cmdLine) changed(m *Model) {70+ c.sel, c.top, c.tabbed = 0, 0, false71+ c.shown = c.shown[:0]72+ q := strings.ToLower(strings.TrimPrefix(strings.TrimLeft(m.line.text(), " "), ":"))73+ if word, _, arg := strings.Cut(q, " "); arg && isFileWord(word) {74+ return75+ }76+ for _, w := range fileWords {77+ if strings.HasPrefix(w.word, q) {78+ c.shown = append(c.shown, w)79+ }80+ }81+ var starts, contains []cmdItem82+ for _, id := range commandIDs() {83+ cm := commands[id]84+ title := strings.ToLower(cm.title)85+ it := cmdItem{word: id, title: cm.title, desc: cm.desc, key: m.shortcut(id), off: !cm.available(m)}86+ switch {87+ case id == "vim.command":88+ case strings.HasPrefix(id, q) || strings.HasPrefix(title, q):89+ starts = append(starts, it)90+ case strings.Contains(id, q) || strings.Contains(title, q):91+ contains = append(contains, it)92+ }93+ }94+ c.shown = append(append(c.shown, starts...), contains...)95+}96+97+// isFileWord reports whether word is one of vim's file commands, which98+// take an argument.99+func isFileWord(word string) bool {100+ return slices.ContainsFunc(fileWords, func(it cmdItem) bool { return it.word == strings.TrimSuffix(word, "!") })101+}102+103+// commandIDs are the registered commands' IDs, by title.104+func commandIDs() []string {105+ ids := make([]string, 0, len(commands))106+ for id := range commands {107+ ids = append(ids, id)108+ }109+ slices.SortFunc(ids, func(a, b string) int {110+ return cmp.Or(cmp.Compare(commands[a].title, commands[b].title), cmp.Compare(a, b))111+ })112+ return ids113+}114+115+func (c *cmdLine) key(m *Model, k tea.KeyPressMsg) tea.Cmd {116+ switch key := k.String(); {117+ case key == "esc", key == "backspace" && len(m.line.buf) == 0:118+ m.closeOverlay()119+ case key == "enter":120+ return c.run(m)121+ case key == "tab":122+ c.complete(m, 1)123+ case key == "shift+tab":124+ c.complete(m, -1)125+ case key == "up", key == "ctrl+p":126+ c.move(-1, len(c.shown))127+ case key == "down", key == "ctrl+n":128+ c.move(1, len(c.shown))129+ default:130+ before := m.line.text()131+ m.line.key(k)132+ if m.line.text() != before {133+ c.changed(m)134+ }135+ }136+ return nil137+}138+139+// complete puts the highlighted completion on the line; pressed again,140+// it moves on to the next one (d = 1) or the previous one (d = -1).141+func (c *cmdLine) complete(m *Model, d int) {142+ if len(c.shown) == 0 {143+ return144+ }145+ if c.tabbed {146+ c.move(d, len(c.shown))147+ }148+ m.line.set(c.shown[c.sel].word)149+ c.tabbed = true150+}151+152+// run closes the line and carries it out. Text that isn't a command,153+// cell or range runs the highlighted completion, so ":fill d" Enter154+// fills down.155+func (c *cmdLine) run(m *Model) tea.Cmd {156+ text := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(m.line.text()), ":"))157+ fallback := ""158+ if c.sel < len(c.shown) {159+ fallback = c.shown[c.sel].word160+ }161+ m.closeOverlay()162+ if text == "" {163+ return nil164+ }165+ if cmd, ok := m.runCmdLine(text); ok {166+ return cmd167+ }168+ if fallback != "" {169+ if cmd, ok := m.runCmdLine(fallback); ok {170+ return cmd171+ }172+ }173+ m.fail("Not a command, cell or range: " + text)174+ return nil175+}176+177+// runCmdLine carries out a command line and reports whether it was one.178+func (m *Model) runCmdLine(text string) (tea.Cmd, bool) {179+ word, arg, _ := strings.Cut(text, " ")180+ arg = strings.TrimSpace(arg)181+ bang := strings.HasSuffix(word, "!")182+ switch strings.TrimSuffix(word, "!") {183+ case "w":184+ return m.writeTo(arg, bang), true185+ case "q":186+ if bang {187+ return m.exit(), true188+ }189+ return m.runCommand("quit"), true190+ case "wq", "x":191+ if word == "x" && !m.changed && arg == "" {192+ return m.exit(), true193+ }194+ m.quitAfterSave = true195+ return m.writeTo(arg, bang), true196+ case "e":197+ return m.editFile(arg, bang), true198+ }199+ if n, err := strconv.Atoi(text); err == nil {200+ m.clearSelection()201+ m.cur.Row = m.visibleRow(clamp(n-1, 0, sheet.MaxRows-1))202+ return nil, true203+ }204+ if m.gotoText(text) {205+ return nil, true206+ }207+ id, ok := commandNamed(text)208+ if !ok {209+ return nil, false210+ }211+ if c := commands[id]; !c.available(m) {212+ m.note = c.title + " isn't available now"213+ return nil, true214+ }215+ return m.runCommand(id), true216+}217+218+// commandNamed finds a command by its ID or title, in any case.219+func commandNamed(text string) (string, bool) {220+ ids := commandIDs()221+ for _, id := range ids {222+ if strings.EqualFold(id, text) {223+ return id, true224+ }225+ }226+ for _, id := range ids {227+ if strings.EqualFold(commands[id].title, text) {228+ return id, true229+ }230+ }231+ return "", false232+}233+234+// writeTo is :w. Without a name it saves; with one it saves under that235+// name, or downloads when the name is another format's (:w out.csv).236+// An existing file is replaced only after asking, or with :w!.237+func (m *Model) writeTo(name string, force bool) tea.Cmd {238+ if name == "" {239+ return m.runCommand("file.save")240+ }241+ if k, ok := fileio.KindOf(name); ok {242+ if !k.CanExport() {243+ m.quitAfterSave = false244+ m.fail("012 can't write " + k.String() + " files")245+ return nil246+ }247+ if k.HasTables() {248+ m.openTableName(name, k, sheet.Rect{})249+ return nil250+ }251+ return m.confirmReplace(filepath.Base(name)+" exists.", func(m *Model) tea.Cmd {252+ return m.download(name, k, sheet.Rect{}, "")253+ }, exists(name) && !force)254+ }255+ name = withExt(name)256+ return m.confirmReplace(filepath.Base(name)+" exists.", func(m *Model) tea.Cmd {257+ return saveCmd(m.sheet, name)258+ }, exists(name) && !force && name != m.filename)259+}260+261+// editFile is :e name: open a sheet or import a file, refusing to drop262+// unsaved changes unless forced with :e!.263+func (m *Model) editFile(name string, force bool) tea.Cmd {264+ switch {265+ case name == "":266+ return m.runCommand("file.open")267+ case m.changed && !force:268+ m.fail("Unsaved changes: :w saves them, :e! " + name + " discards them")269+ return nil270+ }271+ if _, ok := fileio.KindOf(name); ok {272+ return m.confirmImport(name, fileio.Options{})273+ }274+ return loadCmd(withExt(name))275+}276+277+func (c *cmdLine) contextLine(m *Model) (string, string) {278+ return m.th.Title.Render(":") + m.line.text(), ""279+}280+281+func (c *cmdLine) cursor(m *Model) (int, int) {282+ return 1 + ansi.StringWidth(m.line.head()), contextLine283+}284+285+func (c *cmdLine) status(m *Model) (string, string) {286+ keys := m.th.KeyHints("Tab", "complete", "Enter", "run", "Esc", "cancel")287+ if c.sel >= len(c.shown) {288+ return "", keys289+ }290+ it := c.shown[c.sel]291+ if it.off {292+ return it.desc + " (not available now)", keys293+ }294+ return it.desc, keys295+}296+297+// rows is how many completions show.298+func (c *cmdLine) rows(m *Model) int {299+ return max(min(len(c.shown), 8, m.height-contextLine-4), 0)300+}301+302+// layout draws the completions in a box under the context line: title,303+// what Tab types, and the shortcut.304+func (c *cmdLine) layout(m *Model) []box {305+ rows := c.rows(m)306+ if rows == 0 {307+ return nil308+ }309+ c.show(rows)310+ inner := min(m.width-2, 72)311+ tw, ww, kw := 0, 0, 0312+ for _, it := range c.shown {313+ tw = max(tw, ansi.StringWidth(it.title))314+ ww = max(ww, ansi.StringWidth(it.word))315+ if it.key != "" {316+ kw = max(kw, ansi.StringWidth(it.key)+2)317+ }318+ }319+ tw = min(tw, inner/2)320+ ww = max(min(ww, inner-1-tw-2-kw-2), 0)321+ lines := make([]string, rows)322+ for r := range rows {323+ i := c.top + r324+ it := c.shown[i]325+ base, dim := m.th.MenuBar, m.th.Muted326+ switch {327+ case i == c.sel:328+ base, dim = m.th.MenuSelected, m.th.MenuSelected329+ case it.off:330+ base, dim = m.th.Disabled, m.th.Disabled331+ }332+ row := base.Render(" "+theme.PadRight(ansi.Truncate(it.title, tw, "…"), tw)+" ") +333+ dim.Render(theme.PadRight(ansi.Truncate(it.word, ww, "…"), ww))334+ k := ""335+ switch {336+ case it.key == "":337+ case i == c.sel || it.off:338+ k = base.Render(" " + it.key + " ")339+ default:340+ k = m.th.Chip(it.key)341+ }342+ row += base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row)-ansi.StringWidth(k)-1, 0))) + k + base.Render(" ")343+ lines[r] = ansi.Truncate(row, inner, "")344+ }345+ footer := strconv.Itoa(c.sel+1) + " of " + strconv.Itoa(len(c.shown))346+ return []box{{id: cmdLineID, x: 0, y: contextLine + 1, lines: m.th.Frame(inner, "Commands", footer, lines)}}347+}348+349+func (c *cmdLine) mouse(m *Model, e mouseEvent) tea.Cmd {350+ if e.box != cmdLineID {351+ if e.kind == mousePress {352+ m.closeOverlay()353+ }354+ return nil355+ }356+ i := c.top + e.row - 1 // under the top border357+ switch {358+ case e.kind == mouseWheel && e.button == tea.MouseWheelUp:359+ c.move(-1, len(c.shown))360+ case e.kind == mouseWheel && e.button == tea.MouseWheelDown:361+ c.move(1, len(c.shown))362+ case e.row < 1 || i >= len(c.shown) || i >= c.top+c.rows(m):363+ case e.kind == mouseMotion:364+ c.sel = i365+ case e.kind == mousePress && e.button == tea.MouseLeft:366+ m.line.set(c.shown[i].word)367+ c.sel = i368+ return c.run(m)369+ }370+ return nil371+}
M internal/ui/commands.go +29 -6
@@ -69,17 +69,21 @@ var keymap = map[string]string{
6969 7070 // keysFor returns the shortcuts bound to a command, the one to show first7171 // leading: Ctrl combinations (what Sheets shows), then function keys, then72-// Alt combinations and named keys.73-func keysFor(id string) []string {72+// Alt combinations and named keys. With vim keys on, it leaves out the73+// Sheets keys vim takes and adds the vim keys (dd, u) last.74+func keysFor(id string, vim bool) []string {7475 var keys []string7576 for k, c := range keymap {76- if c == id {77+ if c == id && !(vim && vimShadows(k)) {7778 keys = append(keys, k)7879 }7980 }8081 slices.SortFunc(keys, func(a, b string) int {8182 return cmp.Or(cmp.Compare(keyRank(a), keyRank(b)), cmp.Compare(a, b))8283 })84+ if vim {85+ keys = append(keys, vimKeysFor(id)...)86+ }8387 return keys8488 }8589
@@ -97,11 +101,30 @@ func keyRank(k string) int {
97101 return 498102 }99103 104+// keyLabel is how a key is shown: "Ctrl+S", "Del", or a vim key as typed,105+// e.g. "dd" or "G".106+func keyLabel(k string) string {107+ if isVimKey(k) {108+ return k109+ }110+ return theme.KeyLabel(k)111+}112+100113 // shortcut is the key shown next to a command in menus and the palette,101-// e.g. "Ctrl+S", or "" if it has none.114+// e.g. "Ctrl+S", or "" if it has none. It ignores vim keys; see115+// Model.shortcut.102116 func shortcut(id string) string {103- if keys := keysFor(id); len(keys) > 0 {104- return theme.KeyLabel(keys[0])117+ if keys := keysFor(id, false); len(keys) > 0 {118+ return keyLabel(keys[0])119+ }120+ return ""121+}122+123+// shortcut is the key shown for a command with the keys in use: with vim124+// keys on, the vim key when Sheets' key is taken or there is none.125+func (m *Model) shortcut(id string) string {126+ if keys := keysFor(id, m.prefs.vim); len(keys) > 0 {127+ return keyLabel(keys[0])105128 }106129 return ""107130 }
M internal/ui/file.go +1 -1
M internal/ui/filter.go +1 -1
M internal/ui/find.go +44 -0
@@ -63,10 +63,48 @@ func init() {
6363 return nil6464 }},6565 )66+ register(67+ &command{id: "edit.find_next", title: "Find next", desc: "Go to the next match of the last search", run: func(m *Model) tea.Cmd {68+ m.findAgain(1)69+ return nil70+ }},71+ &command{id: "edit.find_prev", title: "Find previous", desc: "Go to the previous match of the last search", run: func(m *Model) tea.Cmd {72+ m.findAgain(-1)73+ return nil74+ }},75+ )6676 keymap["ctrl+f"] = "edit.find"6777 keymap["ctrl+h"] = "edit.replace"6878 }6979 80+// findAgain goes to the next (d = 1) or previous (d = -1) match of the81+// last search from the active cell, without opening the bar, as n and N82+// do in vim and less.83+func (m *Model) findAgain(d int) {84+ f := m.find85+ if f == nil || f.fields[0] == "" {86+ m.note = "No search yet: " + m.shortcut("edit.find") + " finds"87+ return88+ }89+ if f.where == inRange && !f.home.Live() {90+ f.where = inSheet91+ }92+ here := cellOn{m.sheet, m.cur}93+ f.from = here94+ f.search(m) // lands on the first match at or after here95+ switch {96+ case f.err != "":97+ m.note = f.err98+ return99+ case len(f.matches) == 0:100+ m.note = "No matches for " + f.fields[0]101+ return102+ case d > 0 && f.matches[f.cur] == here, d < 0:103+ f.step(m, d)104+ }105+ m.note = "Match " + strconv.Itoa(f.cur+1) + " of " + strconv.Itoa(len(f.matches)) + " for " + f.fields[0]106+}107+70108 // openFind opens the bar, keeping the last search. A multi-cell selection71109 // becomes the search scope, as Sheets' "specific range".72110 func (m *Model) openFind(replace bool) {
@@ -214,6 +252,12 @@ func (f *findBar) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
214252 m.find = f215253 m.closeOverlay()216254 case "enter":255+ if m.prefs.vim && !f.replace {256+ // As / in vim: Enter stays on the match; n and N go on.257+ m.find = f258+ m.closeOverlay()259+ return nil260+ }217261 if f.replace && f.field == 1 {218262 f.replaceOne(m)219263 } else {
M internal/ui/help.go +13 -5
@@ -78,20 +78,28 @@ type helpRow struct {
7878 // helpRows lists the shortcuts, generated from the keymap and the command7979 // registry so help can't drift from behavior. Movement and typing, which8080 // aren't commands, come first; then commands grouped by the menu they're81-// in.82-func helpRows() []helpRow {81+// in. With vim keys on, the vim keys come first.82+func helpRows(vim bool) []helpRow {83+ if vim {84+ return append(vimHelpRows(), keyHelpRows(true)...)85+ }86+ return keyHelpRows(false)87+}88+89+// keyHelpRows are the Sheets keys and every command's shortcuts.90+func keyHelpRows(vim bool) []helpRow {8391 listed := map[string]bool{}8492 var rows []helpRow8593 group := func(title string, static []helpRow, ids ...string) {8694 var cmds []helpRow8795 for _, id := range ids {88- keys := keysFor(id)96+ keys := keysFor(id, vim)8997 if listed[id] || len(keys) == 0 || commands[id] == nil {9098 continue9199 }92100 listed[id] = true93101 for i, k := range keys {94- keys[i] = theme.KeyLabel(k)102+ keys[i] = keyLabel(k)95103 }96104 cmds = append(cmds, helpRow{keys: keys, action: commands[id].title})97105 }
@@ -169,7 +177,7 @@ func (s *shortcuts) indicator() string { return "HELP" }
169177 170178 // lines renders the rows, as one or two columns, and returns their width.171179 func (s *shortcuts) lines(m *Model) ([]string, int) {172- rows := helpRows()180+ rows := helpRows(m.prefs.vim)173181 keyW, actW := 0, 0174182 for _, r := range rows {175183 keyW = max(keyW, ansi.StringWidth(m.th.Chips(r.keys)))
M internal/ui/help_test.go +3 -3
M internal/ui/menubar.go +1 -1
M internal/ui/menuoverlay.go +1 -1
M internal/ui/model.go +11 -2
@@ -83,6 +83,9 @@ type Model struct {
8383 jev *jevRunner // answers JEV functions; nil without an API key: jev.go8484 term terminal // what the terminal supports: graphics.go8585 86+ prefs prefs // the user's preferences, kept across files: prefs.go87+ vim vimState // a vim key sequence in progress: vim.go88+8689 keyAt time.Time // when the key the next frame answers was pressed, for telemetry8790 8891 th theme.Theme
@@ -90,7 +93,8 @@ type Model struct {
9093 9194 // New returns a model editing s. filename may be empty.9295 func New(s *sheet.Sheet, filename string) *Model {93- return &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(), charts: chartState{last: -1}}96+ return &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(), charts: chartState{last: -1},97+ prefs: initialPrefs()}9498 }9599 96100 // Init implements tea.Model. It asks the terminal for its background color
@@ -230,6 +234,11 @@ func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {
230234 }231235 232236 func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {237+ if m.prefs.vim {238+ if cmd, ok := m.vimKeyPress(k); ok {239+ return cmd240+ }241+ }233242 key := k.String()234243 if m.moveKey(key) {235244 m.entry.tabbing = false
@@ -280,7 +289,7 @@ func (m *Model) focus() *sheet.Addr {
280289 return &m.point.at281290 case m.mouse.drag == dragFill:282291 return &m.mouse.fillAt283- case m.selecting && m.whole == wholeNone:292+ case m.selecting && (m.whole == wholeNone || m.vim.visual == visualRows):284293 return &m.ext285294 }286295 return &m.cur
M internal/ui/palette.go +1 -1
M internal/ui/panel.go +10 -1
A internal/ui/prefs.go +48 -0
@@ -0,0 +1,48 @@
1+package ui2+3+import tea "charm.land/bubbletea/v2"4+5+// Preferences belong to the user, not the sheet: they aren't saved in6+// .012 files or undone, and File > New and Open keep them. Each one is a7+// checked command in File > Settings.8+9+// prefs are the user's preferences.10+type prefs struct {11+ vim bool // vim keys in READY mode: see vim.go12+}13+14+// initialPrefs is what a new Model starts with. It is the seam where15+// preferences are read from the user's configuration (for vim keys, the16+// option keymap = default|vim); until that's wired, the defaults.17+func initialPrefs() prefs {18+ return prefs{}19+}20+21+// prefsChanged is called after the user changes a preference, with the22+// new preferences in m.prefs. It is the seam where they are written back23+// to the user's configuration; nothing persists them yet.24+func (m *Model) prefsChanged() tea.Cmd {25+ return nil26+}27+28+// SetVimKeys turns vim keys on or off, as the setting does, for callers29+// that know the user's choice at startup.30+func (m *Model) SetVimKeys(on bool) {31+ m.prefs.vim = on32+ m.vim = vimState{}33+}34+35+func init() {36+ register(&command{id: "settings.vim", title: "Vim keys",37+ desc: "Move with hjkl, counts and operators (dd, yy, p), visual selection with v and V, and : commands",38+ run: func(m *Model) tea.Cmd {39+ m.SetVimKeys(!m.prefs.vim)40+ m.note = "Vim keys off: typing replaces the cell, as in Sheets"41+ if m.prefs.vim {42+ m.note = "Vim keys on: i or Enter edits, : runs a command, F1 lists the keys"43+ }44+ return m.prefsChanged()45+ },46+ checked: func(m *Model) bool { return m.prefs.vim },47+ })48+}
A internal/ui/rows.go +103 -0
@@ -0,0 +1,103 @@
1+package ui2+3+import (4+ tea "charm.land/bubbletea/v2"5+6+ "github.com/FelineStateMachine/012/internal/sheet"7+)8+9+// Whole-row commands, as vim's line operators: copy rows (yy), cut them10+// (dd), paste them back as new rows (p, P), and open a new row to type in11+// (o, O). They work without vim keys too, from the palette.12+13+func init() {14+ register(15+ &command{id: "row.yank", title: "Copy rows", desc: "Copy the selected rows whole, to paste as new rows", run: func(m *Model) tea.Cmd {16+ r := m.selectedRows()17+ cmd := m.copyRows(r)18+ m.note = "Copied " + rowCount(r.To.Row-r.From.Row+1)19+ return cmd20+ }},21+ &command{id: "row.cut", title: "Cut rows", desc: "Delete the selected rows, keeping them to paste as new rows", run: func(m *Model) tea.Cmd {22+ r := m.selectedRows()23+ cmd := m.copyRows(r)24+ n := r.To.Row - r.From.Row + 125+ m.sheet.DeleteRows(r.From.Row, n)26+ m.note = "Deleted " + rowCount(n) + "; p pastes them back"27+ return cmd28+ }},29+ &command{id: "row.paste_below", title: "Paste rows below", desc: "Insert the copied rows below the active cell's row",30+ run: func(m *Model) tea.Cmd { return m.pasteRows(m.cur.Row + 1) }, enabled: (*Model).rowsCopied},31+ &command{id: "row.paste_above", title: "Paste rows above", desc: "Insert the copied rows above the active cell's row",32+ run: func(m *Model) tea.Cmd { return m.pasteRows(m.cur.Row) }, enabled: (*Model).rowsCopied},33+ &command{id: "row.open_below", title: "Open a row below", desc: "Insert a row below the active cell and start typing in it",34+ run: func(m *Model) tea.Cmd { return m.openRow(m.cur.Row + 1) }},35+ &command{id: "row.open_above", title: "Open a row above", desc: "Insert a row above the active cell and start typing in it",36+ run: func(m *Model) tea.Cmd { return m.openRow(m.cur.Row) }},37+ )38+}39+40+// selectedRows is the rows of the selection across the columns in use.41+func (m *Model) selectedRows() sheet.Rect {42+ r := m.selection()43+ last := 044+ if used, ok := m.sheet.UsedRange(); ok {45+ last = used.To.Col46+ }47+ return sheet.Rect{From: sheet.Addr{Row: r.From.Row}, To: sheet.Addr{Col: last, Row: r.To.Row}}48+}49+50+// copyRows puts rows r on the clipboard, marked as whole rows so pasting51+// inserts them, and on the system clipboard.52+func (m *Model) copyRows(r sheet.Rect) tea.Cmd {53+ clip := m.sheet.Copy(r)54+ m.copied = clipboard{clip: clip, sheet: m.sheet}55+ m.vim.rows = clip56+ return tea.SetClipboard(formatTSV(clip.Text()))57+}58+59+// rowsCopied reports whether the clipboard holds whole rows.60+func (m *Model) rowsCopied() bool {61+ return m.vim.rows != nil && m.copied.clip == m.vim.rows62+}63+64+// pasteRows inserts the copied rows at row at, as many times as the vim65+// count says, as one undo step, and moves to the first of them.66+func (m *Model) pasteRows(at int) tea.Cmd {67+ clip := m.copied.clip68+ cols, rows := clip.Size()69+ n := rows * m.vim.n()70+ dst := sheet.Rect{From: sheet.Addr{Row: at}, To: sheet.Addr{Col: cols - 1, Row: at + n - 1}}71+ if !dst.To.Valid() {72+ return m.structural(sheet.ErrPushedOff)73+ }74+ err := m.sheet.Batch(sheet.Change{Label: "paste " + rowCount(n), Focus: dst, Sheet: m.sheet}, func() error {75+ if err := m.sheet.InsertRows(at, n); err != nil {76+ return err77+ }78+ _, err := m.sheet.Paste(clip, dst, false)79+ return err80+ })81+ if err != nil {82+ return m.structural(err)83+ }84+ m.clearSelection()85+ m.cur.Row = at86+ m.note = "Pasted " + rowCount(n)87+ return nil88+}89+90+// openRow inserts a blank row at row at and starts an entry in it, in the91+// active cell's column.92+func (m *Model) openRow(at int) tea.Cmd {93+ if at >= sheet.MaxRows {94+ return m.structural(sheet.ErrPushedOff)95+ }96+ if err := m.sheet.InsertRows(at, 1); err != nil {97+ return m.structural(err)98+ }99+ m.clearSelection()100+ m.cur.Row = at101+ m.startEntry(modeEnter, "")102+ return nil103+}
M internal/ui/selection.go +33 -26
@@ -212,35 +212,42 @@ func isMoveKey(key string) bool {
212212 213213 func (m *Model) openGoto() {214214 m.openText("Go to:", m.cur.String(), func(m *Model, text string) tea.Cmd {215- text = strings.TrimSpace(text)216- // A sheet may lead: Sheet2!A1, 'Q3 plan'!B2:C9, or just Sheet2!.217- target := m.sheet218- if name, rest := sheet.SplitSheet(text); name != "" {219- if target = m.book().Lookup(name); target == nil {220- m.fail("There's no sheet named " + name)221- return nil222- }223- if text = rest; text == "" {224- m.showSheet(target)225- return nil226- }227- }228- r, ok := sheet.ParseRange(text)229- if n, named := m.sheet.LookupName(text); named && !n.Gone() && target == m.sheet {230- r, ok, target = n.Range, true, n.Sheet231- }232- if !ok {215+ if !m.gotoText(strings.TrimSpace(text)) {233216 m.fail("Not a cell, range or named range: " + text)234- return nil235217 }236- m.showSheet(target)237- if r.From == r.To {238- m.clearSelection()239- m.cur = r.From240- return nil241- }242- m.selectRect(r)243218 return nil244219 })245220 m.prompt.indicator = "POINT"246221 }222+223+// gotoText goes to a cell, range or named range, on any sheet, and224+// reports whether text named one. A sheet may lead: Sheet2!A1,225+// 'Q3 plan'!B2:C9, or just Sheet2!.226+func (m *Model) gotoText(text string) bool {227+ target := m.sheet228+ if name, rest := sheet.SplitSheet(text); name != "" {229+ if target = m.book().Lookup(name); target == nil {230+ m.fail("There's no sheet named " + name)231+ return true232+ }233+ if text = rest; text == "" {234+ m.showSheet(target)235+ return true236+ }237+ }238+ r, ok := sheet.ParseRange(text)239+ if n, named := m.sheet.LookupName(text); named && !n.Gone() && target == m.sheet {240+ r, ok, target = n.Range, true, n.Sheet241+ }242+ if !ok {243+ return false244+ }245+ m.showSheet(target)246+ if r.From == r.To {247+ m.clearSelection()248+ m.cur = r.From249+ return true250+ }251+ m.selectRect(r)252+ return true253+}
A internal/ui/vim.go +287 -0
@@ -0,0 +1,287 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// Vim keys (File > Settings > Vim keys), in the spirit of sc-im and13+// VisiData: in READY mode, which the indicator calls NORMAL, letters move14+// and act instead of starting an entry. Counts repeat a motion or say how15+// many rows or cells an operator takes (5j, 3dd, 4x); v and V select16+// cells or whole rows (VISUAL); : opens a command line (COMMAND). Keys17+// vim doesn't claim keep their Sheets meaning, so arrows, Ctrl+S, Alt18+// menus and F-keys work as always. Every action but moving and selecting19+// runs a registered command; the bindings are tables in vimkeys.go.20+21+// vimState is where a vim key sequence has got to.22+type vimState struct {23+ count int // the count typed so far, 0 for none24+ keys string // a sequence begun but not finished, e.g. "d" or "g"25+ visual visualKind26+ // rows is the clip dd or yy last copied: pasting it inserts rows.27+ rows *sheet.Clip28+}29+30+type visualKind int31+32+const (33+ visualNone visualKind = iota34+ visualCells35+ visualRows36+)37+38+// maxCount caps a count, so a slip of the finger can't loop for long.39+const maxCount = 999940+41+// n is the count, 1 when none was typed.42+func (v *vimState) n() int { return max(v.count, 1) }43+44+func (v *vimState) reset() { v.count, v.keys = 0, "" }45+46+// pending is what's been typed of an unfinished sequence, e.g. "3d".47+func (v *vimState) pending() string {48+ if v.count == 0 {49+ return v.keys50+ }51+ return strconv.Itoa(v.count) + v.keys52+}53+54+// vimKey names a key press as the vim tables do: the character typed,55+// or the key's name with its modifiers, e.g. "G", "$", "ctrl+d", "up".56+func vimKey(k tea.KeyPressMsg) string {57+ if t := typed(k); t != "" && t != " " {58+ return t59+ }60+ return k.String()61+}62+63+// vimActive reports whether vim keys have READY mode.64+func (m *Model) vimActive() bool {65+ return m.prefs.vim && m.mode == modeReady66+}67+68+// visual reports whether a visual selection is in progress; a selection69+// made any other way (a click, Shift+arrows, a command) ends it.70+func (m *Model) visual() visualKind {71+ if !m.selecting {72+ m.vim.visual = visualNone73+ }74+ return m.vim.visual75+}76+77+// vimKeyPress handles a key in NORMAL or VISUAL mode and reports whether78+// it took it. Keys it leaves go to the Sheets bindings.79+func (m *Model) vimKeyPress(k tea.KeyPressMsg) (tea.Cmd, bool) {80+ v := &m.vim81+ key := vimKey(k)82+ if key == "esc" {83+ return m.vimEscape()84+ }85+ if d := digit(key); v.keys == "" && (d > 0 || d == 0 && v.count > 0) {86+ v.count = min(v.count*10+d, maxCount)87+ return nil, true88+ }89+ seq := v.keys + key90+ if mv, ok := vimMotions[seq]; ok {91+ m.vimMove(mv)92+ v.reset()93+ return nil, true94+ }95+ table := vimNormal96+ if m.visual() != visualNone {97+ table = vimVisual98+ }99+ if b, ok := table[seq]; ok {100+ cmd := b.run(m, v.n())101+ v.reset()102+ return cmd, true103+ }104+ if vimPrefix(table, seq) {105+ v.keys = seq106+ return nil, true107+ }108+ begun := v.pending() != ""109+ v.reset()110+ // A broken sequence and stray letters do nothing, as in vim; other111+ // keys (arrows, Ctrl+S, F1) fall through to their Sheets meaning.112+ return nil, begun || typed(k) != ""113+}114+115+// vimEscape cancels a sequence being typed, then a visual selection, then116+// deselects, as Esc does in Sheets.117+func (m *Model) vimEscape() (tea.Cmd, bool) {118+ switch {119+ case m.vim.pending() != "":120+ m.vim.reset()121+ case m.visual() != visualNone:122+ m.vim.visual = visualNone123+ m.clearSelection()124+ default:125+ return m.runCommand("select.none"), true126+ }127+ return nil, true128+}129+130+func digit(key string) int {131+ if len(key) == 1 && key[0] >= '0' && key[0] <= '9' {132+ return int(key[0] - '0')133+ }134+ return -1135+}136+137+// vimPrefix reports whether seq begins a longer binding or motion.138+func vimPrefix(table map[string]vimBinding, seq string) bool {139+ for k := range table {140+ if len(k) > len(seq) && strings.HasPrefix(k, seq) {141+ return true142+ }143+ }144+ for k := range vimMotions {145+ if len(k) > len(seq) && strings.HasPrefix(k, seq) {146+ return true147+ }148+ }149+ return false150+}151+152+// motion moves a, the active cell or a visual selection's moving corner;153+// counted is whether a count was typed, which some motions read as a row.154+type motion func(m *Model, a *sheet.Addr, n int, counted bool)155+156+// vimMove applies a motion: in NORMAL mode to the active cell, dropping157+// any selection; in VISUAL mode to the moving corner.158+func (m *Model) vimMove(mv motion) {159+ a := &m.cur160+ if m.visual() != visualNone {161+ a = &m.ext162+ }163+ mv(m, a, m.vim.n(), m.vim.count > 0)164+ *a = clampAddr(*a)165+ a.Row = m.visibleRow(a.Row)166+ if m.visual() == visualNone {167+ m.clearSelection()168+ }169+ m.entry.tabbing = false170+ m.clampView()171+}172+173+// nav repeats a Sheets movement key n times.174+func nav(key string) motion {175+ return func(m *Model, a *sheet.Addr, n int, _ bool) {176+ for range n {177+ before := *a178+ m.navigate(key, a)179+ if *a == before {180+ return // at an edge; the rest of the count does nothing181+ }182+ }183+ }184+}185+186+// toRow goes to row n when a count was typed, else to the row first()187+// picks.188+func toRow(first func(m *Model) int) motion {189+ return func(m *Model, a *sheet.Addr, n int, counted bool) {190+ if counted {191+ a.Row = n - 1192+ } else {193+ a.Row = first(m)194+ }195+ }196+}197+198+// screenLine goes to a row on screen: pick chooses from the rows shown.199+func screenLine(pick func(rows []int, n int) int) motion {200+ return func(m *Model, a *sheet.Addr, n int, _ bool) {201+ var rows []int202+ for _, r := range m.screenRows() {203+ if fr, _ := m.frozen(); r != divider && r >= fr {204+ rows = append(rows, r)205+ }206+ }207+ if len(rows) > 0 {208+ a.Row = rows[clamp(pick(rows, n), 0, len(rows)-1)]209+ }210+ }211+}212+213+// halfPage moves the cell and the view half a screen, d = 1 down or -1 up.214+func halfPage(d int) motion {215+ return func(m *Model, a *sheet.Addr, n int, _ bool) {216+ rows := d * max(m.scrollRows()/2, 1) * n217+ a.Row = m.stepRow(a.Row, rows)218+ m.top = m.stepRow(m.top, rows)219+ }220+}221+222+// vimMotions move in NORMAL and VISUAL modes. The arrows and Sheets'223+// movement keys are here too, so they move a visual selection's corner224+// rather than dropping it.225+var vimMotions = map[string]motion{226+ "h": nav("left"), "j": nav("down"), "k": nav("up"), "l": nav("right"),227+ "left": nav("left"), "down": nav("down"), "up": nav("up"), "right": nav("right"),228+ "w": nav("ctrl+right"), "b": nav("ctrl+left"),229+ "ctrl+left": nav("ctrl+left"), "ctrl+right": nav("ctrl+right"), "ctrl+up": nav("ctrl+up"), "ctrl+down": nav("ctrl+down"),230+ "pgup": nav("pgup"), "pgdown": nav("pgdown"), "home": nav("home"), "end": nav("end"),231+ "ctrl+home": nav("ctrl+home"), "ctrl+end": nav("ctrl+end"),232+ "0": nav("home"), "^": nav("home"),233+ "$": func(m *Model, a *sheet.Addr, _ int, _ bool) {234+ last := sheet.Addr{Col: sheet.MaxCols - 1, Row: a.Row}235+ if m.sheet.Cell(last).Blank() {236+ last = m.sheet.Edge(last, -1, 0)237+ }238+ a.Col = last.Col239+ },240+ "gg": toRow(func(*Model) int { return 0 }),241+ "G": toRow(func(m *Model) int {242+ used, _ := m.sheet.UsedRange()243+ return used.To.Row244+ }),245+ "H": screenLine(func(_ []int, n int) int { return n - 1 }),246+ "M": screenLine(func(rows []int, _ int) int { return (len(rows) - 1) / 2 }),247+ "L": screenLine(func(rows []int, n int) int { return len(rows) - n }),248+ "ctrl+d": halfPage(1),249+ "ctrl+u": halfPage(-1),250+}251+252+// vimLine is the context line while a sequence is being typed (the keys253+// so far, as vim's showcmd) or in VISUAL mode (what applies).254+func (m *Model) vimLine() string {255+ if p := m.vim.pending(); p != "" {256+ return m.th.Key.Render(p) + m.th.Muted.Render(" ") + m.th.KeyHints("Esc", "cancel")257+ }258+ if m.vim.visual == visualRows {259+ return m.th.KeyHints("d", "delete rows", "y", "copy rows", "o", "other end", ":", "command", "Esc", "back")260+ }261+ return m.th.KeyHints("d", "delete", "y", "copy", "p", "paste", "o", "other corner", "V", "rows", "Esc", "back")262+}263+264+// startVisual selects from the active cell: cells, or whole rows.265+func (m *Model) startVisual(kind visualKind) {266+ if m.visual() == visualNone {267+ m.selecting, m.ext = true, m.cur268+ }269+ m.vim.visual = kind270+ m.whole = wholeNone271+ if kind == visualRows {272+ m.whole = wholeRows273+ }274+}275+276+// spanRows selects n whole rows from the active cell's, for an operator277+// with a count (3dd).278+func (m *Model) spanRows(n int) {279+ m.ext = sheet.Addr{Col: m.cur.Col, Row: min(m.cur.Row+n-1, sheet.MaxRows-1)}280+ m.selecting, m.whole = true, wholeRows281+}282+283+// spanCols selects n cells from the active cell rightwards (4x).284+func (m *Model) spanCols(n int) {285+ m.ext = sheet.Addr{Col: min(m.cur.Col+n-1, sheet.MaxCols-1), Row: m.cur.Row}286+ m.selecting, m.whole = n > 1, wholeNone287+}
A internal/ui/vim_test.go +390 -0
@@ -0,0 +1,390 @@
1+package ui2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ tea "charm.land/bubbletea/v2"10+)11+12+// vimModel is a model with vim keys on and a small table: A1:C6 filled13+// with their own addresses, row 1 as a header.14+func vimModel(t *testing.T) *Model {15+ t.Helper()16+ m := newModel()17+ for _, col := range "ABC" {18+ for row := 1; row <= 6; row++ {19+ a := string(col) + string(rune('0'+row))20+ if err := m.sheet.Set(addr(a), a); err != nil {21+ t.Fatal(err)22+ }23+ }24+ }25+ m.sheet.ClearHistory()26+ m.saved, m.changed = m.sheet.StateID(), false27+ m.runCommand("settings.vim")28+ return m29+}30+31+func indicator(m *Model) string {32+ f := strings.Fields(line(m, menuLine))33+ return f[len(f)-1]34+}35+36+// File > Settings > Vim keys turns NORMAL mode on and off; letters move37+// instead of typing, and the setting survives File > New.38+func TestVimSetting(t *testing.T) {39+ m := newModel()40+ press(t, m, "<alt+f>", "<up>", "<up>", "<right>")41+ if s := screen(m); !strings.Contains(s, "Vim keys") {42+ t.Fatalf("settings submenu:\n%s", s)43+ }44+ press(t, m, "<down>", "<enter>")45+ if !m.prefs.vim || indicator(m) != "NORMAL" || !strings.Contains(line(m, contextLine), "Vim keys on") {46+ t.Fatalf("vim %v, indicator %q, context %q", m.prefs.vim, indicator(m), line(m, contextLine))47+ }48+ press(t, m, "j", "l", "x")49+ if m.cur != addr("B2") || m.mode != modeReady || m.sheet.Len() != 0 {50+ t.Errorf("hjkl: cur %v mode %v cells %d", m.cur, m.mode, m.sheet.Len())51+ }52+ if !strings.Contains(line(m, m.height-1), ": command") {53+ t.Errorf("status line: %q", line(m, m.height-1))54+ }55+ m.runCommand("file.new")56+ if !m.prefs.vim || indicator(m) != "NORMAL" {57+ t.Errorf("File > New dropped vim keys")58+ }59+ m.runCommand("settings.vim")60+ press(t, m, "j")61+ if m.prefs.vim || indicator(m) != "ENTER" || bar(m) != "j" {62+ t.Errorf("off: indicator %q, bar %q", indicator(m), bar(m))63+ }64+}65+66+func TestVimMotionsAndCounts(t *testing.T) {67+ m := vimModel(t)68+ steps := []struct {69+ keys []string70+ want string71+ }{72+ {[]string{"3j"}, "A4"},73+ {[]string{"2l"}, "C4"},74+ {[]string{"k"}, "C3"},75+ {[]string{"h"}, "B3"},76+ {[]string{"gg"}, "B1"},77+ {[]string{"G"}, "B6"},78+ {[]string{"3G"}, "B3"},79+ {[]string{"5gg"}, "B5"},80+ {[]string{"0"}, "A5"},81+ {[]string{"$"}, "C5"},82+ {[]string{"b"}, "A5"},83+ {[]string{"w"}, "C5"},84+ {[]string{"<down>"}, "C6"},85+ {[]string{"10k"}, "C1"},86+ {[]string{"<ctrl+d>"}, "C8"},87+ {[]string{"<ctrl+u>"}, "C1"},88+ {[]string{"L"}, "C15"},89+ {[]string{"M"}, "C8"},90+ {[]string{"H"}, "C1"},91+ {[]string{"j", "20", "<esc>", "j"}, "C3"},92+ }93+ for _, s := range steps {94+ press(t, m, s.keys...)95+ if m.cur.String() != s.want || m.hasRange() {96+ t.Fatalf("%v: at %v (range %v), want %s", s.keys, m.cur, m.hasRange(), s.want)97+ }98+ }99+ // A count or a sequence being typed shows on the context line.100+ press(t, m, "3d")101+ if l := line(m, contextLine); !strings.HasPrefix(l, "3d") || indicator(m) != "NORMAL" {102+ t.Errorf("pending: %q", l)103+ }104+ press(t, m, "<esc>")105+ if m.vim.pending() != "" || strings.HasPrefix(line(m, contextLine), "3d") {106+ t.Errorf("Esc kept %q", m.vim.pending())107+ }108+ // A broken sequence does nothing.109+ press(t, m, "dz", "j")110+ if m.cur != addr("C4") || m.sheet.Len() != 18 {111+ t.Errorf("dz: at %v, %d cells", m.cur, m.sheet.Len())112+ }113+}114+115+func TestVimRowOperators(t *testing.T) {116+ m := vimModel(t)117+ press(t, m, "j", "2dd")118+ if input(m, "A2") != "A4" || m.sheet.Len() != 12 || m.cur != addr("A2") {119+ t.Fatalf("2dd: A2 %q, %d cells, at %v", input(m, "A2"), m.sheet.Len(), m.cur)120+ }121+ if !strings.Contains(line(m, contextLine), "Deleted 2 rows") {122+ t.Errorf("context: %q", line(m, contextLine))123+ }124+ // p puts the rows back below, P above; each is one undo step.125+ press(t, m, "p")126+ if input(m, "A3") != "A2" || input(m, "A4") != "A3" || input(m, "C4") != "C3" || m.cur != addr("A3") {127+ t.Fatalf("p: A3 %q A4 %q C4 %q at %v", input(m, "A3"), input(m, "A4"), input(m, "C4"), m.cur)128+ }129+ press(t, m, "u")130+ if input(m, "A3") != "A5" || m.sheet.Len() != 12 {131+ t.Fatalf("u: A3 %q", input(m, "A3"))132+ }133+ press(t, m, "gg", "P")134+ if input(m, "A1") != "A2" || input(m, "A3") != "A1" {135+ t.Fatalf("P: A1 %q A3 %q", input(m, "A1"), input(m, "A3"))136+ }137+ press(t, m, "u", "u")138+ if input(m, "A2") != "A2" || m.sheet.Len() != 18 {139+ t.Fatalf("uu: A2 %q, %d cells", input(m, "A2"), m.sheet.Len())140+ }141+ press(t, m, "<ctrl+r>")142+ if input(m, "A2") != "A4" {143+ t.Fatalf("Ctrl+R: A2 %q", input(m, "A2"))144+ }145+ press(t, m, "u")146+ // yy copies without deleting; 2p pastes twice.147+ press(t, m, "G", "yy", "2p")148+ if input(m, "B7") != "B6" || input(m, "B8") != "B6" || m.sheet.Len() != 24 {149+ t.Fatalf("yy 2p: B7 %q B8 %q, %d cells", input(m, "B7"), input(m, "B8"), m.sheet.Len())150+ }151+ // x clears cells to the right, keeping rows.152+ press(t, m, "gg", "2x")153+ if input(m, "A1") != "" || input(m, "B1") != "" || input(m, "C1") != "C1" || m.hasRange() {154+ t.Fatalf("2x: %q %q %q", input(m, "A1"), input(m, "B1"), input(m, "C1"))155+ }156+ // o and O open a row and start typing in it.157+ press(t, m, "o", "new", "<enter>")158+ if input(m, "A2") != "new" || input(m, "A3") != "A2" {159+ t.Fatalf("o: A2 %q A3 %q", input(m, "A2"), input(m, "A3"))160+ }161+ press(t, m, "gg", "O")162+ if m.mode != modeEnter || m.cur != addr("A1") || input(m, "C2") != "C1" {163+ t.Fatalf("O: mode %v at %v, C2 %q", m.mode, m.cur, input(m, "C2"))164+ }165+ press(t, m, "<esc>")166+ if indicator(m) != "NORMAL" {167+ t.Errorf("after Esc: %q", indicator(m))168+ }169+}170+171+func TestVimEditKeys(t *testing.T) {172+ m := vimModel(t)173+ press(t, m, "i", "x", "<enter>")174+ if input(m, "A1") != "xA1" || m.cur != addr("A2") {175+ t.Fatalf("i: A1 %q at %v", input(m, "A1"), m.cur)176+ }177+ press(t, m, "a", "y", "<enter>")178+ if input(m, "A2") != "A2y" {179+ t.Fatalf("a: A2 %q", input(m, "A2"))180+ }181+ press(t, m, "=", "1+1", "<enter>")182+ if m.sheet.Value(addr("A3")).Num != 2 {183+ t.Fatalf("=: A3 %q", input(m, "A3"))184+ }185+ // Sheets keys vim doesn't take still work.186+ press(t, m, "<enter>")187+ if m.mode != modeEdit {188+ t.Errorf("Enter: mode %v", m.mode)189+ }190+ press(t, m, "<esc>", "<delete>")191+ if input(m, "A4") != "" {192+ t.Errorf("Del: A4 %q", input(m, "A4"))193+ }194+}195+196+func TestVimVisual(t *testing.T) {197+ m := vimModel(t)198+ press(t, m, "v", "j", "l")199+ if indicator(m) != "VISUAL" || m.selection().String() != "A1:B2" {200+ t.Fatalf("v: %q %v", indicator(m), m.selection())201+ }202+ if !strings.Contains(line(m, contextLine), "other corner") {203+ t.Errorf("context: %q", line(m, contextLine))204+ }205+ press(t, m, "o")206+ if m.cur != addr("B2") || m.selection().String() != "A1:B2" {207+ t.Errorf("o: at %v, %v", m.cur, m.selection())208+ }209+ press(t, m, "y")210+ if indicator(m) != "NORMAL" || m.hasRange() || m.copied.clip == nil || m.copied.clip.Src.String() != "A1:B2" {211+ t.Fatalf("y: %q range %v", indicator(m), m.hasRange())212+ }213+ press(t, m, "G", "p")214+ if input(m, "B6") != "A1" || input(m, "C7") != "B2" {215+ t.Fatalf("p: B6 %q C7 %q", input(m, "B6"), input(m, "C7"))216+ }217+ press(t, m, "gg", "0", "v", "d")218+ if input(m, "A1") != "" || indicator(m) != "NORMAL" {219+ t.Fatalf("v d: A1 %q", input(m, "A1"))220+ }221+ press(t, m, "p")222+ if input(m, "A1") != "A1" {223+ t.Fatalf("p after d: A1 %q", input(m, "A1"))224+ }225+ // V selects whole rows; motions stretch them and d deletes them.226+ press(t, m, "j", "V", "2j")227+ if indicator(m) != "VISUAL" || m.whole != wholeRows || m.selection().From.Row != 1 || m.selection().To.Row != 3 {228+ t.Fatalf("V: %v whole %v", m.selection(), m.whole)229+ }230+ press(t, m, "d")231+ if input(m, "A2") != "A5" || !m.rowsCopied() {232+ t.Fatalf("V d: A2 %q", input(m, "A2"))233+ }234+ // Esc leaves VISUAL, keeping the active cell.235+ press(t, m, "v", "l", "<esc>")236+ if indicator(m) != "NORMAL" || m.hasRange() || m.cur != addr("A2") {237+ t.Errorf("Esc: %q %v at %v", indicator(m), m.hasRange(), m.cur)238+ }239+}240+241+func TestVimFind(t *testing.T) {242+ m := vimModel(t)243+ press(t, m, "n")244+ if !strings.Contains(line(m, contextLine), "No search yet") {245+ t.Errorf("n before a search: %q", line(m, contextLine))246+ }247+ press(t, m, "/", "B", "<enter>")248+ if m.overlay != nil || m.cur != addr("B1") || indicator(m) != "NORMAL" {249+ t.Fatalf("/B: overlay %v at %v", m.overlay, m.cur)250+ }251+ press(t, m, "n", "n")252+ if m.cur != addr("B3") || !strings.Contains(line(m, contextLine), "Match 3 of 6") {253+ t.Fatalf("nn: at %v, %q", m.cur, line(m, contextLine))254+ }255+ press(t, m, "N")256+ if m.cur != addr("B2") {257+ t.Errorf("N: at %v", m.cur)258+ }259+ press(t, m, "gg", "N")260+ if m.cur != addr("B6") {261+ t.Errorf("N wraps: at %v", m.cur)262+ }263+}264+265+func TestVimCommandLine(t *testing.T) {266+ m := vimModel(t)267+ press(t, m, ":")268+ if indicator(m) != "COMMAND" || !strings.HasPrefix(line(m, contextLine), ":") {269+ t.Fatalf(": %q %q", indicator(m), line(m, contextLine))270+ }271+ press(t, m, "B12", "<enter>")272+ if m.cur != addr("B12") || m.overlay != nil || indicator(m) != "NORMAL" {273+ t.Fatalf(":B12 at %v", m.cur)274+ }275+ press(t, m, ":40", "<enter>")276+ if m.cur != addr("B40") {277+ t.Fatalf(":40 at %v", m.cur)278+ }279+ press(t, m, ":Sheet1!C2", "<enter>")280+ if m.cur != addr("C2") {281+ t.Fatalf(":Sheet1!C2 at %v", m.cur)282+ }283+ // Completion comes from the command registry.284+ press(t, m, ":fill")285+ s := screen(m)286+ for _, want := range []string{"Commands", "Fill down", "edit.fill_down", "Fill right"} {287+ if !strings.Contains(s, want) {288+ t.Fatalf(":fill completions lack %q:\n%s", want, s)289+ }290+ }291+ if !strings.Contains(line(m, m.height-1), "Copy the top row") {292+ t.Errorf("status: %q", line(m, m.height-1))293+ }294+ press(t, m, "<tab>")295+ if m.line.text() != "edit.fill_down" {296+ t.Errorf("Tab: %q", m.line.text())297+ }298+ press(t, m, "<tab>")299+ if m.line.text() != "edit.fill_right" {300+ t.Errorf("Tab Tab: %q", m.line.text())301+ }302+ press(t, m, "<esc>")303+ // A title runs its command; so does the first completion of a prefix.304+ press(t, m, "v", "j", ":", "fill down", "<enter>")305+ if input(m, "C3") != "C2" {306+ t.Errorf(":fill down: C3 %q", input(m, "C3"))307+ }308+ press(t, m, "<esc>", ":edit.undo", "<enter>")309+ if input(m, "C3") != "C3" {310+ t.Errorf(":edit.undo: C3 %q", input(m, "C3"))311+ }312+ press(t, m, ":keyboard sh", "<enter>")313+ if _, ok := m.overlay.(*shortcuts); !ok {314+ t.Errorf(":keyboard sh opened %T", m.overlay)315+ }316+ press(t, m, "<esc>", ":nonsense", "<enter>")317+ if m.mode != modeError || !strings.Contains(line(m, m.height-1), "Not a command, cell or range: nonsense") {318+ t.Errorf(":nonsense: %q", line(m, m.height-1))319+ }320+ press(t, m, "<esc>", ":", "<backspace>")321+ if m.overlay != nil {322+ t.Errorf("Backspace on an empty line left %T open", m.overlay)323+ }324+}325+326+func TestVimFileCommands(t *testing.T) {327+ dir := t.TempDir()328+ m := vimModel(t)329+ name := filepath.Join(dir, "book")330+ press(t, m, "x", ":w "+name, "<enter>")331+ if _, err := os.Stat(name + ".012"); err != nil || m.filename != name+".012" || m.changed {332+ t.Fatalf(":w: %v, filename %q, changed %v", err, m.filename, m.changed)333+ }334+ press(t, m, ":w "+filepath.Join(dir, "out.csv"), "<enter>")335+ if data, err := os.ReadFile(filepath.Join(dir, "out.csv")); err != nil || !strings.Contains(string(data), "B1,C1") {336+ t.Fatalf(":w out.csv: %v %q", err, data)337+ }338+ press(t, m, "j", "x", ":e other", "<enter>")339+ if m.mode != modeError || !strings.Contains(line(m, m.height-1), "Unsaved changes") {340+ t.Fatalf(":e with changes: %q", line(m, m.height-1))341+ }342+ press(t, m, "<esc>", ":e! "+name, "<enter>")343+ if m.changed || input(m, "A2") != "A2" {344+ t.Fatalf(":e!: changed %v, A2 %q", m.changed, input(m, "A2"))345+ }346+ if _, ok := press(t, m, ":q", "<enter>").(tea.QuitMsg); !ok {347+ t.Error(":q didn't quit a saved sheet")348+ }349+ press(t, m, "x", ":q", "<enter>")350+ if !strings.Contains(line(m, contextLine), "unsaved changes") {351+ t.Errorf(":q with changes: %q", line(m, contextLine))352+ }353+ press(t, m, "<esc>")354+ if _, ok := press(t, m, ":wq", "<enter>").(tea.QuitMsg); !ok || m.changed {355+ t.Errorf(":wq didn't save and quit")356+ }357+}358+359+// Help lists the vim keys first, and the palette and menus show vim keys360+// where Sheets' are taken.361+func TestVimHelpAndShortcuts(t *testing.T) {362+ m := vimModel(t)363+ press(t, m, "<f1>")364+ s := screen(m)365+ if !strings.Contains(s, "Vim keys: NORMAL") || !strings.Contains(s, "Left, down, up, right") {366+ t.Errorf("help:\n%s", s)367+ }368+ found := false369+ for _, r := range helpRows(true) {370+ found = found || r.action == "Cut rows" && strings.Join(r.keys, " ") == "dd"371+ }372+ if !found {373+ t.Error("help lacks dd for Cut rows")374+ }375+ if m.shortcut("row.cut") != "dd" || m.shortcut("edit.fill_down") != "" || m.shortcut("edit.undo") != "Ctrl+Z" {376+ t.Errorf("shortcuts: %q %q %q", m.shortcut("row.cut"), m.shortcut("edit.fill_down"), m.shortcut("edit.undo"))377+ }378+ if shortcut("edit.fill_down") != "Ctrl+D" {379+ t.Errorf("Sheets shortcut: %q", shortcut("edit.fill_down"))380+ }381+ rows := helpRows(true)382+ if rows[0].heading != "Vim keys: NORMAL" {383+ t.Errorf("first help group %q", rows[0].heading)384+ }385+ for _, r := range helpRows(false) {386+ if strings.HasPrefix(r.heading, "Vim") {387+ t.Error("vim keys in help with vim keys off")388+ }389+ }390+}
A internal/ui/vimkeys.go +239 -0
@@ -0,0 +1,239 @@
1+package ui2+3+import (4+ tea "charm.land/bubbletea/v2"5+)6+7+// The vim key tables. Each binding runs a registered command, so vim keys8+// show next to commands in the palette and the shortcuts, and do what the9+// menus do; the few that only change mode (v, V, =) say so in help.10+11+// vimSpan is what a count means to a binding.12+type vimSpan int13+14+const (15+ spanRepeat vimSpan = iota // run the command n times (3u)16+ spanOnce // the count means nothing (:)17+ spanRows // act on n rows from the active cell's (3dd)18+ spanCols // act on n cells from the active cell rightwards (4x)19+)20+21+// vimBinding is what a key sequence does in NORMAL or VISUAL mode.22+type vimBinding struct {23+ id string // the command run, or shown in help for do24+ span vimSpan25+ label string // for help, when not the command's title26+ // do, when set, acts instead of running id: for keys that change27+ // mode or choose between commands.28+ do func(m *Model, n int) tea.Cmd29+}30+31+// run carries out the binding with count n.32+func (b vimBinding) run(m *Model, n int) tea.Cmd {33+ if b.do != nil {34+ return b.do(m, n)35+ }36+ c := commands[b.id]37+ if !c.available(m) {38+ m.note = c.title + " isn't available now"39+ return nil40+ }41+ switch b.span {42+ case spanOnce:43+ return m.runCommand(b.id)44+ case spanRows:45+ m.spanRows(n)46+ case spanCols:47+ m.spanCols(n)48+ default:49+ cmds := make([]tea.Cmd, 0, n)50+ for range n {51+ cmds = append(cmds, m.runCommand(b.id))52+ }53+ return tea.Batch(cmds...)54+ }55+ cmd := m.runCommand(b.id)56+ if m.mode == modeReady {57+ m.clearSelection()58+ }59+ return cmd60+}61+62+// vimNormal binds key sequences in NORMAL mode, in the order help lists63+// them.64+var vimNormal = map[string]vimBinding{65+ "i": {id: "edit", label: "Edit the cell, caret at the start", do: func(m *Model, _ int) tea.Cmd {66+ cmd := m.runCommand("edit")67+ m.line.pos = 068+ return cmd69+ }},70+ "a": {id: "edit", span: spanOnce},71+ "=": {label: "Start a formula", do: func(m *Model, _ int) tea.Cmd { m.startEntry(modeEnter, "="); return nil }},72+ "o": {id: "row.open_below", span: spanOnce},73+ "O": {id: "row.open_above", span: spanOnce},74+ "x": {id: "clear", span: spanCols},75+ "dd": {id: "row.cut", span: spanRows},76+ "yy": {id: "row.yank", span: spanRows},77+ "p": {id: "row.paste_below", label: "Paste rows below, or cells here", do: putAfter},78+ "P": {id: "row.paste_above", label: "Paste rows above, or cells here", do: putBefore},79+ "u": {id: "edit.undo"},80+ "ctrl+r": {id: "edit.redo"},81+ "v": {label: "Select cells (VISUAL)", do: func(m *Model, _ int) tea.Cmd { m.startVisual(visualCells); return nil }},82+ "V": {label: "Select rows (VISUAL)", do: func(m *Model, _ int) tea.Cmd { m.startVisual(visualRows); return nil }},83+ "/": {id: "edit.find", span: spanOnce},84+ "n": {id: "edit.find_next"},85+ "N": {id: "edit.find_prev"},86+ ":": {id: "vim.command", span: spanOnce},87+ "gt": {id: "sheet.next"},88+ "gT": {id: "sheet.prev"},89+}90+91+// vimVisual binds keys in VISUAL mode; motions extend the selection.92+var vimVisual = map[string]vimBinding{93+ "d": {label: "Delete the selection, keeping a copy", do: visualDelete},94+ "x": {label: "Delete the selection, keeping a copy", do: visualDelete},95+ "y": {label: "Copy the selection", do: func(m *Model, _ int) tea.Cmd {96+ id := "edit.copy"97+ if m.vim.visual == visualRows {98+ id = "row.yank"99+ }100+ return m.endVisual(m.runCommand(id))101+ }},102+ "p": {id: "edit.paste", label: "Paste over the selection", do: func(m *Model, _ int) tea.Cmd {103+ m.vim.visual = visualNone104+ return m.runCommand("edit.paste")105+ }},106+ "o": {label: "Go to the other corner", do: func(m *Model, _ int) tea.Cmd {107+ m.cur, m.ext = m.ext, m.cur108+ return nil109+ }},110+ "v": {label: "Select cells, or back to NORMAL", do: func(m *Model, _ int) tea.Cmd { return m.switchVisual(visualCells) }},111+ "V": {label: "Select rows, or back to NORMAL", do: func(m *Model, _ int) tea.Cmd { return m.switchVisual(visualRows) }},112+ ":": {id: "vim.command", span: spanOnce},113+}114+115+// vimHelpOrder lists the NORMAL and VISUAL bindings for help, grouped.116+var vimHelpOrder = []string{"i", "a", "=", "o", "O", "x", "dd", "yy", "p", "P", "u", "ctrl+r", "v", "V", "/", "n", "N", ":", "gt", "gT"}117+118+// visualDelete deletes the selection, keeping a copy to paste: whole119+// rows with V, the cells' contents with v.120+func visualDelete(m *Model, _ int) tea.Cmd {121+ if m.vim.visual == visualRows {122+ return m.endVisual(m.runCommand("row.cut"))123+ }124+ cmd := m.runCommand("edit.copy")125+ m.runCommand("clear")126+ return m.endVisual(cmd)127+}128+129+// endVisual goes back to NORMAL mode after an operator.130+func (m *Model) endVisual(cmd tea.Cmd) tea.Cmd {131+ m.vim.visual = visualNone132+ if m.mode == modeReady {133+ m.clearSelection()134+ }135+ return cmd136+}137+138+// switchVisual changes a visual selection to kind, or ends it when it139+// already is one.140+func (m *Model) switchVisual(kind visualKind) tea.Cmd {141+ if m.vim.visual == kind {142+ return m.endVisual(nil)143+ }144+ m.startVisual(kind)145+ return nil146+}147+148+// putAfter is p: rows copied with yy or dd go in as new rows below the149+// active one; cells copied any other way are pasted at the active cell.150+func putAfter(m *Model, _ int) tea.Cmd {151+ if !m.rowsCopied() {152+ return m.runCommand("edit.paste")153+ }154+ return m.runCommand("row.paste_below") // it reads the count, n155+156+}157+158+// putBefore is P: as p, but rows go in above the active one.159+func putBefore(m *Model, _ int) tea.Cmd {160+ if !m.rowsCopied() {161+ return m.runCommand("edit.paste")162+ }163+ return m.runCommand("row.paste_above")164+}165+166+// vimKeysFor returns the vim keys bound to a command, NORMAL mode first.167+func vimKeysFor(id string) []string {168+ var keys []string169+ for _, k := range vimHelpOrder {170+ if vimNormal[k].id == id {171+ keys = append(keys, k)172+ }173+ }174+ return keys175+}176+177+// vimShadows reports whether vim keys take key from its Sheets binding:178+// Ctrl+D and Ctrl+U scroll, Ctrl+R redoes.179+func vimShadows(key string) bool {180+ _, bound := vimNormal[key]181+ _, moves := vimMotions[key]182+ return bound || moves183+}184+185+// isVimKey reports whether k is one of the vim tables' keys, which are186+// shown as typed (dd, G, $) rather than as a named key.187+func isVimKey(k string) bool {188+ _, bound := vimNormal[k]189+ _, moves := vimMotions[k]190+ _, visual := vimVisual[k]191+ return (bound || moves || visual) && !isNamedKey(k)192+}193+194+// isNamedKey reports whether k names a key rather than a character.195+func isNamedKey(k string) bool {196+ return len(k) > 2 || k == "up"197+}198+199+// vimVisualOrder lists the VISUAL bindings for help.200+var vimVisualOrder = []string{"d", "x", "y", "p", "o", "v", "V"}201+202+// vimHelpRows are the vim keys for the shortcuts view: motions, then the203+// NORMAL and VISUAL bindings, from the tables above.204+func vimHelpRows() []helpRow {205+ rows := []helpRow{206+ {heading: "Vim keys: NORMAL"},207+ {keys: []string{"h", "j", "k", "l"}, action: "Left, down, up, right"},208+ {keys: []string{"w", "b"}, action: "Jump to the edge of the data"},209+ {keys: []string{"gg", "G"}, action: "First row, last row of data (5G: row 5)"},210+ {keys: []string{"0", "$"}, action: "Column A, last cell of the row"},211+ {keys: []string{"H", "M", "L"}, action: "Top, middle, bottom of the screen"},212+ {keys: []string{"Ctrl+D", "Ctrl+U"}, action: "Half a screen down, up"},213+ {keys: []string{"5j", "3dd"}, action: "A count repeats a move or sizes an operator"},214+ }215+ rows = append(rows, bindingRows(vimNormal, vimHelpOrder)...)216+ rows = append(rows, helpRow{heading: "Vim keys: VISUAL"},217+ helpRow{keys: []string{"Motions"}, action: "Extend the selection"})218+ rows = append(rows, bindingRows(vimVisual, vimVisualOrder)...)219+ return append(rows, helpRow{keys: []string{"Esc"}, action: "Back to NORMAL"})220+}221+222+// bindingRows lists bindings in order, joining neighbors that do the223+// same thing (d and x).224+func bindingRows(table map[string]vimBinding, order []string) []helpRow {225+ var rows []helpRow226+ for _, k := range order {227+ b := table[k]228+ action := b.label229+ if action == "" {230+ action = commands[b.id].title231+ }232+ if n := len(rows); n > 0 && rows[n-1].action == action {233+ rows[n-1].keys = append(rows[n-1].keys, keyLabel(k))234+ continue235+ }236+ rows = append(rows, helpRow{keys: []string{keyLabel(k)}, action: action})237+ }238+ return rows239+}