cat / 012
clone

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

Text fields move and delete by word; Ctrl+U deletes to the line's start

Dami committed b56c152
Ctrl+arrows, Option+arrows (alt+b/f) and Alt+arrows outside a cell move
by word; Ctrl+Backspace (ctrl+h without the kitty protocol),
Option+Backspace and Ctrl+W delete the word before the caret, Alt+D and
Ctrl+Delete the one after. The notebook-running screen waits for its
settled state.
commit
b56c15256fbf7f989dbe955e0e56090a5ef9c090
parent
e95283ba98c1144d525666304f7291f249e11bd9
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.7 of 3, Jev's estimate
7 files changed, +208 -1
Mdocs/reference/keys.md4+
Me2e/screennotebook_test.go3+-
Minternal/ui/lineedit/area.go6++
Minternal/ui/lineedit/lineedit.go3+
Ainternal/ui/lineedit/word.go98++++++++++++++++++++++++++++++
Ainternal/ui/lineedit/word_test.go75+++++++++++++++++++++++
Ainternal/ui/wordkeys_test.go20++++++
M docs/reference/keys.md +4 -0
M e2e/screennotebook_test.go +2 -1
M internal/ui/lineedit/area.go +6 -0
M internal/ui/lineedit/lineedit.go +3 -0
A internal/ui/lineedit/word.go +98 -0
@@ -0,0 +1,98 @@
1+package lineedit2+3+import (4+ "slices"5+ "unicode"6+)7+8+// Word keys, by the names terminals send them under. Option+arrows on9+// macOS arrive as alt+b and alt+f in most terminals, Ctrl+Backspace as10+// ctrl+h without the kitty keyboard protocol, and Cmd+Backspace as11+// ctrl+u.12+var (13+ wordLeftKeys = []string{"ctrl+left", "alt+left", "alt+b"}14+ wordRightKeys = []string{"ctrl+right", "alt+right", "alt+f"}15+ wordBackKeys = []string{"ctrl+backspace", "alt+backspace", "ctrl+h", "ctrl+w"}16+ wordDelKeys = []string{"ctrl+delete", "alt+delete", "alt+d"}17+ lineBackKeys = []string{"ctrl+u", "super+backspace"}18+)19+20+// IsWordKey reports whether key moves or deletes by word or to the start21+// of the line, so a field can pass it on to Key.22+func IsWordKey(key string) bool {23+ for _, keys := range [][]string{wordLeftKeys, wordRightKeys, wordBackKeys, wordDelKeys, lineBackKeys} {24+ if slices.Contains(keys, key) {25+ return true26+ }27+ }28+ return false29+}30+31+// wordKey applies a word key and reports whether key was one.32+func (l *Line) wordKey(key string) bool {33+ switch {34+ case slices.Contains(wordLeftKeys, key):35+ l.Pos = l.wordStart(l.Pos)36+ case slices.Contains(wordRightKeys, key):37+ l.Pos = l.wordEnd(l.Pos)38+ case slices.Contains(wordBackKeys, key):39+ l.DeleteBack(l.wordStart(l.Pos))40+ case slices.Contains(wordDelKeys, key):41+ l.Buf = slices.Delete(l.Buf, l.Pos, l.wordEnd(l.Pos))42+ case slices.Contains(lineBackKeys, key):43+ l.DeleteBack(0)44+ default:45+ return false46+ }47+ return true48+}49+50+// DeleteBack deletes from index from up to the caret.51+func (l *Line) DeleteBack(from int) {52+ l.Buf = slices.Delete(l.Buf, from, l.Pos)53+ l.Pos = from54+}55+56+// A word is a run of letters, digits and underscores, or a run of other57+// symbols, so in a formula Ctrl+Backspace takes B2 from SUM(A1:B2 and58+// then the colon. Spaces and line breaks around a word go with it.59+func wordClass(r rune) int {60+ switch {61+ case unicode.IsSpace(r):62+ return 063+ case r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r):64+ return 165+ }66+ return 267+}68+69+// wordStart is the start of the word before i, skipping spaces first.70+func (l *Line) wordStart(i int) int {71+ for i > 0 && wordClass(l.Buf[i-1]) == 0 {72+ i--73+ }74+ if i == 0 {75+ return 076+ }77+ c := wordClass(l.Buf[i-1])78+ for i > 0 && wordClass(l.Buf[i-1]) == c {79+ i--80+ }81+ return i82+}83+84+// wordEnd is the end of the word after i, skipping spaces first.85+func (l *Line) wordEnd(i int) int {86+ n := len(l.Buf)87+ for i < n && wordClass(l.Buf[i]) == 0 {88+ i++89+ }90+ if i == n {91+ return n92+ }93+ c := wordClass(l.Buf[i])94+ for i < n && wordClass(l.Buf[i]) == c {95+ i++96+ }97+ return i98+}
A internal/ui/lineedit/word_test.go +75 -0
@@ -0,0 +1,75 @@
1+package lineedit2+3+import (4+ "testing"5+6+ tea "charm.land/bubbletea/v2"7+)8+9+var (10+ ctrlLeft = tea.KeyPressMsg{Code: tea.KeyLeft, Mod: tea.ModCtrl}11+ altRight = tea.KeyPressMsg{Code: tea.KeyRight, Mod: tea.ModAlt}12+ ctrlBackspace = tea.KeyPressMsg{Code: tea.KeyBackspace, Mod: tea.ModCtrl}13+ legacyCtrlBS = tea.KeyPressMsg{Code: 'h', Mod: tea.ModCtrl}14+ optBackspace = tea.KeyPressMsg{Code: tea.KeyBackspace, Mod: tea.ModAlt}15+ altD = tea.KeyPressMsg{Code: 'd', Mod: tea.ModAlt}16+ ctrlU = tea.KeyPressMsg{Code: 'u', Mod: tea.ModCtrl}17+)18+19+func TestWordMoves(t *testing.T) {20+ var l Line21+ l.Set("=SUM(A1:B2) + total")22+ for _, want := range []int{14, 12, 10, 8, 7, 5, 4, 1, 0, 0} {23+ l.Key(ctrlLeft)24+ if l.Pos != want {25+ t.Fatalf("ctrl+left to %d, want %d", l.Pos, want)26+ }27+ }28+ for _, want := range []int{1, 4, 5, 7, 8, 10, 11, 13, 19, 19} {29+ l.Key(altRight)30+ if l.Pos != want {31+ t.Fatalf("alt+right to %d, want %d", l.Pos, want)32+ }33+ }34+}35+36+func TestWordDeletes(t *testing.T) {37+ var l Line38+ l.Set("=SUM(A1:B2")39+ for _, step := range []struct {40+ k tea.KeyPressMsg41+ want string42+ }{43+ {ctrlBackspace, "=SUM(A1:"},44+ {legacyCtrlBS, "=SUM(A1"},45+ {optBackspace, "=SUM("},46+ } {47+ l.Key(step.k)48+ if l.Text() != step.want {49+ t.Fatalf("%s left %q, want %q", step.k, l.Text(), step.want)50+ }51+ }52+ l.Set("hello big world")53+ l.Pos = 654+ l.Key(altD)55+ if l.Text() != "hello world" || l.Pos != 6 {56+ t.Errorf("alt+d left %q at %d", l.Text(), l.Pos)57+ }58+ l.Key(ctrlU)59+ if l.Text() != " world" || l.Pos != 0 {60+ t.Errorf("ctrl+u left %q at %d", l.Text(), l.Pos)61+ }62+}63+64+func TestAreaWordKeys(t *testing.T) {65+ a := &Area{}66+ a.Set("ls\n| where size > 1kb")67+ a.Key(ctrlBackspace, 40)68+ if a.Text() != "ls\n| where size > " {69+ t.Fatalf("ctrl+backspace left %q", a.Text())70+ }71+ a.Key(ctrlU, 40)72+ if a.Text() != "ls\n" {73+ t.Errorf("ctrl+u took more than its line: %q", a.Text())74+ }75+}
A internal/ui/wordkeys_test.go +20 -0