cat / 012
clone

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

Add undo, clipboard, fill, insert and delete to the UI

Dami committed 89336a7
Ctrl+Z undoes and Ctrl+Y or Ctrl+Shift+Z redoes any change, selecting
what changed and saying what it was on the context line ("Undid: clear
B3:B5"). The modified flag follows the history, so undoing back to the
saved state clears it.

Ctrl+C, Ctrl+X and Ctrl+V copy, cut and paste with references adjusted
as in Sheets, Ctrl+Shift+V pastes values only, and the copied values go
to the system clipboard as TSV over OSC 52. The copied range keeps a
dashed underline until Esc or the next edit, with the keys to use on the
context line. Pasting tab-separated or multi-line text from the terminal
fills a block of cells in one undo step.

Ctrl+D and Ctrl+R fill down and right, and Ctrl+Enter enters what's
being typed into every selected cell; the selection now stays while
typing. Rows and columns can be inserted and deleted through the new
insert.* and delete.* commands, and Ctrl+Alt+= and Ctrl+Alt+- act on
rows, or on columns when whole columns are selected. F4 cycles the
reference at the caret through A1, $A$1, A$1 and $A1.

Help lists shortcuts in two columns by title so it still fits.
commit
89336a7d66c744189d74bf384b508ca4b1eb53b7
parent
ae0bac0a26b061523892dbd843a7984065c5efb4
author
Dami <dami.etoile@gmail.com>
13 files changed, +969 -42
MREADME.md8+
Me2e/testdata/screens/help-light.html22+-
Me2e/testdata/screens/help.html22+-
Minternal/sheet/sheet.go2+-
Ainternal/ui/clipboard.go223++++++++++++++++++++++
Minternal/ui/commands.go3+-
Ainternal/ui/edit.go294+++++++++++++++++++++++++++++
Ainternal/ui/edit_test.go309++++++++++++++++++++++++++++++
Minternal/ui/entry.go30++-
Minternal/ui/model.go36+++
Minternal/ui/model_test.go2+-
Minternal/ui/theme.go6+
Minternal/ui/view.go54++++-
M README.md +8 -0
M e2e/testdata/screens/help-light.html +11 -11
M e2e/testdata/screens/help.html +11 -11
M internal/sheet/sheet.go +1 -1
A internal/ui/clipboard.go +223 -0
@@ -0,0 +1,223 @@
1+package ui2+3+import (4+ "strings"5+6+ tea "charm.land/bubbletea/v2"7+ "github.com/charmbracelet/x/ansi"8+9+ "one23/internal/sheet"10+)11+12+// Copy, cut and paste follow Sheets: Ctrl+C copies the selection and13+// marks it until Esc or the next edit, Ctrl+V pastes with references14+// adjusted, Ctrl+X then Ctrl+V moves. The copied values also go to the15+// system clipboard as TSV (OSC 52), and a multi-cell paste from the16+// terminal fills a block of cells.17+18+// clipboard is what Ctrl+V pastes.19+type clipboard struct {20+ clip *sheet.Clip21+ cut bool // pasting moves the cells rather than copying them22+ marked bool // the source shows the copy marker23+ keep bool // the last edit was a paste, which keeps the marker24+}25+26+func init() {27+ register(28+ &command{id: "edit.copy", title: "Copy", desc: "Copy the selection", run: func(m *Model) tea.Cmd { return m.copy(false) }},29+ &command{id: "edit.cut", title: "Cut", desc: "Cut the selection, to move it where you paste", run: func(m *Model) tea.Cmd { return m.copy(true) }},30+ &command{id: "edit.paste", title: "Paste", desc: "Paste into the selection, adjusting references", run: func(m *Model) tea.Cmd { return m.paste(false) }},31+ &command{id: "edit.paste_values", title: "Paste values only", desc: "Paste the copied values without formulas", run: func(m *Model) tea.Cmd { return m.paste(true) }},32+ )33+ keymap["ctrl+c"] = "edit.copy"34+ keymap["ctrl+x"] = "edit.cut"35+ keymap["ctrl+v"] = "edit.paste"36+ keymap["ctrl+shift+v"] = "edit.paste_values"37+}38+39+// copy puts the selection on the clipboard and the system clipboard.40+func (m *Model) copy(cut bool) tea.Cmd {41+ clip := m.sheet.Copy(m.copyRange())42+ m.copied = clipboard{clip: clip, cut: cut, marked: true}43+ return tea.SetClipboard(formatTSV(clip.Text()))44+}45+46+// copyRange is the selection, trimmed to the data when whole rows or47+// columns are selected, as there's no point copying thousands of blanks.48+func (m *Model) copyRange() sheet.Rect {49+ r := m.selection()50+ if m.whole == wholeNone {51+ return r52+ }53+ used, ok := m.sheet.UsedRange()54+ r.To = sheet.Addr{Col: min(r.To.Col, used.To.Col), Row: min(r.To.Row, used.To.Row)}55+ if !ok || r.To.Col < r.From.Col || r.To.Row < r.From.Row {56+ r.To = r.From57+ }58+ return r59+}60+61+// paste pastes the clipboard into the selection and selects what it62+// wrote.63+func (m *Model) paste(values bool) tea.Cmd {64+ c := m.copied65+ if c.clip == nil {66+ m.note = "Nothing to paste: copy with Ctrl+C first"67+ return nil68+ }69+ var r sheet.Rect70+ var err error71+ if c.cut && !values {72+ r, err = m.sheet.Move(c.clip.Src, m.cur)73+ m.copied = clipboard{}74+ } else {75+ r, err = m.sheet.Paste(c.clip, m.selection(), values)76+ m.copied.keep = true77+ }78+ if err != nil {79+ m.fail(err.Error())80+ return nil81+ }82+ m.selectRect(r)83+ switch {84+ case c.cut && !values:85+ m.note = "Moved " + c.clip.Src.String() + " to " + r.String()86+ case values:87+ m.note = "Pasted values into " + countCells(r) + " at " + r.String()88+ default:89+ m.note = "Pasted " + countCells(r) + " at " + r.String()90+ }91+ return nil92+}93+94+// copyMarked reports whether a shows the copy marker.95+func (m *Model) copyMarked(a sheet.Addr) bool {96+ return m.copied.marked && m.copied.clip.Src.Contains(a)97+}98+99+// clearCopyMark hides the copy marker; a pending cut is cancelled, as in100+// Sheets, since the cells it would move may have changed.101+func (m *Model) clearCopyMark() {102+ if m.copied.cut {103+ m.copied = clipboard{}104+ }105+ m.copied.marked = false106+}107+108+// pasteText fills a block of cells from pasted text with tabs or line109+// breaks, such as cells copied from another spreadsheet, and reports110+// whether it did. Each value is entered as if typed.111+func (m *Model) pasteText(content string) bool {112+ content = strings.TrimSuffix(strings.TrimSuffix(content, "\n"), "\r")113+ if !strings.ContainsAny(content, "\t\n") {114+ return false115+ }116+ rows := parseTSV(content)117+ width := 0118+ for _, row := range rows {119+ width = max(width, len(row))120+ }121+ r := sheet.Rect{From: m.cur, To: sheet.Addr{Col: m.cur.Col + width - 1, Row: m.cur.Row + len(rows) - 1}}122+ if !r.To.Valid() {123+ m.fail(sheet.ErrPasteEdge.Error())124+ return true125+ }126+ m.sheet.Batch(sheet.Change{Label: "paste into " + r.String(), Focus: r}, func() error {127+ for i, row := range rows {128+ for j := range width {129+ var v string130+ if j < len(row) {131+ v = row[j]132+ }133+ a := sheet.Addr{Col: r.From.Col + j, Row: r.From.Row + i}134+ if m.sheet.Set(a, v) != nil {135+ m.sheet.Set(a, "'"+v) // a broken formula stays as text136+ }137+ }138+ }139+ return nil140+ })141+ m.selectRect(r)142+ m.note = "Pasted " + countCells(r) + " at " + r.String()143+ return true144+}145+146+// formatTSV writes rows as tab-separated values the way Sheets puts them147+// on the clipboard: fields with tabs, line breaks or a leading quote are148+// quoted, with quotes doubled.149+func formatTSV(rows [][]string) string {150+ var b strings.Builder151+ for i, row := range rows {152+ if i > 0 {153+ b.WriteByte('\n')154+ }155+ for j, f := range row {156+ if j > 0 {157+ b.WriteByte('\t')158+ }159+ if strings.ContainsAny(f, "\t\n\r") || strings.HasPrefix(f, `"`) {160+ f = `"` + strings.ReplaceAll(f, `"`, `""`) + `"`161+ }162+ b.WriteString(f)163+ }164+ }165+ return b.String()166+}167+168+// parseTSV reads tab-separated rows, keeping blank lines and fields, and169+// unquoting fields written by formatTSV (or Sheets and Excel).170+func parseTSV(s string) [][]string {171+ var rows [][]string172+ var row []string173+ var field strings.Builder174+ quoted, fieldStart := false, true175+ for i := 0; i < len(s); i++ {176+ c := s[i]177+ switch {178+ case quoted && c == '"' && i+1 < len(s) && s[i+1] == '"':179+ field.WriteByte('"')180+ i++181+ case quoted && c == '"':182+ quoted = false183+ case quoted:184+ field.WriteByte(c)185+ case c == '"' && fieldStart:186+ quoted = true187+ case c == '\t':188+ row = append(row, field.String())189+ field.Reset()190+ fieldStart = true191+ continue192+ case c == '\n':193+ row = append(row, strings.TrimSuffix(field.String(), "\r"))194+ rows = append(rows, row)195+ row, fieldStart = nil, true196+ field.Reset()197+ continue198+ default:199+ field.WriteByte(c)200+ }201+ fieldStart = false202+ }203+ return append(rows, append(row, strings.TrimSuffix(field.String(), "\r")))204+}205+206+// readyLine is the context line in READY mode: feedback on the last207+// action, or what to do with a copied range.208+func (m *Model) readyLine() string {209+ switch {210+ case m.note != "":211+ return m.note212+ case m.copied.marked && m.copied.cut:213+ return "Cut " + m.copied.clip.Src.String() + " " + m.keyHints("Ctrl+V", "move here", "Esc", "cancel")214+ case m.copied.marked:215+ text := "Copied " + m.copied.clip.Src.String() + " "216+ full := text + m.keyHints("Ctrl+V", "paste", "Ctrl+Shift+V", "paste values", "Esc", "clear")217+ if ansi.StringWidth(full) <= m.width {218+ return full219+ }220+ return text + m.keyHints("Ctrl+V", "paste", "Esc", "clear")221+ }222+ return ""223+}
M internal/ui/commands.go +2 -1
A internal/ui/edit.go +294 -0
@@ -0,0 +1,294 @@
1+package ui2+3+import (4+ "errors"5+ "fmt"6+ "slices"7+ "strings"8+ "unicode/utf8"9+10+ tea "charm.land/bubbletea/v2"11+12+ "one23/internal/sheet"13+)14+15+// Undo and redo, fill, inserting and deleting rows and columns, and F4 in16+// formulas. The engine does the work and records each change as one undo17+// step; this file maps commands onto the selection and reports back on the18+// context line.19+20+func init() {21+ register(22+ &command{id: "edit.undo", title: "Undo", desc: "Undo the last change", run: (*Model).undo},23+ &command{id: "edit.redo", title: "Redo", desc: "Redo the last undone change", run: (*Model).redo},24+ &command{id: "edit.fill_down", title: "Fill down", desc: "Copy the top row of the selection into the rows below it", run: func(m *Model) tea.Cmd {25+ return m.fill(m.sheet.FillDown)26+ }},27+ &command{id: "edit.fill_right", title: "Fill right", desc: "Copy the left column of the selection into the columns right of it", run: func(m *Model) tea.Cmd {28+ return m.fill(m.sheet.FillRight)29+ }},30+ &command{id: "insert.row_above", title: "Insert rows above", desc: "Insert as many rows above the selection as it spans", run: func(m *Model) tea.Cmd {31+ r := m.selection()32+ return m.structural(m.sheet.InsertRows(r.From.Row, r.To.Row-r.From.Row+1))33+ }},34+ &command{id: "insert.row_below", title: "Insert rows below", desc: "Insert as many rows below the selection as it spans", run: func(m *Model) tea.Cmd {35+ r := m.selection()36+ if r.To.Row == sheet.MaxRows-1 {37+ return m.structural(sheet.ErrPushedOff)38+ }39+ return m.structural(m.sheet.InsertRows(r.To.Row+1, r.To.Row-r.From.Row+1))40+ }},41+ &command{id: "insert.col_left", title: "Insert columns left", desc: "Insert as many columns left of the selection as it spans", run: func(m *Model) tea.Cmd {42+ r := m.selection()43+ return m.structural(m.sheet.InsertCols(r.From.Col, r.To.Col-r.From.Col+1))44+ }},45+ &command{id: "insert.col_right", title: "Insert columns right", desc: "Insert as many columns right of the selection as it spans", run: func(m *Model) tea.Cmd {46+ r := m.selection()47+ if r.To.Col == sheet.MaxCols-1 {48+ return m.structural(sheet.ErrPushedOff)49+ }50+ return m.structural(m.sheet.InsertCols(r.To.Col+1, r.To.Col-r.From.Col+1))51+ }},52+ &command{id: "delete.row", title: "Delete rows", desc: "Delete the selected rows; references to them become #REF!", run: func(m *Model) tea.Cmd {53+ r := m.selection()54+ m.sheet.DeleteRows(r.From.Row, r.To.Row-r.From.Row+1)55+ return nil56+ }},57+ &command{id: "delete.col", title: "Delete columns", desc: "Delete the selected columns; references to them become #REF!", run: func(m *Model) tea.Cmd {58+ r := m.selection()59+ m.sheet.DeleteCols(r.From.Col, r.To.Col-r.From.Col+1)60+ return nil61+ }},62+ // Sheets' Ctrl+Alt+= and Ctrl+Alt+- act on rows, or on columns when63+ // whole columns are selected.64+ &command{id: "insert.selection", title: "Insert rows or columns", desc: "Insert rows above the selection, or columns left of it when whole columns are selected", run: func(m *Model) tea.Cmd {65+ if m.whole == wholeCols {66+ return m.runCommand("insert.col_left")67+ }68+ return m.runCommand("insert.row_above")69+ }},70+ &command{id: "delete.selection", title: "Delete rows or columns", desc: "Delete the selected rows, or columns when whole columns are selected", run: func(m *Model) tea.Cmd {71+ if m.whole == wholeCols {72+ return m.runCommand("delete.col")73+ }74+ return m.runCommand("delete.row")75+ }},76+ )77+ keymap["ctrl+alt+="] = "insert.selection"78+ keymap["ctrl+alt+-"] = "delete.selection"79+ keymap["ctrl+z"] = "edit.undo"80+ keymap["ctrl+y"] = "edit.redo"81+ keymap["ctrl+shift+z"] = "edit.redo"82+ keymap["ctrl+d"] = "edit.fill_down"83+ keymap["ctrl+r"] = "edit.fill_right"84+}85+86+func (m *Model) undo() tea.Cmd {87+ c, ok := m.sheet.Undo()88+ m.afterHistory("Undid", "Nothing to undo", c, ok)89+ return nil90+}91+92+func (m *Model) redo() tea.Cmd {93+ c, ok := m.sheet.Redo()94+ m.afterHistory("Redid", "Nothing to redo", c, ok)95+ return nil96+}97+98+// afterHistory selects what an undo or redo changed, as Sheets does, and99+// says what it was on the context line.100+func (m *Model) afterHistory(verb, none string, c sheet.Change, ok bool) {101+ if !ok {102+ m.note = none103+ return104+ }105+ m.note = verb + ": " + c.Label106+ m.selectRect(c.Focus)107+}108+109+// selectRect selects r, as whole columns or rows when it spans the sheet.110+func (m *Model) selectRect(r sheet.Rect) {111+ allRows := r.From.Row == 0 && r.To.Row == sheet.MaxRows-1112+ allCols := r.From.Col == 0 && r.To.Col == sheet.MaxCols-1113+ switch {114+ case allRows && allCols:115+ m.selecting, m.whole, m.ext = true, wholeAll, m.cur116+ case allRows:117+ m.cur.Col = r.From.Col118+ m.ext = sheet.Addr{Col: r.To.Col, Row: m.cur.Row}119+ m.selecting, m.whole = true, wholeCols120+ case allCols:121+ m.cur.Row = r.From.Row122+ m.ext = sheet.Addr{Col: m.cur.Col, Row: r.To.Row}123+ m.selecting, m.whole = true, wholeRows124+ default:125+ m.cur, m.ext = r.From, r.To126+ m.selecting, m.whole = r.From != r.To, wholeNone127+ }128+}129+130+func (m *Model) fill(fn func(sheet.Rect) (sheet.Rect, error)) tea.Cmd {131+ if _, err := fn(m.selection()); err != nil {132+ m.fail(err.Error())133+ }134+ return nil135+}136+137+// structural reports an insert that couldn't be done.138+func (m *Model) structural(err error) tea.Cmd {139+ if err != nil {140+ m.fail(err.Error())141+ }142+ return nil143+}144+145+// fillEntry stores the entry being typed in every selected cell, adjusting146+// references as if it were copied from the active cell (Ctrl+Enter).147+func (m *Model) fillEntry() bool {148+ input := string(m.buf)149+ if err := m.sheet.FillEntry(m.selection(), m.cur, input); err != nil {150+ m.entryError(err, input)151+ return false152+ }153+ m.cancelEntry()154+ return true155+}156+157+// entryError keeps an entry that can't be stored in EDIT mode, with the158+// caret at the problem and the reason on line 3.159+func (m *Model) entryError(err error, input string) {160+ var pe *sheet.ParseError161+ m.mode = modeEdit162+ m.hint = err.Error()163+ if errors.As(err, &pe) {164+ m.bufPos = utf8.RuneCountInString(input[:min(pe.Pos, len(input))])165+ }166+}167+168+// toggleAbsolute cycles the reference at the caret through A1, $A$1, A$1169+// and $A1, as F4 does in Sheets. A range cycles both corners.170+func (m *Model) toggleAbsolute() {171+ if buf, pos, ok := cycleRef(m.buf, m.bufPos); ok {172+ m.buf, m.bufPos = buf, pos173+ }174+}175+176+// cycleRef finds the reference (or range) at or just before pos in a177+// formula and returns the text with its absolute markers cycled, and the178+// caret moved to the reference's end.179+func cycleRef(buf []rune, pos int) ([]rune, int, bool) {180+ quoted := make([]bool, len(buf))181+ in := false182+ for i, r := range buf {183+ if r == '"' {184+ in = !in185+ }186+ quoted[i] = in || r == '"'187+ }188+ isRefRune := func(i int) bool {189+ r := buf[i]190+ return !quoted[i] && (r == '$' || r >= '0' && r <= '9' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z')191+ }192+ // word returns the bounds of the run of reference characters around i.193+ word := func(i int) (int, int) {194+ start, end := i, i195+ for start > 0 && isRefRune(start-1) {196+ start--197+ }198+ for end < len(buf) && isRefRune(end) {199+ end++200+ }201+ return start, end202+ }203+ isRef := func(start, end int) bool {204+ if start == end || start > 0 && buf[start-1] == '@' || end < len(buf) && buf[end] == '(' {205+ return false206+ }207+ _, ok := sheet.ParseAddr(string(buf[start:end]))208+ return ok209+ }210+ start, end := word(pos)211+ if !isRef(start, end) {212+ return nil, 0, false213+ }214+ // Widen to a range written A1:B2 or A1..B2.215+ sep := func(i int) int {216+ switch {217+ case i < len(buf) && buf[i] == ':':218+ return 1219+ case i+1 < len(buf) && buf[i] == '.' && buf[i+1] == '.':220+ return 2221+ }222+ return 0223+ }224+ corners := [][2]int{{start, end}}225+ if n := sep(end); n > 0 {226+ if s2, e2 := word(end + n); s2 == end+n && isRef(s2, e2) {227+ corners = append(corners, [2]int{s2, e2})228+ }229+ } else {230+ for n := 1; n <= 2 && start-n > 0; n++ {231+ if sep(start-n) == n {232+ if s0, e0 := word(start - n - 1); e0 == start-n && isRef(s0, e0) {233+ corners = [][2]int{{s0, e0}, {start, end}}234+ }235+ break236+ }237+ }238+ }239+ next := nextMarkers(string(buf[corners[0][0]:corners[0][1]]))240+ out := slices.Clone(buf[:corners[0][0]])241+ for i, c := range corners {242+ if i > 0 {243+ out = append(out, buf[corners[i-1][1]:c[0]]...)244+ }245+ out = append(out, []rune(withMarkers(string(buf[c[0]:c[1]]), next))...)246+ }247+ caret := len(out)248+ out = append(out, buf[corners[len(corners)-1][1]:]...)249+ return out, caret, true250+}251+252+// nextMarkers returns the absolute markers that follow ref's in F4's253+// cycle: A1 -> $A$1 -> A$1 -> $A1 -> A1.254+func nextMarkers(ref string) markers {255+ col := strings.HasPrefix(ref, "$")256+ row := strings.Contains(ref[1:], "$")257+ switch {258+ case !col && !row:259+ return markers{true, true}260+ case col && row:261+ return markers{false, true}262+ case row:263+ return markers{true, false}264+ }265+ return markers{}266+}267+268+// markers says which parts of a reference are absolute.269+type markers struct{ col, row bool }270+271+// withMarkers rewrites ref with the given absolute markers.272+func withMarkers(ref string, marks markers) string {273+ ref = strings.ToUpper(strings.ReplaceAll(ref, "$", ""))274+ i := strings.IndexAny(ref, "0123456789")275+ var b strings.Builder276+ if marks.col {277+ b.WriteByte('$')278+ }279+ b.WriteString(ref[:i])280+ if marks.row {281+ b.WriteByte('$')282+ }283+ b.WriteString(ref[i:])284+ return b.String()285+}286+287+// countCells describes a paste for the context line.288+func countCells(r sheet.Rect) string {289+ n := (r.To.Col - r.From.Col + 1) * (r.To.Row - r.From.Row + 1)290+ if n == 1 {291+ return "1 cell"292+ }293+ return fmt.Sprintf("%d cells", n)294+}
A internal/ui/edit_test.go +309 -0
@@ -0,0 +1,309 @@
1+package ui2+3+import (4+ "fmt"5+ "strings"6+ "testing"7+8+ tea "charm.land/bubbletea/v2"9+)10+11+func init() {12+ named["f4"] = tea.Key{Code: tea.KeyF4}13+ named["end"] = tea.Key{Code: tea.KeyEnd}14+}15+16+func TestUndoRedoKeys(t *testing.T) {17+ t.Chdir(t.TempDir())18+ m := newModel()19+ press(t, m, "1", "<enter>", "=A1*2", "<enter>", "<ctrl+s>", "undo", "<enter>")20+ if m.changed {21+ t.Fatal("changed after save")22+ }23+ press(t, m, "<up>", "<delete>")24+ if !m.changed || input(m, "A2") != "" {25+ t.Fatalf("clear: changed %v A2 %q", m.changed, input(m, "A2"))26+ }27+ press(t, m, "<ctrl+home>", "<ctrl+z>")28+ if input(m, "A2") != "=A1*2" || m.cur != addr("A2") || m.changed {29+ t.Errorf("undo: A2 %q cur %v changed %v", input(m, "A2"), m.cur, m.changed)30+ }31+ if got := line(m, 2); got != "Undid: clear A2" {32+ t.Errorf("context line %q", got)33+ }34+ press(t, m, "<ctrl+z>", "<ctrl+z>")35+ if m.sheet.Len() != 0 || line(m, 2) != "Undid: edit A1" || !m.changed {36+ t.Errorf("undo all: %d cells, line %q, changed %v", m.sheet.Len(), line(m, 2), m.changed)37+ }38+ press(t, m, "<ctrl+z>")39+ if line(m, 2) != "Nothing to undo" {40+ t.Errorf("context line %q", line(m, 2))41+ }42+ press(t, m, "<ctrl+y>", "<ctrl+shift+z>")43+ if m.sheet.Value(addr("A2")).Num != 2 || line(m, 2) != "Redid: edit A2" {44+ t.Errorf("redo: A2 %+v line %q", m.sheet.Value(addr("A2")), line(m, 2))45+ }46+ // The note goes away with the next key.47+ press(t, m, "<down>")48+ if line(m, 2) != "" {49+ t.Errorf("note stayed: %q", line(m, 2))50+ }51+}52+53+func TestUndoColumnWidthPrompt(t *testing.T) {54+ m := newModel()55+ press(t, m, "<f10>", "f", "<enter>", "c", "<right>", "<right>", "<enter>")56+ if m.sheet.ColWidth(0) != 12 {57+ t.Fatalf("width %d", m.sheet.ColWidth(0))58+ }59+ press(t, m, "<ctrl+z>")60+ if m.sheet.ColWidth(0) != 10 || m.sheet.CanUndo() {61+ t.Errorf("width preview and result should be one step: width %d", m.sheet.ColWidth(0))62+ }63+ press(t, m, "<f10>", "f", "<enter>", "c", "<right>", "<esc>")64+ if m.sheet.CanUndo() || m.changed {65+ t.Errorf("cancelled width prompt left an undo step: %v %v %d %v", m.sheet.CanUndo(), m.changed, m.sheet.ColWidth(0), m.mode)66+ }67+}68+69+// clipboardText runs the copy command and returns what it sends to the70+// system clipboard.71+func clipboardText(t *testing.T, m *Model, id string) string {72+ t.Helper()73+ cmd := m.runCommand(id)74+ if cmd == nil {75+ t.Fatal("no clipboard command")76+ }77+ return fmt.Sprint(cmd())78+}79+80+func TestCopyPaste(t *testing.T) {81+ m := newModel()82+ press(t, m, "1", "<tab>", "=A1*2", "<enter>", "2", "<enter>", "<ctrl+home>", "<shift+right>")83+ if got := clipboardText(t, m, "edit.copy"); got != "1\t2" {84+ t.Errorf("system clipboard %q", got)85+ }86+ if !m.copyMarked(addr("B1")) || !strings.HasPrefix(line(m, 2), "Copied A1:B1 Ctrl+V paste") {87+ t.Fatalf("marker %v, line %q", m.copyMarked(addr("B1")), line(m, 2))88+ }89+ press(t, m, "<down>", "<ctrl+v>")90+ if input(m, "B2") != "=A2*2" || m.sheet.Value(addr("B2")).Num != 2 || m.selection().String() != "A2:B2" {91+ t.Errorf("paste: B2 %q = %+v, selection %v", input(m, "B2"), m.sheet.Value(addr("B2")), m.selection())92+ }93+ if line(m, 2) != "Pasted 2 cells at A2:B2" || !m.copied.marked {94+ t.Errorf("after paste: line %q marked %v", line(m, 2), m.copied.marked)95+ }96+ // Pasting into a larger multiple tiles.97+ press(t, m, "<down>", "<shift+down>", "<shift+down>", "<shift+right>", "<ctrl+v>")98+ if input(m, "B5") != "=A5*2" || m.selection().String() != "A3:B5" {99+ t.Errorf("tiled paste: B5 %q selection %v", input(m, "B5"), m.selection())100+ }101+ press(t, m, "<esc>")102+ if m.copied.marked || line(m, 2) != "" {103+ t.Errorf("Esc kept the marker: line %q", line(m, 2))104+ }105+ // Ctrl+V still pastes after the marker is gone.106+ press(t, m, "<right>", "<right>", "<ctrl+shift+v>")107+ if input(m, "C3") != "1" || input(m, "D3") != "2" {108+ t.Errorf("paste values: %q %q", input(m, "C3"), input(m, "D3"))109+ }110+}111+112+func TestEditClearsCopyMarker(t *testing.T) {113+ m := newModel()114+ press(t, m, "1", "<enter>", "<up>", "<ctrl+c>", "<down>", "x", "<enter>")115+ if m.copied.marked {116+ t.Error("an edit kept the copy marker")117+ }118+ press(t, m, "<ctrl+v>")119+ if input(m, "A3") != "1" {120+ t.Errorf("paste after edit: %q", input(m, "A3"))121+ }122+}123+124+func TestCutPasteMoves(t *testing.T) {125+ m := newModel()126+ press(t, m, "5", "<enter>", "=A1+1", "<enter>", "<ctrl+home>", "<ctrl+x>")127+ if !strings.HasPrefix(line(m, 2), "Cut A1 Ctrl+V move here") {128+ t.Errorf("context line %q", line(m, 2))129+ }130+ press(t, m, "<right>", "<right>", "<ctrl+v>")131+ if input(m, "A1") != "" || input(m, "C1") != "5" || input(m, "A2") != "=C1+1" {132+ t.Errorf("move: A1 %q C1 %q A2 %q", input(m, "A1"), input(m, "C1"), input(m, "A2"))133+ }134+ if m.copied.clip != nil || line(m, 2) != "Moved A1 to C1" {135+ t.Errorf("cut should paste once: line %q", line(m, 2))136+ }137+ press(t, m, "<ctrl+z>")138+ if input(m, "A1") != "5" || input(m, "A2") != "=A1+1" {139+ t.Errorf("undo move: A1 %q A2 %q", input(m, "A1"), input(m, "A2"))140+ }141+}142+143+func TestCopyMarkerDrawn(t *testing.T) {144+ m := newModel()145+ press(t, m, "1", "<enter>")146+ before := m.gridRow(0)147+ press(t, m, "<up>", "<ctrl+c>", "<down>")148+ if m.gridRow(0) == before || !strings.Contains(m.gridRow(0), "\x1b[") {149+ t.Errorf("copy marker not drawn: %q", m.gridRow(0))150+ }151+}152+153+func TestPasteTSVFromTerminal(t *testing.T) {154+ m := newModel()155+ press(t, m, "<right>")156+ send(m, tea.PasteMsg{Content: "Qty\tPrice\r\n2\t3\r\n\t=B2*C2\r\n"})157+ want := map[string]string{"B1": "Qty", "C1": "Price", "B2": "2", "C2": "3", "C3": "=B2*C2"}158+ for a, in := range want {159+ if input(m, a) != in {160+ t.Errorf("%s = %q, want %q", a, input(m, a), in)161+ }162+ }163+ if m.sheet.Value(addr("C3")).Num != 6 || m.selection().String() != "B1:C3" || line(m, 2) != "Pasted 6 cells at B1:C3" {164+ t.Errorf("C3 %+v selection %v line %q", m.sheet.Value(addr("C3")), m.selection(), line(m, 2))165+ }166+ press(t, m, "<ctrl+z>")167+ if m.sheet.Len() != 0 {168+ t.Errorf("paste should undo in one step, %d cells left", m.sheet.Len())169+ }170+ // A single line still starts an entry.171+ send(m, tea.PasteMsg{Content: "hello\n"})172+ if m.mode != modeEnter || string(m.buf) != "hello" {173+ t.Errorf("mode %v buf %q", m.mode, string(m.buf))174+ }175+}176+177+func TestTSVRoundTrip(t *testing.T) {178+ rows := [][]string{{"a", "tab\there", ""}, {`"quoted"`, "line\nbreak", `5" pipe`}, {"", "", ""}}179+ text := formatTSV(rows)180+ got := parseTSV(text)181+ if fmt.Sprint(got) != fmt.Sprint(rows) {182+ t.Errorf("round trip:\n%q\n%q", got, rows)183+ }184+ if got := parseTSV("a\t\tb\n\nc"); fmt.Sprint(got) != fmt.Sprint([][]string{{"a", "", "b"}, {""}, {"c"}}) {185+ t.Errorf("blank fields and lines: %q", got)186+ }187+}188+189+func TestFillKeys(t *testing.T) {190+ m := newModel()191+ press(t, m, "1", "<enter>", "2", "<enter>", "<ctrl+home>", "<right>", "=A1*10", "<enter>")192+ press(t, m, "<up>", "<shift+down>", "<ctrl+d>")193+ if input(m, "B2") != "=A2*10" || m.sheet.Value(addr("B2")).Num != 20 {194+ t.Errorf("fill down: B2 %q", input(m, "B2"))195+ }196+ press(t, m, "<shift+right>", "<ctrl+r>")197+ if input(m, "C2") != "=B2*10" {198+ t.Errorf("fill right: C2 %q", input(m, "C2"))199+ }200+ // Ctrl+Enter fills the entry into the selection, keeping it selected.201+ press(t, m, "<esc>", "<down>", "<down>", "<shift+down>", "<shift+right>", "=A1+$A$1", "<ctrl+enter>")202+ if input(m, "C4") != "=B2+$A$1" || m.mode != modeReady || m.selection().String() != "B3:C4" {203+ t.Errorf("ctrl+enter: C4 %q mode %v selection %v", input(m, "C4"), m.mode, m.selection())204+ }205+ press(t, m, "<ctrl+z>")206+ if input(m, "B3") != "" || input(m, "C4") != "" {207+ t.Error("ctrl+enter fill is not one undo step")208+ }209+}210+211+func TestInsertDeleteRowsAndColumns(t *testing.T) {212+ m := newModel()213+ press(t, m, "1", "<enter>", "2", "<enter>", "=SUM(A1:A2)", "<enter>")214+ press(t, m, "<up>", "<up>")215+ m.runCommand("insert.row_above")216+ if input(m, "A4") != "=SUM(A1:A3)" || input(m, "A2") != "" {217+ t.Fatalf("insert above: A4 %q", input(m, "A4"))218+ }219+ m.runCommand("insert.row_below")220+ if input(m, "A5") != "=SUM(A1:A4)" {221+ t.Errorf("insert below: A5 %q", input(m, "A5"))222+ }223+ press(t, m, "<ctrl+home>")224+ m.runCommand("delete.row")225+ if input(m, "A4") != "=SUM(A1:A3)" || m.sheet.Value(addr("A4")).Num != 2 {226+ t.Errorf("delete: A4 %q = %+v", input(m, "A4"), m.sheet.Value(addr("A4")))227+ }228+ m.runCommand("insert.col_left")229+ if input(m, "B4") != "=SUM(B1:B3)" {230+ t.Errorf("insert col: B4 %q", input(m, "B4"))231+ }232+ m.runCommand("insert.col_right")233+ press(t, m, "<right>")234+ m.runCommand("delete.col")235+ if input(m, "B4") != "=SUM(B1:B3)" {236+ t.Errorf("delete col: B4 %q", input(m, "B4"))237+ }238+ press(t, m, "<ctrl+z>")239+ if line(m, 2) != "Undid: delete 1 column" || m.whole != wholeCols {240+ t.Errorf("undo: line %q whole %v", line(m, 2), m.whole)241+ }242+ // Inserting can't push data off the sheet.243+ press(t, m, "<esc>", "<ctrl+end>", "<ctrl+space>")244+ m.runCommand("insert.row_above")245+ if m.mode != modeError {246+ t.Errorf("mode %v", m.mode)247+ }248+}249+250+func TestF4CyclesReferences(t *testing.T) {251+ m := newModel()252+ press(t, m, "=A1+B2")253+ for _, want := range []string{"=A1+$B$2", "=A1+B$2", "=A1+$B2", "=A1+B2"} {254+ press(t, m, "<f4>")255+ if string(m.buf) != want {256+ t.Errorf("F4 gave %q, want %q", string(m.buf), want)257+ }258+ }259+ // F4 while pointing puts the reference in first.260+ press(t, m, "*", "<up>", "<f4>")261+ if string(m.buf) != "=A1+B2*$A$1" || m.mode != modeEnter {262+ t.Errorf("F4 in POINT: %q mode %v", string(m.buf), m.mode)263+ }264+}265+266+func TestCycleRef(t *testing.T) {267+ tests := []struct {268+ in string269+ caret int270+ want string271+ ok bool272+ }{273+ {"=a1", 3, "=$A$1", true},274+ {"=A1+1", 1, "=$A$1+1", true},275+ {"=SUM(A1:B2)", 10, "=SUM($A$1:$B$2)", true},276+ {"=SUM(A1:B2)", 7, "=SUM($A$1:$B$2)", true},277+ {"=SUM($A$1..B2)", 12, "=SUM(A$1..B$2)", true},278+ {`="A1"`, 3, "", false},279+ {"=LOG10(2)", 6, "", false},280+ {"=1+2", 2, "", false},281+ }282+ for _, tt := range tests {283+ out, pos, ok := cycleRef([]rune(tt.in), tt.caret)284+ if ok != tt.ok || string(out) != tt.want {285+ t.Errorf("cycleRef(%q, %d) = %q, %v; want %q", tt.in, tt.caret, string(out), ok, tt.want)286+ }287+ if ok && pos > len(out) {288+ t.Errorf("caret %d past end of %q", pos, string(out))289+ }290+ }291+}292+293+func TestInsertDeleteKeysFollowSelection(t *testing.T) {294+ m := newModel()295+ press(t, m, "1", "<tab>", "2", "<enter>", "=A1+B1", "<enter>", "<ctrl+home>")296+ press(t, m, "<ctrl+alt+=>")297+ if input(m, "A3") != "=A2+B2" {298+ t.Errorf("Ctrl+Alt+= on a cell inserts a row: A3 %q", input(m, "A3"))299+ }300+ press(t, m, "<ctrl+alt+->")301+ press(t, m, "<ctrl+space>", "<ctrl+alt+=>")302+ if input(m, "B2") != "=B1+C1" {303+ t.Errorf("Ctrl+Alt+= on columns inserts a column: B2 %q", input(m, "B2"))304+ }305+ press(t, m, "<right>", "<right>", "<ctrl+space>", "<ctrl+alt+->")306+ if input(m, "B2") != "=B1+#REF!" {307+ t.Errorf("Ctrl+Alt+- on columns deletes them: B2 %q", input(m, "B2"))308+ }309+}
M internal/ui/entry.go +21 -9
@@ -1,7 +1,6 @@
11 package ui22 33 import (4- "errors"54 "slices"65 "strings"76 "unicode"
@@ -15,12 +14,12 @@ import (
1514 // Entry follows Google Sheets: typing replaces the cell (ENTER), Enter or1615 // F2 edits it in place (EDIT). Enter commits and moves down, Tab commits1716 // and moves right, Esc cancels. While typing a formula, arrow keys after an18-// operator point at cells (POINT) and insert their references.17+// operator point at cells (POINT) and insert their references. The18+// selection stays while typing, so Ctrl+Enter can fill it.1919 2020 func (m *Model) startEntry(md mode, text string) {2121 m.mode = md2222 m.buf, m.bufPos, m.hint = nil, 0, ""23- m.clearSelection()2423 m.insert(text)2524 }2625
@@ -50,6 +49,10 @@ func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {
5049 m.mode = modeEdit5150 return nil5251 }52+ if key == "f4" {53+ m.toggleAbsolute()54+ return nil55+ }5356 if m.isFormula() && m.canPoint() && m.startPoint(key) {5457 return nil5558 }
@@ -77,6 +80,8 @@ func (m *Model) editKey(k tea.KeyPressMsg) tea.Cmd {
7780 return nil7881 }7982 switch key {83+ case "f4":84+ m.toggleAbsolute()8085 case "up", "down", "pgup", "pgdown":8186 if m.commit() {8287 m.navigate(key, &m.cur)
@@ -95,6 +100,10 @@ func (m *Model) commitKey(key string) bool {
95100 default:96101 return false97102 }103+ if key == "ctrl+enter" && m.hasRange() {104+ m.fillEntry()105+ return true106+ }98107 if !m.commit() {99108 return true100109 }
@@ -191,6 +200,10 @@ func (m *Model) pointKey(k tea.KeyPressMsg) tea.Cmd {
191200 case "esc", "backspace":192201 m.resumeEntry("")193202 return nil203+ case "f4":204+ m.resumeEntry(m.point.text())205+ m.toggleAbsolute()206+ return nil194207 case "enter", "shift+enter", "tab", "shift+tab", "ctrl+enter":195208 m.resumeEntry(m.point.text())196209 m.commitKey(key)
@@ -220,15 +233,11 @@ func (m *Model) resumeEntry(ref string) {
220233 func (m *Model) commit() bool {221234 input := string(m.buf)222235 if err := m.set(m.cur, input); err != nil {223- var pe *sheet.ParseError224- m.mode = modeEdit225- m.hint = err.Error()226- if errors.As(err, &pe) {227- m.bufPos = utf8.RuneCountInString(input[:min(pe.Pos, len(input))])228- }236+ m.entryError(err, input)229237 return false230238 }231239 m.cancelEntry()240+ m.clearSelection()232241 return true233242 }234243
@@ -261,6 +270,9 @@ func (m *Model) insert(text string) {
261270 func (m *Model) handlePaste(content string) {262271 switch m.mode {263272 case modeReady:273+ if m.pasteText(content) {274+ return275+ }264276 if content = strings.TrimSpace(content); content != "" {265277 m.startEntry(modeEnter, content)266278 }
M internal/ui/model.go +36 -0
@@ -46,6 +46,10 @@ type Model struct {
4646 sheet *sheet.Sheet4747 filename string4848 changed bool49+ saved int // the sheet's StateID when last saved or loaded50+51+ copied clipboard // see clipboard.go52+ note string // feedback on the last action, e.g. "Undid: clear B3"4953 5054 cur sheet.Addr // the active cell5155 top, left int // first visible row and column
@@ -104,6 +108,7 @@ func (m *Model) Init() tea.Cmd {
104108 // Update implements tea.Model.105109 func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {106110 before, beforeMode := *m.focus(), m.mode111+ state := m.beginUpdate(msg)107112 var cmd tea.Cmd108113 switch msg := msg.(type) {109114 case tea.WindowSizeMsg:
@@ -127,6 +132,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
127132 m.handleWheel(msg.Mouse())128133 case savedMsg:129134 m.handleSaved(msg)135+ if msg.err == nil {136+ m.saved = m.sheet.StateID()137+ }130138 case loadedMsg:131139 m.handleLoaded(msg)132140 case filesMsg:
@@ -134,12 +142,40 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
134142 }135143 // Scroll only when the focus moves, so the mouse wheel can look around136144 // without the view snapping back, as in Sheets.145+ m.endUpdate(state)137146 if f := *m.focus(); f != before || m.mode != beforeMode {138147 m.scrollTo(f)139148 }140149 return m, cmd141150 }142151 152+// beginUpdate prepares for an input event and returns the sheet's state153+// before it. Each user action outside a prompt ends any run of column154+// width changes in the undo history, and clears the last action's note.155+func (m *Model) beginUpdate(msg tea.Msg) int {156+ switch msg.(type) {157+ case tea.KeyPressMsg, tea.MouseClickMsg, tea.PasteMsg:158+ m.note = ""159+ if m.mode != modePrompt {160+ m.sheet.Seal()161+ }162+ }163+ return m.sheet.StateID()164+}165+166+// endUpdate reacts to edits made while handling an event: the modified167+// flag follows the undo history, and any edit but a paste clears the copy168+// marker, as in Sheets.169+func (m *Model) endUpdate(state int) {170+ if m.sheet.StateID() != state {171+ m.changed = m.sheet.StateID() != m.saved172+ if !m.copied.keep {173+ m.clearCopyMark()174+ }175+ }176+ m.copied.keep = false177+}178+143179 func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {144180 switch m.mode {145181 case modeReady:
M internal/ui/model_test.go +1 -1
M internal/ui/theme.go +6 -0
M internal/ui/view.go +46 -8
@@ -9,6 +9,7 @@ import (
99 "strings"1010 1111 tea "charm.land/bubbletea/v2"12+ "charm.land/lipgloss/v2"1213 "github.com/charmbracelet/x/ansi"1314 1415 "one23/internal/sheet"
@@ -130,6 +131,8 @@ func (m *Model) panelLine3() string {
130131 return m.keyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "accept and move", "Esc", "cancel")131132 case m.mode == modeEdit:132133 return m.keyHints("Enter", "accept", "Left/Right", "move the caret", "Esc", "cancel")134+ case m.mode == modeReady:135+ return m.readyLine()133136 }134137 return ""135138 }
@@ -233,16 +236,26 @@ func (m *Model) gridRow(row int) string {
233236 if a == m.cur && (m.mode == modeEnter || m.mode == modeEdit) {234237 text = m.inCellText(m.sheet.ColWidth(a.Col))235238 }239+ var style lipgloss.Style240+ styled := true236241 switch {237242 case a == focus:238- b.WriteString(m.th.pointer.Render(text))243+ style = m.th.pointer239244 case selecting && sel.Contains(a):240- b.WriteString(m.th.selection.Render(text))245+ style = m.th.selection241246 case m.sheet.Value(a).Kind == sheet.Error:242- b.WriteString(m.th.errorCell.Render(text))247+ style = m.th.errorCell243248 default:244- b.WriteString(text)249+ styled = false245250 }251+ // The copy marker is layered on the cell's own colors.252+ if m.copyMarked(a) {253+ style, styled = style.Inherit(m.th.copied), true254+ }255+ if styled {256+ text = style.Render(text)257+ }258+ b.WriteString(text)246259 }247260 return b.String()248261 }
@@ -359,13 +372,17 @@ func (m *Model) helpLines() []string {
359372 }360373 }361374 slices.SortFunc(ids, func(a, b string) int { return strings.Compare(commands[a].title, commands[b].title) })362- for _, id := range ids {375+ entries := make([]string, len(ids))376+ width := 0377+ for i, id := range ids {363378 keys := keysFor(id)364- for i, k := range keys {365- keys[i] = keyLabel(k)379+ for j, k := range keys {380+ keys[j] = keyLabel(k)366381 }367- lines = append(lines, fmt.Sprintf(" %-20s%s", strings.Join(keys, " / "), commands[id].desc))382+ entries[i] = fmt.Sprintf("%-22s%s", strings.Join(keys, " / "), commands[id].title)383+ width = max(width, len(entries[i])+4)368384 }385+ lines = append(lines, columns(entries, width, m.width)...)369386 names := make([]string, 0, len(sheet.Funcs()))370387 for _, f := range sheet.Funcs() {371388 names = append(names, f.Name)
@@ -379,6 +396,27 @@ func (m *Model) helpLines() []string {
379396 return append(lines, wrapWords(" Functions: ", names, m.width)...)380397 }381398 399+// columns lays out entries in two columns, top to bottom, when two of the400+// given width fit on the screen, and in one column otherwise.401+func columns(entries []string, width, screen int) []string {402+ if 2+2*width > screen {403+ out := make([]string, len(entries))404+ for i, e := range entries {405+ out[i] = " " + e406+ }407+ return out408+ }409+ half := (len(entries) + 1) / 2410+ out := make([]string, half)411+ for i := range half {412+ out[i] = " " + entries[i]413+ if j := i + half; j < len(entries) {414+ out[i] = padRight(out[i], 2+width) + entries[j]415+ }416+ }417+ return out418+}419+382420 // wrapWords lays out words after prefix, wrapping to width and indenting383421 // continuation lines under the first word.384422 func wrapWords(prefix string, words []string, width int) []string {