cat / 012
clone

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

Vim: . repeat, registers, marks, cc and s, : history, :w!; keys held to preview with the kitty keyboard protocol

Dami committed 317b5c4
. replays the last NORMAL change (x, s, dd, cc, p, o, and edits begun
with i, a or =, with what was typed) with its count and register, a
count before it replacing the change's own. "a-"z, "0, "1-"9, "- and
"+ name registers for yy, dd, x, p and VISUAL y, d, p; "+p reads the
system clipboard over OSC 52. m{a-z}, `{a-z}, '{a-z} and ''/`` mark and
jump. cc/S and s clear the row or cell and start an entry. Up and Down
on the : line walk a per-session history (prefix-filtered, as vim's);
Ctrl+N and Ctrl+P move the completions. :w! writes over a file changed
on disk or of the name given, as Overwrite and Replace do.

The view asks for key release events. On terminals that report them,
Space held on a selected chart shows it across the grid, and in the
theme picker (empty search) hides the list to show the sheet; letting
go goes back. Without the protocol Space does what it did.

Co-Authored-By: Claude <noreply@anthropic.com>
commit
317b5c45cd6e1e0c1458303b2ecbbd3cb901e8fb
parent
9471af439d88bfe1f5f990ec1590e858a84452cb
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.6 of 3, Jev's estimate
26 files changed, +1302 -61
MROADMAP.md4+-
Mdocs/contributing/architecture.md6+-
Mdocs/reference/keys.md52+++++-
Mdocs/sheets/charts.md5+-
Mdocs/terminal/README.md2+-
Mdocs/terminal/themes.md4+
Minternal/ui/charts.go4+-
Minternal/ui/chartsel.go23++-
Minternal/ui/cmdline.go25++-
Minternal/ui/cmdline/cmdline.go29+++-
Minternal/ui/cmdline/cmdline_test.go36+++-
Ainternal/ui/cmdline/history.go61++++++
Minternal/ui/file.go2+-
Minternal/ui/hosts.go2+
Ainternal/ui/keyboard.go110+++++++++++
Ainternal/ui/keyboard_test.go123++++++++++++
Minternal/ui/model.go14+
Minternal/ui/panel.go2+
Minternal/ui/themepicker/themepicker.go54+++++
Minternal/ui/themepicker/themepicker_test.go2+
Minternal/ui/vim.go59+++++-
Minternal/ui/vimkeys.go133++++++++++---
Ainternal/ui/vimmarks.go117++++++++++++
Ainternal/ui/vimmore_test.go303++++++++++++++++++++++++++++++
Ainternal/ui/vimregs.go104++++++++++
Ainternal/ui/vimrepeat.go87+++++++++
M ROADMAP.md +2 -2
@@ -44,8 +44,6 @@ rises in measured steps; see [Bounds of support](docs/contributing/limits.md#wha
4444 | Item | Size |4545 |---|---|4646 | Record dialog choices (sort bar, filter picker, find and replace, chart editor) and chart drags; let scripts run commands that open dialogs, with answers | M |47-| Vim: `.` repeat, registers, marks, `cc`/`s`, command-line history, `:w!` | M |48-| Hold-to-preview and Shift+Enter on terminals with the kitty keyboard protocol (`View.KeyboardEnhancements`) | S |4947 | Sixel chart images, redrawn on resize, for terminals without kitty graphics | M |5048 5149 ## Later: sharing a live sheet (shelved)
@@ -86,6 +84,7 @@ by a gateway dialing the iroh ticket.
8684 - Formula suggestions, argument hints and pointing at cells and sheets: [Building formulas](docs/formulas/building.md)8785 - Tracing precedents and dependents, hidden sheets explained: [Building formulas](docs/formulas/building.md)8886 - An optional vim keymap with a `:` command line: [Keys and mouse](docs/reference/keys.md#vim-keys)87+- Vim `.` repeat, registers, marks, `cc` and `s`, `:` line history and `:w!`: [Keys and mouse](docs/reference/keys.md#vim-keys)8988 9089 **Data tools**9190
@@ -105,6 +104,7 @@ by a gateway dialing the iroh ticket.
105104 - Color schemes and a config file: [Themes](docs/terminal/themes.md), [Configuration](docs/reference/config.md)106105 - A high-contrast theme at WCAG AAA, and every state readable without color: [Themes](docs/terminal/themes.md#high-contrast), [UX](docs/contributing/ux.md#reading-without-color)107106 - `012 serve` over SSH, with files on the ssh command line and recovery of unsaved work: [Serving over SSH](docs/terminal/ssh.md)107+- Shift+Enter, Ctrl+I and keys held to preview, with the kitty keyboard protocol: [Keys and mouse](docs/reference/keys.md#keys-the-terminal-has-to-tell-apart)108108 109109 **Files**110110
M docs/contributing/architecture.md +3 -3
@@ -369,7 +369,7 @@ draw. The components:
369369 | mouse | `mouseState` | drags, hover, double clicks, the fill handle (`mouse.go`, `fill.go`) |370370 | import | `transfer.Transfer` | the import in progress, its progress display and cancelling (package `transfer`); choosing and placing imports (`transfer.go`, `importplace.go`) |371371 | macros | `recorder`, `macroState` | a recording in progress (`macrorec.go`); a macro running, trust in the file's macros (`macrorun.go`); what scripts act on (`macrohost.go`, `macrohostnav.go`); Data > Macros and the manager (`macro.go`, `macromanage.go`) |372-| others | `clipboard`, `trace`, `chartState`, `jevRunner`, `terminal` | what Ctrl+V pastes, a trace being shown, chart commands' target, JEV questions in flight, what the terminal supports and the chart images sent to it |372+| others | `clipboard`, `trace`, `chartState`, `jevRunner`, `terminal`, `session` | what Ctrl+V pastes, a trace being shown, chart commands' target, JEV questions in flight, what the terminal supports and the chart images sent to it, what outlasts the file open (the `:` history, whether keys can be held: `keyboard.go`) |373373 374374 Overlays implement `overlay.Overlay` (package `overlay`): an indicator for375375 the mode, `Key` and `Mouse` handlers, a `Layout` of boxes to draw, and
@@ -389,8 +389,8 @@ methods off `ui.Model`'s exported API:
389389 | Package | Host | Methods |390390 |---|---|---|391391 | `picker` | `picker.Host` | theme, size, the edit line, close, record the answer to a command's question (5) |392-| `cmdline` | `cmdline.Host` | theme, size, the edit line, close, the commands to complete, run a line, fail (7) |393-| `themepicker` | `themepicker.Host` | a picker's host, and the current theme, the themes directory, preview, keep (9) |392+| `cmdline` | `cmdline.Host` | theme, size, the edit line, close, the commands to complete, run a line, fail, the session's history (8) |393+| `themepicker` | `themepicker.Host` | a picker's host, and the current theme, the themes directory, preview, keep, whether keys can be held (10) |394394 | `findbar` | `findbar.Host` | theme, size, the edit line, the workbook, the sheet and cell shown, show a cell, note, mark modified, leave keeping the search, pass a click to the grid (10) |395395 | `rules` | `rules.Host` | theme, size, the edit line, close, the sheet and selection, save a conditional format or a validation rule (as the commands macros record), note a rule removed or moved, the terminal's palette colors (10) |396396 | `tabstrip`, `transfer` | none | they're handed a view or messages and draw what they're given |
M docs/reference/keys.md +48 -4
@@ -124,6 +124,20 @@ range, F2 renames or repoints it and Ctrl+D deletes it.
124124 | Ctrl+Alt+Shift+0 to 9 | Run the macro with that shortcut ([Macros](../sheets/macros.md#running)) |125125 | Esc | Stop a macro while it runs (the mode indicator says CMD) |126126 127+## Keys the terminal has to tell apart128+129+Terminals send some keys as they send others: Shift+Enter as Enter,130+Ctrl+I as Tab, Ctrl+M as Enter, and Alt and a letter as Esc typed just131+before the letter. A terminal with the kitty keyboard protocol132+([Terminal features](../terminal/README.md#terminal-features-012-uses))133+tells them apart and says when a key is let go, and there 012 also does:134+135+| Key | Action | Where the terminal can't tell |136+|---|---|---|137+| Shift+Enter | Accept an entry and move up, as in Sheets | As Enter: accept and move down |138+| Ctrl+I | Italic | As Tab: move right |139+| Space, held | On a selected chart, show it across the grid until Space is let go; in File > Settings > Theme with nothing typed, hide the list to see the whole sheet in the highlighted theme | Space does what it does there: deselects the chart and starts an entry, or types a space |140+127141 ## Vim keys128142 129143 File > Settings > Vim keys (also `:settings.vim` or the palette) turns
@@ -153,9 +167,16 @@ show them where Sheets' keys are taken.
153167 | `i` `a`, Enter | Edit the cell, caret at the start or the end |154168 | `=` | Start a formula |155169 | `o` `O` | Insert a row below or above and start typing in it |156-| `x` | Clear the cell (with a count, that many to the right) |170+| `x` | Clear the cell, keeping a copy to paste (with a count, that many to the right) |171+| `s` | Clear the cell and start typing in it |157172 | `dd` `yy` | Cut or copy the row (with a count, that many rows) |173+| `cc` `S` | Clear the row's cells, keeping the row and its formats and a copy to paste, and start typing in the active cell |158174 | `p` `P` | Paste rows cut or copied with `dd` or `yy` as new rows below or above, with their row formats; other copied cells paste at the active cell |175+| `.` | Repeat the last change: `x`, `s`, `dd`, `cc`, `p`, `o`, or an edit begun with `i`, `a` or `=` with what was typed; a count replaces the change's own (`3.`) |176+| `"a` to `"z`, `"0` to `"9`, `"-`, `"+` | Name the register the next `yy`, `dd`, `x`, `p` or VISUAL `y`, `d`, `p` uses (see [Registers](#registers)) |177+| `ma` to `mz` | Mark the active cell |178+| `` `a `` to `` `z ``, `'a` to `'z` | Go to the mark, or to its row in column A (on the mark's sheet) |179+| `''` ``` `` ``` | Back to where the last jump (`gg`, `G`, `H`, `M`, `L`, `/`, `n`, `N`, a mark, a `:` cell or row) left from |159180 | `u`, Ctrl+R | Undo, redo |160181 | `v` `V` | Select cells or whole rows (VISUAL); motions stretch the selection |161182 | `/`, `n` `N` | Find (Enter stays on the match), next and previous match |
@@ -172,6 +193,24 @@ show them where Sheets' keys are taken.
172193 | `:` | The command line, acting on the selection |173194 | `v` `V`, Esc | Back to NORMAL |174195 196+### Registers197+198+Copies and deletes go to the clipboard Ctrl+V pastes, which is vim's199+unnamed register (`""`), and also to a register of their own:200+201+| Register | Holds |202+|---|---|203+| `"a` to `"z` | What the operator after it copied or deleted (`"ayy`, `"bx`), until written again; `"ap` pastes it |204+| `"0` | The last copy made without naming a register |205+| `"1` to `"9` | The last rows deleted (`dd`, `cc`, VISUAL `V` `d`), the newest in `"1` |206+| `"-` | The last cells deleted (`x`, `s`, VISUAL `v` `d`) |207+| `"+` | The system clipboard: every copy goes there (OSC 52), and `"+p` pastes what the terminal reads back from it, where the terminal allows reading it |208+209+Rows in a register paste as new rows, as they do after `dd` and `yy`.210+Registers and marks belong to the workbook open, as the clipboard does.211+212+### The command line213+175214 The `:` line takes:176215 177216 | Command | Action |
@@ -179,6 +218,7 @@ The `:` line takes:
179218 | `:B12`, `:Sheet2!A1`, `:C3:D9`, `:Sales` | Go to a cell, range or named range |180219 | `:40` | Go to row 40 |181220 | `:w`, `:w name`, `:w out.csv` | Save, Save as, or Download as another format, as the File menu does |221+| `:w!`, `:w! name`, `:wq!` | Save even if the file changed on disk since it was opened, and replace a file of the name given, without asking (as choosing Overwrite or Replace would) |182222 | `:q`, `:q!` | Quit (asking about unsaved changes), quit discarding them |183223 | `:wq`, `:x` | Save and quit; `:x` saves only if something changed |184224 | `:e name`, `:e!` | Open a sheet or import a file; refused with unsaved changes unless `:e!` |
@@ -186,9 +226,13 @@ The `:` line takes:
186226 187227 Completions for commands appear under the line as you type, drawn from188228 the same registry as the menus and palette: Tab puts the highlighted189-one on the line (Tab again for the next, Shift+Tab back), Up and Down190-move, and Enter runs the line, or the highlighted completion when the191-line isn't a command itself (`:fill d` Enter fills down).229+one on the line (Tab again for the next, Shift+Tab back), Ctrl+N and230+Ctrl+P move, and Enter runs the line, or the highlighted completion when231+the line isn't a command itself (`:fill d` Enter fills down). Up and232+Down go back and forth through the lines run before, as in vim: only233+those starting with what's typed (`:w` Up finds the last `:w` line).234+The history lasts until 012 closes, each `012 serve` session keeping its235+own.192236 193237 ## Mouse194238
M docs/sheets/charts.md +4 -1
M docs/terminal/README.md +1 -1
M docs/terminal/themes.md +4 -0
M internal/ui/charts.go +3 -1
M internal/ui/chartsel.go +19 -4
@@ -9,15 +9,17 @@ import (
99 )1010 1111 // chartSel is a selected chart. Arrows move it a cell at a time, Shift12-// and arrows resize it, Enter edits it and Del deletes it; the mouse13-// drags it or its corner. Other keys deselect it and act as usual.12+// and arrows resize it, Enter edits it, Del deletes it, and Space held13+// shows it across the grid (keyboard.go); the mouse drags it or its14+// corner. Other keys deselect it and act as usual.1415 type chartSel struct {1516 m *Model // the model it acts on1617 i int1718 drag int // chartDragNone, chartDragMove or chartDragResize1819 grabX int // where the chart was grabbed, relative to its corner1920 grabY int20- preview *sheet.Chart // the chart while being dragged21+ preview *sheet.Chart // the chart while being dragged, or held open22+ zoom bool // Space is held: preview is the chart across the grid2123 }2224 2325 func (s *chartSel) Indicator() string { return "CHART" }
@@ -41,6 +43,9 @@ func (s *chartSel) Key(k tea.KeyPressMsg) tea.Cmd {
4143 m.closeOverlay()4244 return nil4345 }46+ if s.holdKey(m, c, k) {47+ return nil48+ }4449 moved := c4550 switch k.String() {4651 case "esc":
@@ -100,6 +105,9 @@ func (s *chartSel) press(m *Model, x, y int) {
100105 101106 func (s *chartSel) Mouse(e overlay.MouseEvent) tea.Cmd {102107 m := s.m108+ if e.Kind != overlay.MouseMotion {109+ s.unzoom()110+ }103111 switch e.Kind {104112 case overlay.MousePress:105113 return s.pressAt(m, e)
@@ -211,6 +219,9 @@ func (s *chartSel) Status() (string, string) {
211219 return "", ""212220 }213221 desc := m.th.Muted.Render("Chart of " + c.Data.String())222+ if s.zoom {223+ return desc, m.th.KeyHints("Space", "let go to go back")224+ }214225 pairs := []string{"Enter", "edit", "Del", "delete", "Arrows", "move", "Shift+arrows", "resize", "Esc", "done"}215226 for {216227 keys := m.th.KeyHints(pairs...)
@@ -234,8 +245,12 @@ func (s *chartSel) ContextLine() (string, string) {
234245 if !ok {235246 return "", ""236247 }248+ how := " drag to move, drag the corner to resize"249+ if m.session.releases { // Space held enlarges the chart: keyboard.go250+ how += ", hold Space to zoom"251+ }237252 return m.th.Key.Render(c.Type.Title()+" chart") + m.th.Muted.Render(" of ") + c.Data.String() +238- m.th.Muted.Render(" drag to move, drag the corner to resize"), ""253+ m.th.Muted.Render(how), ""239254 }240255 241256 const (
M internal/ui/cmdline.go +21 -4
@@ -48,7 +48,7 @@ func (m *Model) runCmdLine(text string) (tea.Cmd, bool) {
4848 bang := strings.HasSuffix(word, "!")4949 switch strings.TrimSuffix(word, "!") {5050 case "w":51- return m.writeTo(arg), true51+ return m.writeTo(arg, bang), true5252 case "q":5353 if bang {5454 return m.exit(), true
@@ -59,10 +59,12 @@ func (m *Model) runCmdLine(text string) (tea.Cmd, bool) {
5959 return m.exit(), true6060 }6161 m.quitAfterSave = true62- return m.writeTo(arg), true62+ return m.writeTo(arg, bang), true6363 case "e":6464 return m.editFile(arg, bang), true6565 }66+ back := m.vim.back67+ m.jumped() // going to a cell or row is a jump '' comes back from6668 if n, err := strconv.Atoi(text); err == nil {6769 m.clearSelection()6870 m.cur.Row = m.visibleRow(clamp(n-1, 0, sheet.MaxRows-1))
@@ -71,6 +73,7 @@ func (m *Model) runCmdLine(text string) (tea.Cmd, bool) {
7173 if m.gotoText(text) {7274 return nil, true7375 }76+ m.vim.back = back7477 id, ok := commandNamed(text)7578 if !ok {7679 return nil, false
@@ -101,13 +104,21 @@ func commandNamed(text string) (string, bool) {
101104 // writeTo is :w. Without a name it saves, as File > Save; with one it102105 // saves as that name, as File > Save as, or downloads when the name is103106 // another format's (:w out.csv), as File > Download. It goes the menus'104-// ways, so files are named, checked and replaced the same.105-func (m *Model) writeTo(name string) tea.Cmd {107+// ways, so files are named, checked and replaced the same. Forced (:w!),108+// it answers their questions as Overwrite and Replace would: it writes109+// over the open file even if it changed on disk since it was opened, and110+// over a file of the name given.111+func (m *Model) writeTo(name string, force bool) tea.Cmd {106112 if name == "" {113+ if force && m.filename != "" {114+ return m.saveAs(m.filename, false)115+ }107116 return m.runCommand("file.save")108117 }109118 k, ok := fileio.KindOf(name)110119 switch {120+ case !ok && force:121+ return m.saveAs(withExt(name), false)111122 case !ok:112123 return m.saveAsFile(name)113124 case !k.CanExport():
@@ -116,6 +127,12 @@ func (m *Model) writeTo(name string) tea.Cmd {
116127 return nil117128 }118129 m.quitAfterSave = false // a download isn't the sheet's file130+ if force && !k.HasTables() {131+ if path, ok := m.path("download to", name); ok {132+ return m.download(name, path, k, sheet.Rect{}, "")133+ }134+ return nil135+ }119136 return m.downloadFile(k, sheet.Rect{}, name)120137 }121138
M internal/ui/cmdline/cmdline.go +26 -3
@@ -32,6 +32,8 @@ type Host interface {
3232 Run(text string) (tea.Cmd, bool)3333 // Fail shows an error.3434 Fail(msg string)35+ // History is the lines run this session, for Up and Down.36+ History() *History3537 }3638 3739 // Item is a completion: a file command or a registered command.
@@ -46,6 +48,7 @@ type Item struct {
4648 // FileWords are vim's file commands.4749 var FileWords = []Item{4850 {Word: "w", Title: "Write", Desc: "Save the sheet; :w name saves it as name, :w name.csv downloads it"},51+ {Word: "w!", Title: "Write anyway", Desc: "Save even if the file changed on disk since it was opened; :w! name replaces name"},4952 {Word: "q", Title: "Quit", Desc: "Close 012, asking about unsaved changes"},5053 {Word: "q!", Title: "Quit without saving", Desc: "Close 012, discarding unsaved changes"},5154 {Word: "wq", Title: "Write and quit", Desc: "Save the sheet, then close 012"},
@@ -62,6 +65,7 @@ type Line struct {
6265 overlay.List6366 shown []Item6467 tabbed bool // the text is a completion Tab put there; Tab again moves on68+ hist browse6569 }6670 6771 // New returns a command line with the edit line cleared.
@@ -122,20 +126,38 @@ func (c *Line) Key(k tea.KeyPressMsg) tea.Cmd {
122126 c.complete(1)123127 case key == "shift+tab":124128 c.complete(-1)125- case key == "up", key == "ctrl+p":129+ case key == "up", key == "down":130+ c.recall(key)131+ case key == "ctrl+p":126132 c.Move(-1, len(c.shown))127- case key == "down", key == "ctrl+n":133+ case key == "ctrl+n":128134 c.Move(1, len(c.shown))129135 default:130136 before := line.Text()131137 line.Key(k)132138 if line.Text() != before {139+ c.hist = browse{}133140 c.Changed()134141 }135142 }136143 return nil137144 }138145 146+// recall is Up and Down: an older or newer line from the history, among147+// those starting with what was typed.148+func (c *Line) recall(key string) {149+ d := -1150+ if key == "down" {151+ d = 1152+ }153+ text, ok := c.hist.step(c.h.History(), c.h.Line().Text(), d)154+ if !ok {155+ return156+ }157+ c.h.Line().Set(text)158+ c.Changed()159+}160+139161 // complete puts the highlighted completion on the line; pressed again,140162 // it moves on to the next one (d = 1) or the previous one (d = -1).141163 func (c *Line) complete(d int) {
@@ -162,6 +184,7 @@ func (c *Line) run() tea.Cmd {
162184 if text == "" {163185 return nil164186 }187+ c.h.History().Add(text)165188 if cmd, ok := c.h.Run(text); ok {166189 return cmd167190 }
@@ -183,7 +206,7 @@ func (c *Line) Cursor() (int, int) {
183206 }184207 185208 func (c *Line) Status() (string, string) {186- keys := c.h.Theme().KeyHints("Tab", "complete", "Enter", "run", "Esc", "cancel")209+ keys := c.h.Theme().KeyHints("Tab", "complete", "Up", "history", "Enter", "run", "Esc", "cancel")187210 if c.Sel >= len(c.shown) {188211 return "", keys189212 }
M internal/ui/cmdline/cmdline_test.go +35 -1
@@ -19,6 +19,7 @@ type fakeHost struct {
1919 closed int2020 ran []string2121 failed string22+ hist History2223 }2324 2425 func newHost() *fakeHost { return &fakeHost{th: theme.New(true)} }
@@ -28,6 +29,7 @@ func (f *fakeHost) Size() (width, height int) { return 80, 24 }
2829 func (f *fakeHost) Line() *lineedit.Line { return &f.line }2930 func (f *fakeHost) Close() { f.closed++ }3031 func (f *fakeHost) Fail(msg string) { f.failed = msg }32+func (f *fakeHost) History() *History { return &f.hist }3133 3234 func (f *fakeHost) Commands() []Item {3335 return []Item{
@@ -75,7 +77,7 @@ func TestCompletes(t *testing.T) {
7577 }7678 h.line.Clear()7779 typeText(c, "w")78- if got := words(c); !strings.HasPrefix(got, "w,wq,") {80+ if got := words(c); !strings.HasPrefix(got, "w,w!,wq,") {7981 t.Errorf("w completes %s", got)8082 }8183 typeText(c, " out.csv")
@@ -156,3 +158,35 @@ func TestLayoutStatusAndMouse(t *testing.T) {
156158 t.Error("a click outside didn't close the line")157159 }158160 }161+162+// Up and Down walk the history, among the lines starting with what was163+// typed; a line run goes to its end, once.164+func TestHistory(t *testing.T) {165+ h := newHost()166+ for _, l := range []string{"w", "B12", "edit.fill_down", "B12"} {167+ c := New(h)168+ typeText(c, l)169+ c.Key(tea.KeyPressMsg{Code: tea.KeyEnter})170+ }171+ if got := strings.Join(h.hist.Lines(), ","); got != "w,edit.fill_down,B12" {172+ t.Fatalf("history %s", got)173+ }174+ c := New(h)175+ up, down := tea.KeyPressMsg{Code: tea.KeyUp}, tea.KeyPressMsg{Code: tea.KeyDown}176+ c.Key(up)177+ c.Key(up)178+ if h.line.Text() != "edit.fill_down" || words(c) != "edit.fill_down" {179+ t.Errorf("Up Up: %q, completions %s", h.line.Text(), words(c))180+ }181+ c.Key(down)182+ c.Key(down)183+ if h.line.Text() != "" {184+ t.Errorf("Down Down: %q", h.line.Text())185+ }186+ typeText(c, "e")187+ c.Key(up)188+ c.Key(up)189+ if h.line.Text() != "edit.fill_down" {190+ t.Errorf("e Up Up: %q", h.line.Text())191+ }192+}
A internal/ui/cmdline/history.go +61 -0
@@ -0,0 +1,61 @@
1+package cmdline2+3+import "strings"4+5+// maxHistory is how many lines the history keeps.6+const maxHistory = 1007+8+// History is the lines run on the command line, oldest first. It lasts9+// as long as the session: each `012 serve` session has its own.10+type History struct {11+ lines []string12+}13+14+// Add puts line at the end of the history, moving it there if it's15+// already in it.16+func (h *History) Add(line string) {17+ if line == "" {18+ return19+ }20+ for i, l := range h.lines {21+ if l == line {22+ h.lines = append(h.lines[:i], h.lines[i+1:]...)23+ break24+ }25+ }26+ h.lines = append(h.lines, line)27+ if len(h.lines) > maxHistory {28+ h.lines = h.lines[len(h.lines)-maxHistory:]29+ }30+}31+32+// Lines are the lines in the history, oldest first.33+func (h *History) Lines() []string { return h.lines }34+35+// browse is a walk through the history with Up and Down, as vim's: only36+// lines starting with what was typed before the first Up.37+type browse struct {38+ on bool39+ at int // the line shown, len(lines) for what was typed40+ prefix string // what was typed41+}42+43+// step moves d lines through h (-1 older, 1 newer) from what's shown,44+// skipping lines that don't start with the prefix, and returns the text45+// to show and whether there was a line to move to.46+func (b *browse) step(h *History, typed string, d int) (string, bool) {47+ if !b.on {48+ *b = browse{on: true, at: len(h.lines), prefix: typed}49+ }50+ for i := b.at + d; i >= 0 && i <= len(h.lines); i += d {51+ if i == len(h.lines) {52+ b.at = i53+ return b.prefix, true54+ }55+ if strings.HasPrefix(h.lines[i], b.prefix) {56+ b.at = i57+ return h.lines[i], true58+ }59+ }60+ return "", false61+}
M internal/ui/file.go +1 -1
M internal/ui/hosts.go +2 -0
A internal/ui/keyboard.go +110 -0
@@ -0,0 +1,110 @@
1+package ui2+3+import (4+ tea "charm.land/bubbletea/v2"5+6+ "github.com/FelineStateMachine/012/internal/sheet"7+ "github.com/FelineStateMachine/012/internal/ui/cmdline"8+)9+10+// The kitty keyboard protocol. Bubble Tea asks every terminal for its11+// first level, which tells apart the keys legacy encodings merge:12+// Shift+Enter from Enter (it accepts an entry and moves up, as in13+// Sheets), Ctrl+I from Tab (italic), Ctrl+M from Enter, and Alt+letter14+// from Esc followed by the letter. 012 also asks for key events15+// (View.KeyboardEnhancements.ReportEventTypes), which say when a key is16+// let go; holding a key then previews something until it is released:17+// Space held on a selected chart shows it across the grid, and in the18+// theme picker hides the list to show the sheet in the highlighted19+// theme. A terminal without the protocol sends neither, and those keys20+// do what they do there: Shift+Enter is Enter, Ctrl+I is Tab, and Space21+// is typed.22+23+// session is what lasts as long as the program, across files opened.24+type session struct {25+ history cmdline.History // lines run on the : line26+ // releases is whether the terminal reports key releases, so a key27+ // can be held.28+ releases bool29+}30+31+// keyboard notes what the terminal said of the keyboard protocol.32+func (m *Model) keyboard(msg tea.KeyboardEnhancementsMsg) {33+ m.session.releases = msg.SupportsEventTypes()34+}35+36+// releaser is an overlay that acts when a key it holds is let go.37+type releaser interface {38+ Release(k tea.KeyReleaseMsg) tea.Cmd39+}40+41+// held reports whether an open overlay is waiting for a release.42+func (m *Model) held() bool {43+ _, ok := m.overlay.(releaser)44+ return ok45+}46+47+// keyReleased passes a key's release to the overlay holding it.48+func (m *Model) keyReleased(k tea.KeyReleaseMsg) tea.Cmd {49+ if r, ok := m.overlay.(releaser); ok {50+ return r.Release(k)51+ }52+ return nil53+}54+55+// holdKey starts holding Space on a selected chart, on a terminal that56+// reports releases, and reports whether it took k. Repeats while it's57+// held do nothing; any other key lets go first.58+func (s *chartSel) holdKey(m *Model, c sheet.Chart, k tea.KeyPressMsg) bool {59+ if k.String() != "space" || !m.session.releases {60+ s.unzoom()61+ return false62+ }63+ if !s.zoom {64+ z := m.zoomed(c)65+ s.preview, s.zoom = &z, true66+ }67+ return true68+}69+70+// Release ends a hold of Space.71+func (s *chartSel) Release(k tea.KeyReleaseMsg) tea.Cmd {72+ if k.String() == "space" {73+ s.unzoom()74+ }75+ return nil76+}77+78+func (s *chartSel) unzoom() {79+ if s.zoom {80+ s.preview, s.zoom = nil, false81+ }82+}83+84+// zoomed is chart c spread over the scrolling part of the grid.85+func (m *Model) zoomed(c sheet.Chart) sheet.Chart {86+ c.At = sheet.Addr{Col: m.left, Row: m.top}87+ x, y := m.chartScreen(c)88+ c.W = min(max(m.width-x, sheet.MinChartW), sheet.MaxChartW)89+ c.H = min(max(gridTop+m.visibleRows()-y, sheet.MinChartH), sheet.MaxChartH)90+ return c91+}92+93+// chartOrder is the order charts are drawn in, bottom first: as they94+// are, but with a chart held open on top.95+func (m *Model) chartOrder(n int) []int {96+ order := make([]int, 0, n)97+ z := -198+ if s, ok := m.overlay.(*chartSel); ok && s.zoom {99+ z = s.i100+ }101+ for i := range n {102+ if i != z {103+ order = append(order, i)104+ }105+ }106+ if z >= 0 && z < n {107+ order = append(order, z)108+ }109+ return order110+}
A internal/ui/keyboard_test.go +123 -0
@@ -0,0 +1,123 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// keyEvents is the terminal's answer when it reports key releases.14+var keyEvents = tea.KeyboardEnhancementsMsg{Flags: 1 | ansi.KittyReportEventTypes}15+16+func space() tea.Key { return tea.Key{Code: tea.KeySpace, Text: " "} }17+18+// The view asks for key releases; Shift+Enter accepts an entry and moves19+// up, and Ctrl+I, which only the protocol tells from Tab, is italic.20+func TestKeyboardProtocol(t *testing.T) {21+ m := newModel()22+ if !m.View().KeyboardEnhancements.ReportEventTypes {23+ t.Error("the view doesn't ask for key releases")24+ }25+ press(t, m, "<down>", "<down>", "5", "<shift+enter>")26+ if input(m, "A3") != "5" || m.cur != addr("A2") {27+ t.Errorf("Shift+Enter: A3 %q at %v", input(m, "A3"), m.cur)28+ }29+ press(t, m, "<down>", "<ctrl+i>")30+ if c := m.sheet.Cell(addr("A3")); m.cur != addr("A3") || c == nil || !c.Style.Italic {31+ t.Errorf("Ctrl+I: at %v", m.cur)32+ }33+ press(t, m, "<tab>")34+ if m.cur != addr("B3") {35+ t.Errorf("Tab: at %v", m.cur)36+ }37+ // A release with nothing held does nothing.38+ if _, cmd := m.Update(tea.KeyReleaseMsg{Code: 'x', Text: "x"}); cmd != nil || m.mode != modeReady {39+ t.Errorf("release: mode %v", m.mode)40+ }41+}42+43+// Holding Space on a selected chart shows it across the grid until44+// Space is let go, on a terminal that reports releases.45+func TestHoldSpaceEnlargesChart(t *testing.T) {46+ m := wideModel()47+ chartTable(t, m)48+ m.runCommand("insert.chart")49+ press(t, m, "<enter>")50+ c := m.sheet.Charts()[0]51+ if strings.Contains(line(m, contextLine), "hold") {52+ t.Errorf("hint without releases: %q", line(m, contextLine))53+ }54+ send(m, keyEvents)55+ if !strings.Contains(line(m, contextLine), "hold Space to zoom") {56+ t.Errorf("context: %q", line(m, contextLine))57+ }58+ send(m, tea.KeyPressMsg(space()))59+ z := m.displayCharts()[0]60+ if z.W <= c.W || z.H <= c.H || z.At != (sheet.Addr{Col: m.left, Row: m.top}) {61+ t.Fatalf("held: %+v", z)62+ }63+ if !strings.Contains(line(m, m.height-1), "let go") {64+ t.Errorf("held status: %q", line(m, m.height-1))65+ }66+ repeat := space()67+ repeat.IsRepeat = true68+ send(m, tea.KeyPressMsg(repeat))69+ send(m, tea.KeyReleaseMsg(space()))70+ if got := m.displayCharts()[0]; got.W != c.W || got.At != c.At || m.sheet.Charts()[0].W != c.W {71+ t.Errorf("after release: shown %+v, stored %+v", got, m.sheet.Charts()[0])72+ }73+ if _, ok := m.overlay.(*chartSel); !ok {74+ t.Errorf("release left %T", m.overlay)75+ }76+ // Any other key lets go first.77+ send(m, tea.KeyPressMsg(space()))78+ press(t, m, "<right>")79+ if got := m.sheet.Charts()[0]; got.W != c.W || got.At.Col != c.At.Col+1 {80+ t.Errorf("Right while held: %+v", got)81+ }82+}83+84+// Without releases, Space on a selected chart deselects it and starts85+// an entry, as it always has.86+func TestSpaceOnChartWithoutReleases(t *testing.T) {87+ m := wideModel()88+ chartTable(t, m)89+ m.runCommand("insert.chart")90+ press(t, m, "<enter>")91+ send(m, tea.KeyPressMsg(space()))92+ if _, ok := m.overlay.(*chartSel); ok || m.mode != modeEnter {93+ t.Errorf("Space: %T mode %v", m.overlay, m.mode)94+ }95+}96+97+// Holding Space in the theme picker hides the list, showing the sheet98+// in the highlighted theme, until it's let go.99+func TestHoldSpaceInThemePicker(t *testing.T) {100+ m, _, _ := configured(t, "theme = Nord\n")101+ send(m, keyEvents)102+ press(t, m, "<alt+f>")103+ openSettingsItem(t, m, "Theme")104+ press(t, m, "<down>")105+ name := m.th.Name106+ if !strings.Contains(screen(m), "Type a name") || !strings.Contains(line(m, m.height-1), "hold to peek") {107+ t.Fatalf("picker: %q\n%s", line(m, m.height-1), screen(m))108+ }109+ send(m, tea.KeyPressMsg(space()))110+ if s := screen(m); strings.Contains(s, "Type a name") || m.th.Name != name {111+ t.Fatalf("held: theme %q\n%s", m.th.Name, s)112+ }113+ send(m, tea.KeyReleaseMsg(space()))114+ if s := screen(m); !strings.Contains(s, "Type a name") || m.overlay == nil {115+ t.Fatalf("released:\n%s", s)116+ }117+ // With a search typed, Space is typed into it.118+ press(t, m, "no")119+ send(m, tea.KeyPressMsg(space()))120+ if m.line.Text() != "no " {121+ t.Errorf("search: %q", m.line.Text())122+ }123+}
M internal/ui/model.go +14 -0
@@ -111,6 +111,7 @@ type Model struct {
111111 jev *jevRunner // answers JEV functions; nil without an API key: jev.go112112 term terminal // what the terminal supports: graphics.go113113 prefs prefs // the settings in effect and the theme chosen: prefs.go114+ session session // what outlasts the file open: the : history, the keyboard: keyboard.go114115 115116 vim vimState // a vim key sequence in progress: vim.go116117 rec *recorder // a macro being recorded: macrorec.go
@@ -149,6 +150,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
149150 if _, key := msg.(tea.KeyPressMsg); key && m.keyAt.IsZero() && telemetry.Enabled() {150151 m.keyAt = time.Now()151152 }153+ if _, release := msg.(tea.KeyReleaseMsg); release && !m.held() {154+ return m, nil // a release matters only to a key held: keyboard.go155+ }152156 if cmd, ok := m.importing(msg); ok {153157 return m, cmd154158 }
@@ -160,6 +164,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
160164 }161165 before, beforeMode := *m.focus(), m.mode162166 state := m.beginUpdate(msg)167+ m.vimCapture(msg)163168 var cmd tea.Cmd164169 if mouse, ok := msg.(tea.MouseMsg); ok {165170 var handled bool
@@ -210,6 +215,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
210215 cmd = m.finishMacro(msg.r, msg.done)211216 case macroEditedMsg:212217 m.macroEdited(msg)218+ case tea.KeyReleaseMsg:219+ cmd = m.keyReleased(msg)220+ case tea.KeyboardEnhancementsMsg:221+ m.keyboard(msg)222+ case tea.ClipboardMsg:223+ if m.vim.clipPaste {224+ m.pasteClipboard(msg.Content)225+ }213226 default:214227 if !m.handlePrefs(msg) {215228 cmd = m.term.handle(msg)
@@ -217,6 +230,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
217230 }218231 // Scroll only when the focus moves, so the mouse wheel can look around219232 // without the view snapping back, as in Sheets.233+ m.vimSettle()220234 m.endUpdate(state)221235 if f := *m.focus(); f != before || m.mode != beforeMode {222236 m.scrollTo(f)
M internal/ui/panel.go +2 -0
M internal/ui/themepicker/themepicker.go +54 -0
@@ -28,6 +28,9 @@ type Host interface {
2828 Preview(name string)2929 // Keep closes the picker and makes name the theme, saving it.3030 Keep(name string)31+ // KeyReleases reports whether the terminal says when a key is let32+ // go (the kitty keyboard protocol), so Space can be held to peek.33+ KeyReleases() bool3134 }3235 3336 // Picker is the open theme picker.
@@ -36,6 +39,7 @@ type Picker struct {
3639 h Host3740 open bool // not yet closed or kept3841 shown string // the theme being previewed42+ peek bool // Space is held: the list is hidden to show the sheet3943 }4044 4145 // New returns a picker of every theme with the current one highlighted
@@ -120,7 +124,57 @@ func (c closing) Close() {
120124 c.Host.Close()121125 }122126 127+// Holding Space, on a terminal that reports key releases, hides the list128+// so the whole sheet shows in the highlighted theme; letting go brings129+// the list back. It needs an empty search, so a space can still be typed130+// into one. Elsewhere Space is typed, as any other key.131+132+// holdsSpace reports whether k, a press of Space, starts or continues a133+// hold rather than typing.134+func (tp *Picker) holdsSpace(k tea.KeyPressMsg) bool {135+ return k.String() == "space" && tp.h.KeyReleases() && (tp.peek || tp.h.Line().Text() == "")136+}137+138+// Release ends a hold of Space.139+func (tp *Picker) Release(k tea.KeyReleaseMsg) tea.Cmd {140+ if k.String() == "space" {141+ tp.peek = false142+ }143+ return nil144+}145+146+func (tp *Picker) Layout() []overlay.Box {147+ if tp.peek {148+ return nil149+ }150+ return tp.Picker.Layout()151+}152+153+func (tp *Picker) Cursor() (int, int) {154+ if tp.peek {155+ return -1, 0156+ }157+ return tp.Picker.Cursor()158+}159+160+func (tp *Picker) Status() (string, string) {161+ desc, keys := tp.Picker.Status()162+ th := tp.h.Theme()163+ switch {164+ case tp.peek:165+ return "Theme " + tp.shown, th.KeyHints("Space", "let go to go back")166+ case tp.h.KeyReleases() && tp.h.Line().Text() == "":167+ keys = th.KeyHints("Space", "hold to peek") + " " + keys168+ }169+ return desc, keys170+}171+123172 func (tp *Picker) Key(k tea.KeyPressMsg) tea.Cmd {173+ if tp.holdsSpace(k) {174+ tp.peek = true175+ return nil176+ }177+ tp.peek = false124178 if k.String() == "esc" {125179 tp.h.Preview("")126180 }
M internal/ui/themepicker/themepicker_test.go +2 -0
M internal/ui/vim.go +51 -8
@@ -18,6 +18,8 @@ import (
1818 // vim doesn't claim keep their Sheets meaning, so arrows, Ctrl+S, Alt1919 // menus and F-keys work as always. Every action but moving and selecting2020 // runs a registered command; the bindings are tables in vimkeys.go.21+// Registers are in vimregs.go, marks in vimmarks.go and . in22+// vimrepeat.go.2123 2224 // vimState is where a vim key sequence has got to.2325 type vimState struct {
@@ -26,6 +28,18 @@ type vimState struct {
2628 visual visualKind2729 // rows is the clip dd or yy last copied: pasting it inserts rows.2830 rows *sheet.Clip31+ reg rune // the register named with " for the next operator, 0 for none32+ // typed are the keys of the sequence so far, after any count and33+ // register, kept for . to repeat.34+ typed []tea.Msg35+ regs map[rune]vimRegister // named, numbered and small-delete registers36+ marks map[rune]mark37+ back *mark // where the last jump left from, for '' and ``38+ // last is the change . repeats; inserting is one still taking the39+ // keys of the entry it started (i, o, cc).40+ last, inserting *vimChange41+ replaying bool // . is replaying last42+ clipPaste bool // "+p asked the terminal for the clipboard2943 }3044 3145 type visualKind int
@@ -42,14 +56,25 @@ const maxCount = 9999
4256 // n is the count, 1 when none was typed.4357 func (v *vimState) n() int { return max(v.count, 1) }4458 45-func (v *vimState) reset() { v.count, v.keys = 0, "" }59+func (v *vimState) reset() { v.count, v.keys, v.reg, v.typed = 0, "", 0, nil }4660 47-// pending is what's been typed of an unfinished sequence, e.g. "3d".61+// pending is what's been typed of an unfinished sequence, e.g. "3d" or62+// "a2y.4863 func (v *vimState) pending() string {49- if v.count == 0 {50- return v.keys64+ p := v.keys65+ if v.count > 0 {66+ p = strconv.Itoa(v.count) + p5167 }52- return strconv.Itoa(v.count) + v.keys68+ if v.reg != 0 {69+ p = `"` + string(v.reg) + p70+ }71+ return p72+}73+74+// argPrefixes are the keys whose next key is a name rather than a75+// command: a register after ", a mark after m, ' and `.76+var argPrefixes = map[string]string{77+ `"`: "register", "m": "set a mark", "'": "go to a mark's row", "`": "go to a mark",5378 }5479 5580 // vimKey names a key press as the vim tables do: the character typed,
@@ -83,12 +108,23 @@ func (m *Model) vimKeyPress(k tea.KeyPressMsg) (tea.Cmd, bool) {
83108 if key == "esc" {84109 return m.vimEscape()85110 }111+ if _, ok := argPrefixes[v.keys]; ok {112+ return m.vimArgKey(key), true113+ }114+ if _, ok := argPrefixes[key]; ok && v.keys == "" {115+ v.keys = key116+ return nil, true117+ }86118 if d := digit(key); v.keys == "" && (d > 0 || d == 0 && v.count > 0) {87119 v.count = min(v.count*10+d, maxCount)88120 return nil, true89121 }122+ v.typed = append(v.typed, k)90123 seq := v.keys + key91124 if mv, ok := vimMotions[seq]; ok {125+ if jumpMotions[seq] {126+ m.jumped()127+ }92128 m.vimMove(mv)93129 v.reset()94130 return nil, true
@@ -98,7 +134,7 @@ func (m *Model) vimKeyPress(k tea.KeyPressMsg) (tea.Cmd, bool) {
98134 table = vimVisual99135 }100136 if b, ok := table[seq]; ok {101- cmd := b.run(m, v.n())137+ cmd := m.runBinding(b)102138 v.reset()103139 return cmd, true104140 }
@@ -266,8 +302,15 @@ func (m *Model) vimLine() string {
266302 // vimNext lists the keys that finish the sequence begun, and what they267303 // do, as pairs for KeyHints: after d, "d" and "cut rows".268304 func (m *Model) vimNext() []string {269- if m.vim.keys == "" {270- return nil // just a count: anything may follow305+ switch m.vim.keys {306+ case "":307+ return nil // just a count or a register: anything may follow308+ case `"`:309+ return []string{"a-z", "named", "0", "last copy", "1-9", "rows deleted", "-", "cells deleted", "+", "system clipboard"}310+ case "m":311+ return []string{"a-z", "mark the cell"}312+ case "'", "`":313+ return []string{"a-z", argPrefixes[m.vim.keys], m.vim.keys, "back"}271314 }272315 table := vimNormal273316 if m.visual() != visualNone {
M internal/ui/vimkeys.go +105 -28
@@ -26,6 +26,32 @@ type vimBinding struct {
2626 // do, when set, acts instead of running id: for keys that change2727 // mode or choose between commands.2828 do func(m *Model, n int) tea.Cmd29+ // change marks a change that . repeats; yank a copy that goes to30+ // the registers ("0), and cut a delete that does ("1 to "9 or "-).31+ change, yank, cut bool32+ jump bool // a jump that '' comes back from (n, N, /)33+}34+35+// runBinding carries out b with the count typed, remembering a change36+// for . and what it copied in the registers.37+func (m *Model) runBinding(b vimBinding) tea.Cmd {38+ v := &m.vim39+ var c *vimChange40+ if b.change && !v.replaying {41+ c = &vimChange{keys: v.typed, count: v.count, reg: v.reg}42+ }43+ if b.jump {44+ m.jumped()45+ }46+ copied := m.copied.clip47+ cmd := b.run(m, v.n())48+ if (b.yank || b.cut) && m.copied.clip != copied {49+ m.storeRegister(b.cut)50+ }51+ if c != nil {52+ m.vimChanged(c)53+ }54+ return cmd2955 }3056 3157 // run carries out the binding with count n.
@@ -62,27 +88,31 @@ func (b vimBinding) run(m *Model, n int) tea.Cmd {
6288 // vimNormal binds key sequences in NORMAL mode, in the order help lists6389 // them.6490 var vimNormal = map[string]vimBinding{65- "i": {id: "edit", label: "Edit the cell, caret at the start", do: func(m *Model, _ int) tea.Cmd {91+ "i": {id: "edit", label: "Edit the cell, caret at the start", change: true, do: func(m *Model, _ int) tea.Cmd {6692 cmd := m.runCommand("edit")6793 m.line.Pos = 06894 return cmd6995 }},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},96+ "a": {id: "edit", span: spanOnce, change: true},97+ "=": {label: "Start a formula", change: true, do: func(m *Model, _ int) tea.Cmd { m.startEntry(modeEnter, "="); return nil }},98+ "o": {id: "row.open_below", span: spanOnce, change: true},99+ "O": {id: "row.open_above", span: spanOnce, change: true},100+ "x": {id: "clear", label: "Clear the cell, keeping a copy", change: true, cut: true, do: deleteCells},101+ "s": {label: "Clear the cell and start typing", change: true, cut: true, do: substitute},102+ "dd": {id: "row.cut", span: spanRows, change: true, cut: true},103+ "cc": {label: "Clear the row and start typing", change: true, cut: true, do: changeRows},104+ "S": {label: "Clear the row and start typing", change: true, cut: true, do: changeRows},105+ "yy": {id: "row.yank", span: spanRows, yank: true},106+ "p": {id: "row.paste_below", label: "Paste rows below, or cells here", change: true, do: putAfter},107+ "P": {id: "row.paste_above", label: "Paste rows above, or cells here", change: true, do: putBefore},108+ ".": {label: "Repeat the last change"}, // do is repeatChange, set in vimrepeat.go79109 "u": {id: "edit.undo"},80110 "ctrl+r": {id: "edit.redo"},81111 "v": {label: "Select cells (VISUAL)", do: func(m *Model, _ int) tea.Cmd { m.startVisual(visualCells); return nil }},82112 "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"},113+ "/": {id: "edit.find", span: spanOnce, jump: true},114+ "n": {id: "edit.find_next", jump: true},115+ "N": {id: "edit.find_prev", jump: true},86116 ":": {id: "vim.command", span: spanOnce},87117 "gt": {id: "sheet.next"},88118 "gT": {id: "sheet.prev"},
@@ -90,9 +120,9 @@ var vimNormal = map[string]vimBinding{
90120 91121 // vimVisual binds keys in VISUAL mode; motions extend the selection.92122 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 {123+ "d": {label: "Delete the selection, keeping a copy", cut: true, do: visualDelete},124+ "x": {label: "Delete the selection, keeping a copy", cut: true, do: visualDelete},125+ "y": {label: "Copy the selection", yank: true, do: func(m *Model, _ int) tea.Cmd {96126 id := "edit.copy"97127 if m.vim.visual == visualRows {98128 id = "row.yank"
@@ -101,7 +131,7 @@ var vimVisual = map[string]vimBinding{
101131 }},102132 "p": {id: "edit.paste", label: "Paste over the selection", do: func(m *Model, _ int) tea.Cmd {103133 m.vim.visual = visualNone104- return m.runCommand("edit.paste")134+ return m.fromRegister(func() tea.Cmd { return m.runCommand("edit.paste") })105135 }},106136 "o": {label: "Go to the other corner", do: func(m *Model, _ int) tea.Cmd {107137 m.cur, m.ext = m.ext, m.cur
@@ -113,7 +143,7 @@ var vimVisual = map[string]vimBinding{
113143 }114144 115145 // 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"}146+var vimHelpOrder = []string{"i", "a", "=", "o", "O", "x", "s", "dd", "cc", "S", "yy", "p", "P", ".", "u", "ctrl+r", "v", "V", "/", "n", "N", ":", "gt", "gT"}117147 118148 // visualDelete deletes the selection, keeping a copy to paste: whole119149 // rows with V, the cells' contents with v.
@@ -126,6 +156,45 @@ func visualDelete(m *Model, _ int) tea.Cmd {
126156 return m.endVisual(cmd)127157 }128158 159+// deleteCells is x: clear n cells from the active one rightwards,160+// keeping a copy to paste, as vim's x keeps the characters it deletes.161+func deleteCells(m *Model, n int) tea.Cmd {162+ if c := commands["clear"]; !c.available(m) {163+ m.note = c.title + " isn't available now"164+ return nil165+ }166+ m.spanCols(n)167+ cmd := m.runCommand("edit.copy")168+ m.runCommand("clear")169+ if m.mode == modeReady {170+ m.clearSelection()171+ }172+ return cmd173+}174+175+// substitute is s: x, then an entry in the cleared cell.176+func substitute(m *Model, n int) tea.Cmd {177+ cmd := deleteCells(m, n)178+ if m.mode == modeReady {179+ m.startEntry(modeEnter, "")180+ }181+ return cmd182+}183+184+// changeRows is cc and S: clear the contents of n rows from the active185+// cell's, keeping a copy of them to paste as rows, and start an entry in186+// the active cell. The rows and their formats stay.187+func changeRows(m *Model, n int) tea.Cmd {188+ m.spanRows(n)189+ cmd := m.runCommand("row.yank")190+ m.runCommand("clear")191+ if m.mode == modeReady {192+ m.clearSelection()193+ m.startEntry(modeEnter, "")194+ }195+ return cmd196+}197+129198 // endVisual goes back to NORMAL mode after an operator.130199 func (m *Model) endVisual(cmd tea.Cmd) tea.Cmd {131200 m.vim.visual = visualNone
@@ -147,20 +216,24 @@ func (m *Model) switchVisual(kind visualKind) tea.Cmd {
147216 148217 // putAfter is p: rows copied with yy or dd go in as new rows below the149218 // active one; cells copied any other way are pasted at the active cell.219+// A register named with " is pasted rather than the clipboard.150220 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-221+ return m.fromRegister(func() tea.Cmd {222+ if !m.rowsCopied() {223+ return m.runCommand("edit.paste")224+ }225+ return m.runCommand("row.paste_below") // it reads the count, n226+ })156227 }157228 158229 // putBefore is P: as p, but rows go in above the active one.159230 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")231+ return m.fromRegister(func() tea.Cmd {232+ if !m.rowsCopied() {233+ return m.runCommand("edit.paste")234+ }235+ return m.runCommand("row.paste_above")236+ })164237 }165238 166239 // vimKeysFor returns the vim keys bound to a command, NORMAL mode first.
@@ -213,7 +286,11 @@ func vimHelpRows() []helpRow {
213286 {keys: []string{"5j", "3dd"}, action: "A count repeats a move or sizes an operator"},214287 }215288 rows = append(rows, bindingRows(vimNormal, vimHelpOrder)...)216- rows = append(rows, helpRow{heading: "Vim keys: VISUAL"},289+ rows = append(rows,290+ helpRow{keys: []string{`"a`, `"0`, `"+`}, action: "Name the register the next operator uses"},291+ helpRow{keys: []string{"ma", "`a", "'a"}, action: "Mark the cell; go to the mark, or its row"},292+ helpRow{keys: []string{"''"}, action: "Back to where the last jump left from"},293+ helpRow{heading: "Vim keys: VISUAL"},217294 helpRow{keys: []string{"Motions"}, action: "Extend the selection"})218295 rows = append(rows, bindingRows(vimVisual, vimVisualOrder)...)219296 return append(rows, helpRow{keys: []string{"Esc"}, action: "Back to NORMAL"})
A internal/ui/vimmarks.go +117 -0
@@ -0,0 +1,117 @@
1+package ui2+3+import (4+ "unicode/utf8"5+6+ tea "charm.land/bubbletea/v2"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// Vim marks: m{a-z} marks the active cell, `{a-z} goes back to it (on12+// its sheet) and '{a-z} to its row, in column A. Jumps (gg, G, H, M, L,13+// /, n, N, a mark, a cell or row from the : line) remember where they14+// left from, and '' or `` goes back there. A mark stays on its address:15+// rows inserted above it don't move it. Marks live with the workbook.16+17+// mark is a marked cell.18+type mark struct {19+ sheet *sheet.Sheet20+ at sheet.Addr21+}22+23+// jumpMotions are the motions that count as jumps.24+var jumpMotions = map[string]bool{"gg": true, "G": true, "H": true, "M": true, "L": true}25+26+// jumped remembers the active cell before a jump, to go back to with a27+// quote typed twice.28+func (m *Model) jumped() {29+ if m.visual() == visualNone {30+ m.vim.back = &mark{sheet: m.sheet, at: m.cur}31+ }32+}33+34+// vimArgKey finishes a sequence that takes a name: the register after ",35+// the mark after m, ' or `.36+func (m *Model) vimArgKey(key string) tea.Cmd {37+ v := &m.vim38+ prefix := v.keys39+ v.keys = ""40+ r, size := utf8.DecodeRuneInString(key)41+ if size != len(key) {42+ r = 0 // a named key, such as "up"43+ }44+ switch prefix {45+ case `"`:46+ if !isRegister(r) {47+ m.note = `No register "` + key + `: a-z, 0-9, -, " and +`48+ v.reset()49+ return nil50+ }51+ v.reg = r // the count typed before it stays, and more may follow52+ case "m":53+ v.reset()54+ m.setMark(r, key)55+ default:56+ v.reset()57+ m.jumpToMark(r, key, prefix == "`")58+ }59+ return nil60+}61+62+// setMark is m{a-z}.63+func (m *Model) setMark(r rune, key string) {64+ if r < 'a' || r > 'z' {65+ m.note = "Marks are a to z, not " + key66+ return67+ }68+ if m.vim.marks == nil {69+ m.vim.marks = map[rune]mark{}70+ }71+ m.vim.marks[r] = mark{sheet: m.sheet, at: m.cur}72+ m.note = "Mark " + key + " at " + m.cur.String() + "; `" + key + " comes back"73+}74+75+// jumpToMark is `{a-z} (the cell) and '{a-z} (its row, in column A);76+// after ' or `, back to where the last jump left from. In VISUAL mode it77+// moves the selection's corner, on the sheet shown.78+func (m *Model) jumpToMark(r rune, key string, exact bool) {79+ var to mark80+ switch mk, ok := m.vim.marks[r]; {81+ case r == '\'' || r == '`':82+ if m.vim.back == nil {83+ m.note = "No jump to go back from yet"84+ return85+ }86+ to = *m.vim.back87+ case ok:88+ to = mk89+ case r >= 'a' && r <= 'z':90+ m.note = "Mark " + key + " isn't set: m" + key + " sets it"91+ return92+ default:93+ m.note = "Marks are a to z, not " + key94+ return95+ }96+ if !to.sheet.Live() {97+ m.note = "Mark " + key + " was on a deleted sheet"98+ return99+ }100+ if !exact {101+ to.at.Col = 0102+ }103+ if m.visual() != visualNone {104+ if to.sheet != m.sheet {105+ m.note = "Mark " + key + " is on " + to.sheet.Name()106+ return107+ }108+ m.ext = clampAddr(to.at)109+ m.ext.Row = m.visibleRow(m.ext.Row)110+ return111+ }112+ m.jumped()113+ m.showSheet(to.sheet)114+ m.clearSelection()115+ m.cur = clampAddr(to.at)116+ m.cur.Row = m.visibleRow(m.cur.Row)117+}
A internal/ui/vimmore_test.go +303 -0
@@ -0,0 +1,303 @@
1+package ui2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+ "time"9+10+ tea "charm.land/bubbletea/v2"11+)12+13+// . repeats the last change with its count, or the count typed before14+// it; an edit begun with i, a, o or = repeats with what was typed.15+func TestVimRepeat(t *testing.T) {16+ m := vimModel(t)17+ press(t, m, ".")18+ if !strings.Contains(line(m, contextLine), "No change to repeat") {19+ t.Errorf(". first: %q", line(m, contextLine))20+ }21+ press(t, m, "x", "j", ".")22+ if input(m, "A1") != "" || input(m, "A2") != "" || input(m, "A3") != "A3" {23+ t.Fatalf("x j .: A1 %q A2 %q", input(m, "A1"), input(m, "A2"))24+ }25+ // A count before . replaces the change's own.26+ press(t, m, "l", "2x", "j", ".")27+ if input(m, "B3") != "" || input(m, "C3") != "" {28+ t.Fatalf("2x .: B3 %q C3 %q", input(m, "B3"), input(m, "C3"))29+ }30+ press(t, m, "j", "h", "3.")31+ if input(m, "A4") != "" || input(m, "B4") != "" || input(m, "C4") != "" {32+ t.Fatalf("3.: %q %q %q", input(m, "A4"), input(m, "B4"), input(m, "C4"))33+ }34+ // An edit repeats with its text and the key that accepted it.35+ press(t, m, "gg", "0", "a", "!", "<enter>")36+ if input(m, "A1") != "!" || m.cur != addr("A2") {37+ t.Fatalf("a!: A1 %q at %v", input(m, "A1"), m.cur)38+ }39+ press(t, m, "j", "j", "j", ".")40+ if input(m, "A5") != "A5!" || m.cur != addr("A6") {41+ t.Fatalf("a . : A5 %q at %v", input(m, "A5"), m.cur)42+ }43+ // o repeats the row it opened and what was typed in it.44+ press(t, m, "G", "o", "new", "<enter>")45+ press(t, m, ".")46+ if input(m, "A7") != "new" || input(m, "A8") != "" || input(m, "A9") != "new" {47+ t.Fatalf("o .: A7 %q A9 %q", input(m, "A7"), input(m, "A9"))48+ }49+ // dd repeats with its count; moves and copies don't replace the change.50+ press(t, m, "gg", "2dd", "yy", "j", ".")51+ if input(m, "A1") != "A3" || input(m, "A2") != "A6" {52+ t.Fatalf("2dd yy .: A1 %q A2 %q", input(m, "A1"), input(m, "A2"))53+ }54+ // Each change undoes on its own.55+ press(t, m, "u")56+ if input(m, "A3") != "A5!" {57+ t.Errorf("u after .: A3 %q", input(m, "A3"))58+ }59+}60+61+// Registers: "a keeps its own copy, "0 the last copy, "1 to "9 deleted62+// rows and "- deleted cells, while the clipboard follows every copy.63+func TestVimRegisters(t *testing.T) {64+ m := vimModel(t)65+ press(t, m, `"ayy`)66+ if r := m.vim.regs['a']; r.clip == nil || !r.rows {67+ t.Fatalf(`"ayy: %+v`, r)68+ }69+ press(t, m, "j", "yy", "j", "dd") // "0 row 2, "1 row 3; A3 is now A470+ press(t, m, "l", "x") // "- is B3, which now holds B471+ for name, want := range map[rune]string{'a': "A1", '0': "A2", '1': "A3", '-': "B4"} {72+ r := m.vim.regs[name]73+ if r.clip == nil {74+ t.Fatalf(`"%c is empty`, name)75+ }76+ if got := formatTSV(r.clip.TextIn(m.locale()))[:2]; got != want {77+ t.Errorf(`"%c holds %q, want %q`, name, got, want)78+ }79+ }80+ // "ap pastes row 1 as a new row below; the clipboard stays "-.81+ press(t, m, "G", `"ap`)82+ if input(m, "A6") != "A1" || input(m, "C6") != "C1" {83+ t.Fatalf(`"ap: A6 %q C6 %q`, input(m, "A6"), input(m, "C6"))84+ }85+ press(t, m, "0", "p")86+ if input(m, "A6") != "B4" {87+ t.Errorf("p after \"ap pastes the clipboard: A6 %q", input(m, "A6"))88+ }89+ // A second delete of rows moves "1 to "2.90+ press(t, m, "gg", "dd")91+ if formatTSV(m.vim.regs['2'].clip.TextIn(m.locale()))[:2] != "A3" {92+ t.Errorf(`"2 after dd: %q`, formatTSV(m.vim.regs['2'].clip.TextIn(m.locale())))93+ }94+ press(t, m, `"bp`)95+ if !strings.Contains(line(m, contextLine), `Register "b is empty`) {96+ t.Errorf(`"bp: %q`, line(m, contextLine))97+ }98+ press(t, m, `"A`)99+ if !strings.Contains(line(m, contextLine), `No register "A`) || m.vim.pending() != "" {100+ t.Errorf(`"A: %q, pending %q`, line(m, contextLine), m.vim.pending())101+ }102+ // The register shows while the sequence is typed.103+ press(t, m, `"a2`)104+ if l := line(m, contextLine); !strings.HasPrefix(l, `"a2`) {105+ t.Errorf("pending register: %q", l)106+ }107+ press(t, m, "<esc>")108+ // VISUAL y into a register, and p from one over a selection.109+ press(t, m, "gg", "0", "v", "l", `"cy`)110+ if r := m.vim.regs['c']; r.clip == nil || r.rows || r.clip.Src.String() != "A1:B1" {111+ t.Fatalf(`v "cy: %+v`, r)112+ }113+ press(t, m, "G", "v", `"cp`)114+ if at := m.cur.String(); input(m, at) != input(m, "A1") {115+ t.Errorf(`v "cp: %s %q`, at, input(m, at))116+ }117+}118+119+// "+p asks the terminal for the system clipboard and pastes its answer.120+func TestVimSystemClipboard(t *testing.T) {121+ m := vimModel(t)122+ press(t, m, "G", "j", `"+p`)123+ if !m.vim.clipPaste {124+ t.Fatal(`"+p didn't ask for the clipboard`)125+ }126+ send(m, tea.ClipboardMsg{Content: "x\ty\n1\t2"})127+ if input(m, "A7") != "x" || input(m, "B8") != "2" || m.vim.clipPaste {128+ t.Fatalf(`"+p block: A7 %q B8 %q`, input(m, "A7"), input(m, "B8"))129+ }130+ press(t, m, "<esc>", "gg", `"+p`)131+ send(m, tea.ClipboardMsg{Content: "one"})132+ if input(m, "A1") != "one" || m.mode != modeReady {133+ t.Fatalf(`"+p value: A1 %q mode %v`, input(m, "A1"), m.mode)134+ }135+ // An answer nobody asked for is ignored.136+ send(m, tea.ClipboardMsg{Content: "two"})137+ if input(m, "A1") != "one" {138+ t.Errorf("unasked clipboard pasted: %q", input(m, "A1"))139+ }140+}141+142+// cc and S clear the row and start typing; s clears the cell. Both keep143+// what they cleared to paste.144+func TestVimChange(t *testing.T) {145+ m := vimModel(t)146+ press(t, m, "j", "l", "cc")147+ if m.mode != modeEnter || m.cur != addr("B2") || input(m, "A2") != "" || input(m, "C2") != "" {148+ t.Fatalf("cc: mode %v at %v, A2 %q", m.mode, m.cur, input(m, "A2"))149+ }150+ press(t, m, "new", "<enter>")151+ if input(m, "B2") != "new" || input(m, "A3") != "A3" || m.sheet.Len() != 16 {152+ t.Fatalf("cc new: B2 %q, %d cells", input(m, "B2"), m.sheet.Len())153+ }154+ if r := m.vim.regs['1']; r.clip == nil || !r.rows {155+ t.Errorf(`cc didn't keep the row in "1`)156+ }157+ press(t, m, "2s")158+ if m.mode != modeEnter || input(m, "B3") != "" || input(m, "C3") != "" || input(m, "A3") != "A3" {159+ t.Fatalf("2s: mode %v B3 %q C3 %q", m.mode, input(m, "B3"), input(m, "C3"))160+ }161+ press(t, m, "<esc>", "p")162+ if input(m, "B3") != "B3" || input(m, "C3") != "C3" {163+ t.Errorf("p after s: B3 %q C3 %q", input(m, "B3"), input(m, "C3"))164+ }165+ press(t, m, "gg", "2S", "top", "<enter>")166+ if input(m, "B1") != "top" || input(m, "A2") != "" || input(m, "A3") != "A3" {167+ t.Errorf("2S: B1 %q A2 %q", input(m, "B1"), input(m, "A2"))168+ }169+ press(t, m, "j", ".")170+ if input(m, "B3") != "top" || input(m, "A3") != "" {171+ t.Errorf("S .: B3 %q A3 %q", input(m, "B3"), input(m, "A3"))172+ }173+}174+175+// Marks: m sets one, ` goes back to the cell and ' to its row, across176+// sheets; '' goes back from a jump.177+func TestVimMarks(t *testing.T) {178+ m := vimModel(t)179+ press(t, m, "j", "l", "ma")180+ if !strings.Contains(line(m, contextLine), "Mark a at B2") {181+ t.Errorf("ma: %q", line(m, contextLine))182+ }183+ press(t, m, "G", "$", "`a")184+ if m.cur != addr("B2") {185+ t.Fatalf("`a: at %v", m.cur)186+ }187+ press(t, m, "l", "'a")188+ if m.cur != addr("A2") {189+ t.Fatalf("'a: at %v", m.cur)190+ }191+ press(t, m, "``")192+ if m.cur != addr("C2") {193+ t.Fatalf("``: at %v", m.cur)194+ }195+ press(t, m, "G", "``")196+ if m.cur != addr("C2") {197+ t.Fatalf("G ``: at %v", m.cur)198+ }199+ press(t, m, ":B12", "<enter>", "''")200+ if m.cur != addr("A2") {201+ t.Fatalf(":B12 '' goes to the row: at %v", m.cur)202+ }203+ press(t, m, "'b")204+ if !strings.Contains(line(m, contextLine), "Mark b isn't set") {205+ t.Errorf("'b: %q", line(m, contextLine))206+ }207+ // A mark on another sheet shows that sheet.208+ first := m.sheet209+ m.runCommand("sheet.new")210+ press(t, m, "`a")211+ if m.sheet != first || m.cur != addr("B2") {212+ t.Errorf("`a from another sheet: %s at %v", m.sheet.Name(), m.cur)213+ }214+ // In VISUAL mode a mark moves the selection's corner.215+ press(t, m, "gg", "0", "v", "`a")216+ if indicator(m) != "VISUAL" || m.selection().String() != "A1:B2" {217+ t.Errorf("v `a: %q %v", indicator(m), m.selection())218+ }219+}220+221+// Up and Down on the : line go through the lines run before, those222+// starting with what's typed; the history outlasts the file.223+func TestVimCommandHistory(t *testing.T) {224+ m := vimModel(t)225+ press(t, m, ":B5", "<enter>", ":fill down", "<enter>", ":B7", "<enter>")226+ press(t, m, ":", "<up>")227+ if m.line.Text() != "B7" {228+ t.Fatalf("Up: %q", m.line.Text())229+ }230+ press(t, m, "<up>", "<up>")231+ if m.line.Text() != "B5" {232+ t.Fatalf("Up Up Up: %q", m.line.Text())233+ }234+ press(t, m, "<up>")235+ if m.line.Text() != "B5" {236+ t.Errorf("Up past the oldest: %q", m.line.Text())237+ }238+ press(t, m, "<down>", "<down>", "<down>")239+ if m.line.Text() != "" {240+ t.Errorf("Down back to what was typed: %q", m.line.Text())241+ }242+ press(t, m, "<esc>", ":B", "<up>")243+ if m.line.Text() != "B7" {244+ t.Errorf("B Up: %q", m.line.Text())245+ }246+ press(t, m, "<up>", "<enter>")247+ if m.cur != addr("B5") {248+ t.Errorf("recalled :B5 at %v", m.cur)249+ }250+ // Ctrl+N moves the completions.251+ press(t, m, ":fill", "<ctrl+n>")252+ if s := screen(m); !strings.Contains(line(m, m.height-1), "Copy the") || !strings.Contains(s, "Fill right") {253+ t.Errorf("Ctrl+N: %q", line(m, m.height-1))254+ }255+ press(t, m, "<esc>")256+ m.runCommand("file.new")257+ press(t, m, ":", "<up>")258+ if m.line.Text() != "B5" {259+ t.Errorf("history after File > New: %q", m.line.Text())260+ }261+}262+263+// :w! writes over a file changed on disk since it was opened, and over a264+// file of the name given, without asking.265+func TestVimWriteForced(t *testing.T) {266+ dir := t.TempDir()267+ m := vimModel(t)268+ name := filepath.Join(dir, "book.012")269+ press(t, m, ":w "+name, "<enter>")270+ if m.filename != name {271+ t.Fatalf(":w: %q", m.filename)272+ }273+ // Another program writes the file.274+ later := time.Now().Add(time.Minute)275+ if err := os.WriteFile(name, []byte("{}"), 0o644); err != nil {276+ t.Fatal(err)277+ }278+ os.Chtimes(name, later, later)279+ press(t, m, "x", ":w", "<enter>")280+ if !strings.Contains(line(m, contextLine), "changed on disk") {281+ t.Fatalf(":w after a change on disk: %q", line(m, contextLine))282+ }283+ press(t, m, "<esc>", ":w!", "<enter>")284+ if data, _ := os.ReadFile(name); string(data) == "{}" || m.changed {285+ t.Fatalf(":w! didn't write: changed %v", m.changed)286+ }287+ other := filepath.Join(dir, "other.012")288+ os.WriteFile(other, []byte("{}"), 0o644)289+ press(t, m, ":w "+other, "<enter>")290+ if !strings.Contains(line(m, contextLine), "exists") {291+ t.Fatalf(":w other: %q", line(m, contextLine))292+ }293+ press(t, m, "<esc>", ":w! "+other, "<enter>")294+ if data, _ := os.ReadFile(other); string(data) == "{}" || m.filename != other {295+ t.Errorf(":w! other: %q, filename %q", data, m.filename)296+ }297+ csv := filepath.Join(dir, "out.csv")298+ os.WriteFile(csv, []byte("old"), 0o644)299+ press(t, m, ":w! "+csv, "<enter>")300+ if data, _ := os.ReadFile(csv); !strings.Contains(string(data), "B1") {301+ t.Errorf(":w! out.csv: %q", data)302+ }303+}
A internal/ui/vimregs.go +104 -0
@@ -0,0 +1,104 @@
1+package ui2+3+import (4+ tea "charm.land/bubbletea/v2"5+6+ "github.com/FelineStateMachine/012/internal/sheet"7+)8+9+// Vim registers. The unnamed register is the clipboard Ctrl+V pastes, so10+// y, d and x in vim keys and Ctrl+C share it. A register named with "11+// before an operator ("ayy, "bx, "ap) holds a copy of its own:12+//13+// - "a to "z: named, kept until written again;14+// - "0: the last copy made without naming a register;15+// - "1 to "9: the last rows deleted (dd, cc, V d), newest in "1;16+// - "-: the last cells deleted (x, s, v d);17+// - "+: the system clipboard, written with OSC 52 as every copy is,18+// and read back from the terminal for "+p;19+// - "": the unnamed register itself.20+//21+// Registers live with the workbook, as the clipboard does.22+23+// vimRegister is what a vim register holds: cells, or whole rows that24+// paste as new rows.25+type vimRegister struct {26+ clip *sheet.Clip27+ sheet *sheet.Sheet // where it was copied from28+ rows bool29+}30+31+// isRegister reports whether r names a register.32+func isRegister(r rune) bool {33+ return r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '"' || r == '+'34+}35+36+// storeRegister puts what an operator just copied (the clipboard) in the37+// register it named, or when it named none, in "0 for a copy, "1 for38+// deleted rows (moving "1 to "8 along to "2 to "9) and "- for deleted39+// cells.40+func (m *Model) storeRegister(deleted bool) {41+ v := &m.vim42+ r := vimRegister{clip: m.copied.clip, sheet: m.copied.sheet, rows: m.rowsCopied()}43+ if v.regs == nil {44+ v.regs = map[rune]vimRegister{}45+ }46+ switch name := v.reg; {47+ case name >= 'a' && name <= 'z', name >= '0' && name <= '9', name == '-':48+ v.regs[name] = r49+ case name == '+' || name == '"':50+ // The clipboard and the system clipboard have it already.51+ case !deleted:52+ v.regs['0'] = r53+ case r.rows:54+ for i := '9'; i > '1'; i-- {55+ v.regs[i] = v.regs[i-1]56+ }57+ v.regs['1'] = r58+ default:59+ v.regs['-'] = r60+ }61+}62+63+// fromRegister runs put, a paste of the clipboard, with the register64+// named with " in the clipboard's place, leaving the clipboard as it was.65+// "+ asks the terminal for the system clipboard, which is pasted when it66+// answers (pasteClipboard).67+func (m *Model) fromRegister(put func() tea.Cmd) tea.Cmd {68+ name := m.vim.reg69+ switch name {70+ case 0, '"':71+ return put()72+ case '+':73+ m.vim.clipPaste = true74+ return tea.ReadClipboard75+ }76+ r, ok := m.vim.regs[name]77+ if !ok || r.clip == nil {78+ m.note = `Register "` + string(name) + ` is empty`79+ return nil80+ }81+ saved, savedRows := m.copied, m.vim.rows82+ m.copied = clipboard{clip: r.clip, sheet: r.sheet}83+ m.vim.rows = nil84+ if r.rows {85+ m.vim.rows = r.clip86+ }87+ cmd := put()88+ m.copied, m.vim.rows = saved, savedRows89+ return cmd90+}91+92+// pasteClipboard pastes the system clipboard's text, read for "+p, at93+// the active cell: a block of cells for tab-separated or multi-line94+// text, as a terminal paste, or one value entered in the cell.95+func (m *Model) pasteClipboard(text string) {96+ m.vim.clipPaste = false97+ if m.mode != modeReady || text == "" {98+ return99+ }100+ m.handlePaste(text)101+ if m.mode == modeEnter {102+ m.commit()103+ }104+}
A internal/ui/vimrepeat.go +87 -0
@@ -0,0 +1,87 @@
1+package ui2+3+import (4+ tea "charm.land/bubbletea/v2"5+)6+7+// . repeats the last change made in NORMAL mode, as vim does: the keys8+// of the operator (x, dd, cc, s, p, o, i, a, = and the rest marked9+// change in vimkeys.go) with its count and register, and when it started10+// an entry, the keys typed into it up to the one that finished it. A11+// count before . replaces the change's own (3. after dd deletes three12+// rows).13+14+// The binding gets repeatChange here rather than in its table, which15+// repeatChange reads through the key handlers.16+func init() {17+ b := vimNormal["."]18+ b.do = repeatChange19+ vimNormal["."] = b20+}21+22+// vimChange is a change . can repeat.23+type vimChange struct {24+ keys []tea.Msg // key presses and pastes, after any count or register25+ count int26+ reg rune27+}28+29+// vimChanged remembers c as the change . repeats, once any entry it30+// started is finished.31+func (m *Model) vimChanged(c *vimChange) {32+ if m.editing() {33+ m.vim.inserting = c34+ return35+ }36+ m.vim.last = c37+}38+39+// vimCapture adds a key or paste to the entry a change started, before40+// the model handles it.41+func (m *Model) vimCapture(msg tea.Msg) {42+ v := &m.vim43+ if v.inserting == nil || v.replaying {44+ return45+ }46+ switch msg.(type) {47+ case tea.KeyPressMsg, tea.PasteMsg:48+ v.inserting.keys = append(v.inserting.keys, msg)49+ }50+}51+52+// vimSettle finishes the change taking an entry's keys once the entry53+// is accepted or cancelled.54+func (m *Model) vimSettle() {55+ if v := &m.vim; v.inserting != nil && !m.editing() {56+ v.last, v.inserting = v.inserting, nil57+ }58+}59+60+// repeatChange is .: it replays the last change's keys, with the count61+// typed before . when there is one.62+func repeatChange(m *Model, _ int) tea.Cmd {63+ v := &m.vim64+ c := v.last65+ if c == nil {66+ m.note = "No change to repeat yet"67+ return nil68+ }69+ count := c.count70+ if v.count > 0 {71+ count = v.count72+ }73+ v.reset()74+ v.count, v.reg = count, c.reg75+ v.replaying = true76+ defer func() { v.replaying = false }()77+ cmds := make([]tea.Cmd, 0, len(c.keys))78+ for _, msg := range c.keys {79+ switch msg := msg.(type) {80+ case tea.KeyPressMsg:81+ cmds = append(cmds, m.handleKey(msg))82+ case tea.PasteMsg:83+ m.handlePaste(msg.Content)84+ }85+ }86+ return tea.Batch(cmds...)87+}