The tabs sit at the left of the status line, the way tmux lists its windows: click to show a sheet, double-click to rename, right-click for its menu, drag to move, + to add. Ctrl+PgDn/PgUp (or Alt+Right/Left) and Shift+F11 work as in Sheets, Alt+Shift+K picks a sheet by name, and each sheet keeps its cursor. Switching sheets while typing a formula points into the other sheet and inserts Sheet2!A1. Go to, named ranges, tracing, cut and paste, and undo work across sheets, and find can search all sheets. XLSX downloads carry every sheet.
- commit
- 9088bd06b240e6aa4c14bb5896a7ee58b12d7d57
- parent
- cb0c2452e53827dd1906de4efa403dfe96903ebb
- author
- Dami <dami.etoile@gmail.com>
| M | ROADMAP.md | 2 | +- |
| M | e2e/ | 9 | +- |
| M | internal/ | 5 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 20 | +- |
| M | internal/ | 82 | +++-- |
| M | internal/ | 23 | + |
| M | internal/ | 33 | +- |
| M | internal/ | 2 | + |
| M | internal/ | 11 | +- |
| M | internal/ | 28 | +- |
| M | internal/ | 159 | +++++++-- |
| M | internal/ | 2 | +- |
| M | internal/ | 3 | + |
| M | internal/ | 25 | +- |
| M | internal/ | 8 | +- |
| M | internal/ | 9 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 41 | ++- |
| M | internal/ | 3 | +- |
| M | internal/ | 5 | +- |
| M | internal/ | 5 | +- |
| M | internal/ | 102 | ++++-- |
| M | internal/ | 1 | + |
| A | internal/ | 513 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 337 | ++++++++++++++++++++ |
| M | internal/ | 9 | + |
| M | internal/ | 46 | ++- |
| M | internal/ | 3 | + |
| M | internal/ | 2 | +- |
| M | internal/ | 7 | +- |
@@ -73,7 +73,7 @@ Avoid in the main binary: DuckDB and automerge (both need cgo).
7373 7474 ## Phase 5: beyond7575 76-- Multiple sheets with `B:A1` style references (1-2-3 R3), sheet tabs. Engine gets a sheet dimension. L.76+- Multiple sheets with references between them, in Sheets' style (`Sheet2!A1`) rather than 1-2-3 R3's `B:A1`, and sheet tabs. Done.7777 - Pivot tables as derived sheets. L.7878 - Macros: replay the command log; Starlark (`go.starlark.net`) for scripts, with step limits.7979 - Serve over SSH with `charm.land/wish/v2`.@@ -58,8 +58,15 @@ func TestOpenXLSXFromCommandLine(t *testing.T) {
5858 dir := t.TempDir()5959 writeXLSX(t, filepath.Join(dir, "q3.xlsx"))6060 s := start(t, dir, "q3.xlsx")61- s.waitFor("Imported q3.xlsx (5 rows); first sheet only, Q3; not imported: Q4")61+ s.waitFor("Imported q3.xlsx (6 rows)")6262 s.waitFor("$1,200.50")63+ // Every sheet comes in, each with its tab.64+ if !strings.HasPrefix(s.line(29), " Q3 Q4 +") {65+ t.Errorf("tabs %q", s.line(29))66+ }67+ s.keys("<ctrl+pgdown>")68+ s.waitForBar("A1", "later")69+ s.keys("<ctrl+pgup>")6370 s.keys("<f5>", "D5", "<enter>")6471 s.waitForBar("D5", "=SUM(D2:D4)")6572 s.waitFor("$2,814.75")@@ -89,6 +89,9 @@ type Change struct {
8989 Label string9090 Focus Rect9191 Sheet *Sheet92+ // Tabs is set when the step added, deleted, renamed or moved sheets;93+ // Focus means nothing then.94+ Tabs bool9295 }9396 9497 // Batch runs fn as a single undo step: everything it changes is undone@@ -396,7 +399,7 @@ func (w *Workbook) swap(from, to *[]*step) (Change, bool) {
396399 }397400 *to = append(*to, inv)398401 w.hist.mergeWidths = false399- return Change{st.label, st.focus, st.sheet}, true402+ return Change{st.label, st.focus, st.sheet, st.sheets != nil}, true400403 }401404 402405 // colRect is the range covering whole columns from..to.@@ -38,7 +38,7 @@ func TestUndoRedo(t *testing.T) {
3838 t.Fatalf("len %d state %d", s.Len(), s.StateID())3939 }4040 c, ok := s.Undo()41- if !ok || c != (Change{"clear A1:A2", NewRect(at("A1"), at("A2")), s}) || s.Value(at("A2")).Num != 20 {41+ if !ok || c != (Change{"clear A1:A2", NewRect(at("A1"), at("A2")), s, false}) || s.Value(at("A2")).Num != 20 {4242 t.Fatalf("undo erase: %v %v A2=%+v", c, ok, s.Value(at("A2")))4343 }4444 s.Undo()@@ -1,7 +1,6 @@
11 package sheet22 33 import (4- "reflect"54 "strings"65 "testing"76 )@@ -223,16 +222,16 @@ func TestPrecedentsAndDependents(t *testing.T) {
223222 "C1": "=A1+SUM(A1:A2)+Both+A1", "C2": "=A2*2", "C3": "=C1", "D1": "text",224223 })225224 s.DefineName("Both", rect("A2:B2"))226- if got, want := s.Precedents(at("C1")), []Rect{rect("A1"), rect("A1:A2"), rect("A2:B2")}; !reflect.DeepEqual(got, want) {225+ if got, want := targets(s, s.Precedents(at("C1"))), "A1 A1:A2 A2:B2"; got != want {227226 t.Errorf("precedents of C1 = %v, want %v", got, want)228227 }229228 if got := s.Precedents(at("D1")); got != nil {230229 t.Errorf("precedents of text: %v", got)231230 }232- if got, want := s.Dependents(at("A2")), []Addr{at("C1"), at("C2")}; !reflect.DeepEqual(got, want) {231+ if got, want := targets(s, s.Dependents(at("A2"))), "C1 C2"; got != want {233232 t.Errorf("dependents of A2 = %v, want %v", got, want)234233 }235- if got, want := s.Dependents(at("B2")), []Addr{at("C1")}; !reflect.DeepEqual(got, want) {234+ if got, want := targets(s, s.Dependents(at("B2"))), "C1"; got != want {236235 t.Errorf("dependents of B2 through a name = %v, want %v", got, want)237236 }238237 if got := s.Dependents(at("B1")); len(got) != 0 {@@ -240,6 +239,19 @@ func TestPrecedentsAndDependents(t *testing.T) {
240239 }241240 }242241 242+// targets writes traced ranges as seen from s, e.g. "A1 Data!B2:B3".243+func targets(s *Sheet, ts []Target) string {244+ var out []string245+ for _, t := range ts {246+ ref := t.Range.String()247+ if t.Sheet != s {248+ ref = Qualified(t.Sheet.Name(), t.Range)249+ }250+ out = append(out, ref)251+ }252+ return strings.Join(out, " ")253+}254+243255 // nameRefs lists a workbook's names and what they stand for, to compare244256 // names across workbooks.245257 func nameRefs(s *Sheet) string {@@ -1,64 +1,82 @@
11 package sheet22 3-import "slices"3+import (4+ "cmp"5+ "slices"6+)47 58 // Tracing, as Excel's Trace Precedents and Trace Dependents: which cells6-// a formula reads, and which formulas read a cell. Only direct links are7-// followed; tracing again from a found cell goes a level further.9+// a formula reads, and which formulas read a cell, on any sheet. Only10+// direct links are followed; tracing again from a found cell goes a level11+// further.12+13+// Target is a traced cell or range and the sheet it is on.14+type Target struct {15+ Sheet *Sheet16+ Range Rect17+}818 919 // Precedents returns the cells and ranges the formula at a reads, in the1020 // order the formula mentions them, with named ranges resolved and11-// repeats dropped. It is empty for anything but a formula.12-func (s *Sheet) Precedents(a Addr) []Rect {21+// repeats dropped. References to sheets that don't exist are left out. It22+// is empty for anything but a formula.23+func (s *Sheet) Precedents(a Addr) []Target {1324 c := s.cells[a]1425 if c == nil || !c.IsFormula() {1526 return nil1627 }17- var out []Rect18- add := func(r Rect) {19- if !slices.Contains(out, r) {20- out = append(out, r)28+ var out []Target29+ add := func(sheet string, r Rect) {30+ t := Target{s.wb.resolve(s, sheet), r}31+ if t.Sheet != nil && !slices.Contains(out, t) {32+ out = append(out, t)2133 }2234 }23- walkRefs(s.bound(c), func(sheet string, a Addr) {24- if sheet == "" {25- add(Rect{a, a})26- }27- }, func(sheet string, r Rect) {28- if sheet == "" {29- add(r)30- }31- })35+ walkRefs(s.bound(c), func(sheet string, a Addr) { add(sheet, Rect{a, a}) }, add)3236 return out3337 }3438 3539 // Dependents returns the formula cells that read a directly, through a36-// reference, a range or a named range, in row-major order.37-func (s *Sheet) Dependents(a Addr) []Addr {38- seen := map[Addr]bool{}40+// reference, a range or a named range: those on this sheet first in41+// row-major order, then those on other sheets in tab order.42+func (s *Sheet) Dependents(a Addr) []Target {43+ w := s.wb44+ seen := map[loc]bool{}3945 for d := range s.dependents[a] {40- seen[d] = true46+ seen[loc{s, d}] = true4147 }4248 for u := range s.rangeUsers.candidates(a.Col) {4349 for _, r := range s.cells[u].ranges {4450 if r.Contains(a) {45- seen[u] = true51+ seen[loc{s, u}] = true4652 }4753 }4854 }49- for k, users := range s.wb.nameUsers {50- if n, ok := s.wb.names[k]; ok && !n.Lost && n.Sheet == s && n.Range.Contains(a) {55+ for k, users := range w.nameUsers {56+ if n, ok := w.names[k]; ok && !n.Lost && n.Sheet == s && n.Range.Contains(a) {5157 for u := range users {52- if u.s == s {53- seen[u.a] = true54- }58+ seen[u] = true5559 }5660 }5761 }58- out := make([]Addr, 0, len(seen))59- for d := range seen {60- out = append(out, d)62+ for u := range w.crossUsers {63+ if w.crossReads(u, loc{s, a}) {64+ seen[u] = true65+ }66+ }67+ out := make([]Target, 0, len(seen))68+ for l := range seen {69+ out = append(out, Target{l.s, Rect{l.a, l.a}})6170 }62- sortAddrs(out)71+ rank := func(t *Sheet) int {72+ if t == s {73+ return -174+ }75+ return w.Index(t)76+ }77+ slices.SortFunc(out, func(x, y Target) int {78+ return cmp.Or(cmp.Compare(rank(x.Sheet), rank(y.Sheet)),79+ cmp.Compare(x.Range.From.Row, y.Range.From.Row), cmp.Compare(x.Range.From.Col, y.Range.From.Col))80+ })6381 return out6482 }@@ -462,3 +462,26 @@ func TestSplitSheet(t *testing.T) {
462462 }463463 }464464 }465+466+func TestTraceAcrossSheets(t *testing.T) {467+ w := bookOf(t,468+ page{"Sheet1", map[string]string{"A1": "1", "B1": "=A1+Data!A1+SUM('Q3 plan'!A1:A2)+Gone!A1", "B2": "=Plan"}},469+ page{"Data", map[string]string{"A1": "2", "B1": "=Sheet1!A1", "B2": "=A1"}},470+ page{"Q3 plan", map[string]string{"A1": "=Sheet1!A1*2"}},471+ )472+ one := w.Sheet(0)473+ w.DefineName("Plan", w.Sheet(2), NewRect(at("A1"), at("A1")))474+ if got, want := targets(one, one.Precedents(at("B1"))), "A1 Data!A1 'Q3 plan'!A1:A2"; got != want {475+ t.Errorf("precedents %q, want %q", got, want)476+ }477+ if got, want := targets(one, one.Precedents(at("B2"))), "'Q3 plan'!A1"; got != want {478+ t.Errorf("precedents through a name %q, want %q", got, want)479+ }480+ if got, want := targets(one, one.Dependents(at("A1"))), "B1 Data!B1 'Q3 plan'!A1"; got != want {481+ t.Errorf("dependents %q, want %q", got, want)482+ }483+ plan := w.Sheet(2)484+ if got, want := targets(plan, plan.Dependents(at("A1"))), "Sheet1!B1 Sheet1!B2"; got != want {485+ t.Errorf("dependents of Q3 plan!A1 %q, want %q", got, want)486+ }487+}@@ -18,9 +18,10 @@ import (
1818 // clipboard is what Ctrl+V pastes.1919 type clipboard struct {2020 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 marker21+ sheet *sheet.Sheet // where the clip was copied from22+ cut bool // pasting moves the cells rather than copying them23+ marked bool // the source shows the copy marker24+ keep bool // the last edit was a paste, which keeps the marker2425 }2526 2627 func init() {@@ -39,7 +40,7 @@ func init() {
3940 // copy puts the selection on the clipboard and the system clipboard.4041 func (m *Model) copy(cut bool) tea.Cmd {4142 clip := m.sheet.Copy(m.copyRange())42- m.copied = clipboard{clip: clip, cut: cut, marked: true}43+ m.copied = clipboard{clip: clip, sheet: m.sheet, cut: cut, marked: true}4344 return tea.SetClipboard(formatTSV(clip.Text()))4445 }4546 @@ -69,7 +70,12 @@ func (m *Model) paste(values bool) tea.Cmd {
6970 var r sheet.Rect7071 var err error7172 if c.cut && !values {72- r, err = m.sheet.Move(c.clip.Src, m.cur)73+ if !c.sheet.Live() {74+ m.copied = clipboard{}75+ m.note = "Nothing to move: the cut cells' sheet was deleted"76+ return nil77+ }78+ r, err = c.sheet.MoveTo(m.sheet, c.clip.Src, m.cur)7379 m.copied = clipboard{}7480 } else {7581 r, err = m.sheet.Paste(c.clip, m.selection(), values)@@ -82,7 +88,7 @@ func (m *Model) paste(values bool) tea.Cmd {
8288 m.selectRect(r)8389 switch {8490 case c.cut && !values:85- m.note = "Moved " + c.clip.Src.String() + " to " + r.String()91+ m.note = "Moved " + m.clipLabel(c) + " to " + r.String()8692 case values:8793 m.note = "Pasted values into " + countCells(r) + " at " + r.String()8894 default:@@ -93,7 +99,16 @@ func (m *Model) paste(values bool) tea.Cmd {
9399 94100 // copyMarked reports whether a shows the copy marker.95101 func (m *Model) copyMarked(a sheet.Addr) bool {96- return m.copied.marked && m.copied.clip.Src.Contains(a)102+ return m.copied.marked && m.copied.sheet == m.sheet && m.copied.clip.Src.Contains(a)103+}104+105+// clipLabel names the copied range, with its sheet when that isn't the106+// one shown: A1:B3, or Sheet1!A1:B3.107+func (m *Model) clipLabel(c clipboard) string {108+ if c.sheet != m.sheet {109+ return sheet.Qualified(c.sheet.Name(), c.clip.Src)110+ }111+ return c.clip.Src.String()97112 }98113 99114 // clearCopyMark hides the copy marker; a pending cut is cancelled, as in@@ -210,9 +225,9 @@ func (m *Model) readyLine() string {
210225 case m.note != "":211226 return m.note212227 case m.copied.marked && m.copied.cut:213- return "Cut " + m.copied.clip.Src.String() + " " + m.keyHints("Ctrl+V", "move here", "Esc", "cancel")228+ return "Cut " + m.clipLabel(m.copied) + " " + m.keyHints("Ctrl+V", "move here", "Esc", "cancel")214229 case m.copied.marked:215- text := "Copied " + m.copied.clip.Src.String() + " "230+ text := "Copied " + m.clipLabel(m.copied) + " "216231 full := text + m.keyHints("Ctrl+V", "paste", "Ctrl+Shift+V", "paste values", "Esc", "clear")217232 if ansi.StringWidth(full) <= m.width {218233 return full@@ -58,6 +58,8 @@ func (m *Model) rightClick(x, y int) {
5858 h := m.hitTest(x, y)5959 sel := m.selection()6060 switch h.kind {61+ case hitTab, hitTabAdd:62+ m.tabRightClick(h, x, y)6163 case hitCell:6264 if !m.hasRange() || !sel.Contains(h.addr) {6365 m.cur = h.addr@@ -103,7 +103,14 @@ func (m *Model) afterHistory(verb, none string, c sheet.Change, ok bool) {
103103 return104104 }105105 m.note = verb + ": " + c.Label106- m.selectRect(c.Focus)106+ i := m.book().Index(m.sheet)107+ if i < 0 {108+ i = m.book().Active()109+ }110+ m.afterSheetsChange(c.Sheet, i)111+ if !c.Tabs {112+ m.selectRect(c.Focus)113+ }107114 }108115 109116 // selectRect selects r, as whole columns or rows when it spans the sheet.@@ -146,7 +153,7 @@ func (m *Model) structural(err error) tea.Cmd {
146153 // references as if it were copied from the active cell (Ctrl+Enter).147154 func (m *Model) fillEntry() bool {148155 input := string(m.buf)149- if err := m.sheet.FillEntry(m.selection(), m.cur, input); err != nil {156+ if err := m.entrySheet().FillEntry(m.selection(), m.cur, input); err != nil {150157 m.entryError(err, input)151158 return false152159 }@@ -19,6 +19,7 @@ import (
1919 2020 func (m *Model) startEntry(md mode, text string) {2121 m.mode = md22+ m.home = nil2223 m.buf, m.bufPos, m.hint, m.assist = nil, 0, "", assist{}2324 m.insert(text)2425 }@@ -42,7 +43,7 @@ func (m *Model) isFormula() bool {
4243 // in a formula where a reference may follow the caret: then they point.4344 func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {4445 key := k.String()45- if m.assistKey(key) || m.commitKey(key) || m.cancelKey(key) {46+ if m.assistKey(key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {4647 return nil4748 }4849 if key == "f2" {@@ -73,7 +74,7 @@ func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {
7374 // editKey handles EDIT mode, where left and right move the caret.7475 func (m *Model) editKey(k tea.KeyPressMsg) tea.Cmd {7576 key := k.String()76- if m.assistKey(key) || m.commitKey(key) || m.cancelKey(key) {77+ if m.assistKey(key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {7778 return nil7879 }7980 if m.isFormula() && m.canPoint() && strings.HasPrefix(key, "shift+") && m.startPoint(key) {@@ -171,13 +172,17 @@ func (m *Model) canPoint() bool {
171172 }172173 173174 // startPoint enters POINT mode if key moves (Shift extends). The pointer174-// starts at the active cell.175+// starts at the active cell, or on another sheet where it last pointed.175176 func (m *Model) startPoint(key string) bool {177+ from := m.cur178+ if m.away() {179+ from = m.point.at180+ }176181 if base, ok := extendKey(key); ok {177182 key = base178- m.point = pointer{at: m.cur, anchor: m.cur, anchored: true}183+ m.point = pointer{at: from, anchor: from, anchored: true}179184 } else if isMoveKey(key) && key != "tab" && key != "shift+tab" {180- m.point = pointer{at: m.cur}185+ m.point = pointer{at: from}181186 } else {182187 return false183188 }@@ -193,7 +198,7 @@ func (m *Model) startPoint(key string) bool {
193198 // formula and carries on typing.194199 func (m *Model) pointKey(k tea.KeyPressMsg) tea.Cmd {195200 key := k.String()196- if m.pointMoveKey(key) {201+ if m.pointMoveKey(key) || m.sheetKey(key) {197202 return nil198203 }199204 switch key {@@ -201,11 +206,11 @@ func (m *Model) pointKey(k tea.KeyPressMsg) tea.Cmd {
201206 m.resumeEntry("")202207 return nil203208 case "f4":204- m.resumeEntry(m.point.text())209+ m.resumeEntry(m.pointRef())205210 m.toggleAbsolute()206211 return nil207212 case "enter", "shift+enter", "tab", "shift+tab", "ctrl+enter":208- m.resumeEntry(m.point.text())213+ m.resumeEntry(m.pointRef())209214 m.commitKey(key)210215 return nil211216 case ":":@@ -215,7 +220,7 @@ func (m *Model) pointKey(k tea.KeyPressMsg) tea.Cmd {
215220 }216221 }217222 if text := typed(k); text != "" {218- m.resumeEntry(m.point.text())223+ m.resumeEntry(m.pointRef())219224 m.insert(text)220225 m.assist = assist{active: true}221226 }@@ -243,14 +248,17 @@ func (m *Model) commit() bool {
243248 }244249 245250 func (m *Model) set(a sheet.Addr, input string) error {246- if err := m.sheet.Set(a, input); err != nil {251+ if err := m.entrySheet().Set(a, input); err != nil {247252 return err248253 }249254 m.changed = true250255 return nil251256 }252257 258+// cancelEntry ends the entry, showing its sheet again if the pointer259+// went into another.253260 func (m *Model) cancelEntry() {261+ m.returnHome()254262 m.mode = modeReady255263 m.buf, m.bufPos, m.hint, m.assist = nil, 0, "", assist{}256264 }@@ -2,6 +2,7 @@ package ui
22 33 import (44 "log/slog"5+ "slices"56 "strconv"67 "strings"78 @@ -15,21 +16,40 @@ import (
1516 // Find and replace is a bar on the context line rather than a dialog, in1617 // the spirit of less and vim: matches highlight in the grid as you type1718 // and the active cell follows the current one. Options are toggles shown18-// as chips and switched with Alt keys, as in VS Code's find widget.19+// as chips and switched with Alt keys, as in VS Code's find widget. The20+// scope chip says where to search, as Sheets' "Search" choice: this21+// sheet, all sheets, or the range selected when the bar opened; Alt+S22+// goes through them.1923 2024 type findBar struct {2125 replace bool // Ctrl+H: a replacement field follows the query2226 field int // 0 is the query, 1 the replacement2327 fields [2]string2428 opts sheet.FindOptions25- scope *sheet.Rect // the selection when the bar opened, if any26- scoped bool // search only within scope29+ scope *sheet.Rect // the selection when the bar opened, if any30+ home *sheet.Sheet // the sheet scope is on31+ where findScope2732 28- matches []sheet.Addr29- index map[sheet.Addr]int33+ matches []cellOn34+ index map[cellOn]int3035 cur int // index of the current match, -1 when none3136 err string // e.g. an invalid regular expression32- from sheet.Addr37+ from cellOn // where the search goes on from38+}39+40+// findScope is where the find bar searches.41+type findScope int42+43+const (44+ inSheet findScope = iota // the sheet shown45+ inAll // every sheet, in tab order46+ inRange // the range selected when the bar opened47+)48+49+// cellOn is a cell on a particular sheet.50+type cellOn struct {51+ s *sheet.Sheet52+ a sheet.Addr3353 }3454 3555 const findID = "find"@@ -58,12 +78,15 @@ func (m *Model) openFind(replace bool) {
5878 if f == nil {5979 f = &findBar{cur: -1}6080 }61- f.scope, f.scoped = nil, false81+ f.scope, f.home = nil, m.sheet82+ if f.where == inRange || f.where == inAll && m.book().Len() == 1 {83+ f.where = inSheet84+ }6285 if m.hasRange() {6386 r := m.selection()64- f.scope, f.scoped = &r, true87+ f.scope, f.where = &r, inRange6588 }66- f.from = m.cur89+ f.from = cellOn{m.sheet, m.cur}6790 m.clearSelection()6891 m.openOverlay(f)6992 // Load the kept query so focus doesn't overwrite it.@@ -102,40 +125,67 @@ func (f *findBar) changed(m *Model) {
102125 func (f *findBar) options() sheet.FindOptions {103126 o := f.opts104127 o.Within = nil105- if f.scoped {128+ if f.where == inRange {106129 o.Within = f.scope107130 }108131 return o109132 }110133 134+// sheets are the sheets searched, in order.135+func (f *findBar) sheets(m *Model) []*sheet.Sheet {136+ switch f.where {137+ case inAll:138+ return m.book().Sheets()139+ case inRange:140+ if f.home.Live() {141+ return []*sheet.Sheet{f.home}142+ }143+ }144+ return []*sheet.Sheet{m.sheet}145+}146+111147 // search finds all matches and makes the first one at or after where the112148 // search started the current one, so the view doesn't jump backwards113149 // while typing.114150 func (f *findBar) search(m *Model) {115151 f.err, f.matches, f.index, f.cur = "", nil, nil, -1116152 span := telemetry.Start("find")117- found, err := m.sheet.Find(f.fields[0], f.options())118- span.End(slog.Int("matches", len(found)))119- if err != nil {120- f.err = "Invalid regular expression"121- return153+ sheets := f.sheets(m)154+ for _, s := range sheets {155+ found, err := s.Find(f.fields[0], f.options())156+ if err != nil {157+ span.End(slog.Int("matches", 0))158+ f.err = "Invalid regular expression"159+ return160+ }161+ for _, a := range found {162+ f.matches = append(f.matches, cellOn{s, a})163+ }122164 }123- f.matches = found124- f.index = make(map[sheet.Addr]int, len(found))125- for i, a := range found {126- f.index[a] = i165+ span.End(slog.Int("matches", len(f.matches)), slog.Int("sheets", len(sheets)))166+ f.index = make(map[cellOn]int, len(f.matches))167+ for i, c := range f.matches {168+ f.index[c] = i127169 }128- if len(found) == 0 {170+ if len(f.matches) == 0 {129171 return130172 }173+ order := func(s *sheet.Sheet) int { return slices.Index(sheets, s) }131174 f.cur = 0132- for i, a := range found {133- if a.Row > f.from.Row || a.Row == f.from.Row && a.Col >= f.from.Col {175+ for i, c := range f.matches {176+ if d := order(c.s) - order(f.from.s); d > 0 || d == 0 && (c.a.Row > f.from.a.Row || c.a.Row == f.from.a.Row && c.a.Col >= f.from.a.Col) {134177 f.cur = i135178 break136179 }137180 }138- m.cur = found[f.cur]181+ f.show(m)182+}183+184+// show makes the current match the active cell, on its sheet.185+func (f *findBar) show(m *Model) {186+ c := f.matches[f.cur]187+ m.showSheet(c.s)188+ m.cur = c.a139189 }140190 141191 // step moves to the next (+1) or previous (-1) match, wrapping around.@@ -144,8 +194,21 @@ func (f *findBar) step(m *Model, d int) {
144194 return145195 }146196 f.cur = (f.cur + d + len(f.matches)) % len(f.matches)147- m.cur = f.matches[f.cur]148- f.from = m.cur197+ f.show(m)198+ f.from = f.matches[f.cur]199+}200+201+// nextScope is where Alt+S goes from where: the range (if there is one),202+// the sheet, then all sheets (if there are several).203+func (f *findBar) nextScope(m *Model) findScope {204+ order := []findScope{inSheet}205+ if m.book().Len() > 1 {206+ order = append(order, inAll)207+ }208+ if f.scope != nil {209+ order = append([]findScope{inRange}, order...)210+ }211+ return order[(slices.Index(order, f.where)+1)%len(order)]149212 }150213 151214 func (f *findBar) key(m *Model, k tea.KeyPressMsg) tea.Cmd {@@ -189,8 +252,9 @@ func (f *findBar) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
189252 f.opts.InFormulas = !f.opts.InFormulas190253 f.search(m)191254 case "alt+s":192- if f.scope != nil {193- f.scoped = !f.scoped255+ if next := f.nextScope(m); next != f.where {256+ f.where = next257+ f.from = cellOn{m.sheet, m.cur}194258 f.search(m)195259 }196260 default:@@ -208,8 +272,9 @@ func (f *findBar) replaceOne(m *Model) {
208272 if f.cur < 0 {209273 return210274 }211- a := f.matches[f.cur]212- changed, err := m.sheet.Replace(a, f.fields[0], f.fields[1], f.options())275+ c := f.matches[f.cur]276+ a := c.a277+ changed, err := c.s.Replace(a, f.fields[0], f.fields[1], f.options())213278 switch {214279 case err != nil:215280 m.note = "Can't replace in " + a.String() + ": " + err.Error()@@ -219,16 +284,27 @@ func (f *findBar) replaceOne(m *Model) {
219284 default:220285 m.changed = true221286 }222- f.from = a287+ f.from = c223288 f.search(m)224- if changed && f.cur >= 0 && f.matches[f.cur] == a {289+ if changed && f.cur >= 0 && f.matches[f.cur] == c {225290 f.step(m, 1) // the cell still matches; move past it226291 }227292 }228293 229294 func (f *findBar) replaceAll(m *Model) {295+ // Across sheets, still one undo step.230296 span := telemetry.Start("replace")231- n, err := m.sheet.ReplaceAll(f.fields[0], f.fields[1], f.options())297+ n := 0298+ err := m.book().Batch(sheet.Change{Label: "replace all", Sheet: m.sheet}, func() error {299+ for _, s := range f.sheets(m) {300+ k, err := s.ReplaceAll(f.fields[0], f.fields[1], f.options())301+ n += k302+ if err != nil {303+ return err304+ }305+ }306+ return nil307+ })232308 span.End(slog.Int("replaced", n))233309 if n > 0 {234310 m.changed = true@@ -282,8 +358,13 @@ func (f *findBar) parts(m *Model) []findPart {
282358 chip(f.opts.WholeCell, "Whole", "alt+w"),283359 chip(f.opts.Regex, ".*", "alt+r"),284360 chip(f.opts.InFormulas, "=", "alt+="))285- if f.scope != nil {286- parts = append(parts, chip(f.scoped, "in "+f.scope.String(), "alt+s"))361+ switch {362+ case f.where == inRange:363+ parts = append(parts, chip(true, "in "+f.scope.String(), "alt+s"))364+ case f.where == inAll:365+ parts = append(parts, chip(true, "in all sheets", "alt+s"))366+ case f.scope != nil || m.book().Len() > 1:367+ parts = append(parts, chip(false, "in "+m.sheet.Name(), "alt+s"))287368 }288369 return parts289370 }@@ -335,7 +416,11 @@ func (f *findBar) line(m *Model) (left, right string) {
335416 case len(f.matches) == 0:336417 right = m.th.warning.Render("No matches")337418 default:338- right = m.th.muted.Render(strconv.Itoa(f.cur+1) + " of " + strconv.Itoa(len(f.matches)))419+ where := ""420+ if f.where == inAll {421+ where = " on " + f.matches[f.cur].s.Name()422+ }423+ right = m.th.muted.Render(strconv.Itoa(f.cur+1) + " of " + strconv.Itoa(len(f.matches)) + where)339424 }340425 return b.String(), right341426 }@@ -397,7 +482,7 @@ func (f *findBar) status(m *Model) (string, string) {
397482 pairs = []string{"Enter", "replace", "Ctrl+Enter", "all", "Tab", "field", "Esc", "close"}398483 }399484 desc := "Alt+C/W/R/= options"400- if f.scope != nil {485+ if f.scope != nil || m.book().Len() > 1 {401486 desc = "Alt+C/W/R/=/S options"402487 }403488 for {@@ -421,6 +506,6 @@ func (m *Model) found(a sheet.Addr) bool {
421506 if !ok {422507 return false423508 }424- _, hit := f.index[a]509+ _, hit := f.index[cellOn{m.sheet, a}]425510 return hit426511 }@@ -84,7 +84,7 @@ func TestFindOptions(t *testing.T) {
8484 t.Errorf("match case: %v", f.matches)8585 }8686 press(t, m, "<alt+c>", "<alt+w>")87- if len(f.matches) != 1 || f.matches[0] != addr("A1") {87+ if len(f.matches) != 1 || f.matches[0].a != addr("A1") {8888 t.Errorf("whole cell: %v", f.matches)8989 }9090 press(t, m, "<alt+w>")@@ -120,6 +120,9 @@ func helpRows() []helpRow {
120120 {keys: []string{"Home", "Ctrl+Home"}, action: "Column A, cell A1"},121121 {keys: []string{"Ctrl+End"}, action: "Last used cell"},122122 }, "goto")123+ group("Sheets", []helpRow{124+ {keys: []string{"Click a tab"}, action: "Show it; double-click renames"},125+ }, "sheet.next", "sheet.prev", "sheet.new", "sheet.goto")123126 group("Selecting", []helpRow{124127 {keys: []string{"Shift+arrows"}, action: "Extend the selection"},125128 {keys: []string{"Ctrl+Shift+arrows"}, action: "Extend to the edge of the data"},@@ -64,19 +64,32 @@ func (m *Model) openRange(label string, onRange func(*Model, sheet.Rect) tea.Cmd
6464 func (m *Model) openGoto() {6565 m.openText("Go to:", m.cur.String(), func(m *Model, text string) tea.Cmd {6666 text = strings.TrimSpace(text)67- if a, ok := sheet.ParseAddr(text); ok {68- m.clearSelection()69- m.cur = a70- return nil67+ // A sheet may lead: Sheet2!A1, 'Q3 plan'!B2:C9, or just Sheet2!.68+ target := m.sheet69+ if name, rest := sheet.SplitSheet(text); name != "" {70+ if target = m.book().Lookup(name); target == nil {71+ m.fail("There's no sheet named " + name)72+ return nil73+ }74+ if text = rest; text == "" {75+ m.showSheet(target)76+ return nil77+ }7178 }7279 r, ok := sheet.ParseRange(text)73- if n, named := m.sheet.LookupName(text); named && !n.Lost {74- r, ok = n.Range, true80+ if n, named := m.sheet.LookupName(text); named && !n.Gone() && target == m.sheet {81+ r, ok, target = n.Range, true, n.Sheet7582 }7683 if !ok {7784 m.fail("Not a cell, range or named range: " + text)7885 return nil7986 }87+ m.showSheet(target)88+ if r.From == r.To {89+ m.clearSelection()90+ m.cur = r.From91+ return nil92+ }8093 m.selectRect(r)8194 return nil8295 })@@ -50,7 +50,12 @@ var menuBar = []menuDef{
5050 {cmd: "edit.fill_down", title: "Fill down"}, {cmd: "edit.fill_right", title: "Fill right"}, sep,5151 {cmd: "edit.find"}, {cmd: "edit.replace"}, sep,5252 {cmd: "clear"}, {cmd: "select.all"}, {cmd: "goto"}, sep,53- {cmd: "delete.row", title: "Delete row"}, {cmd: "delete.col", title: "Delete column"}, {cmd: "delete.selection"},53+ {cmd: "delete.row", title: "Delete row"}, {cmd: "delete.col", title: "Delete column"}, {cmd: "delete.selection"}, sep,54+ {title: "Sheet", items: []menuItem{55+ {cmd: "sheet.rename", title: "Rename"}, {cmd: "sheet.duplicate", title: "Duplicate"}, {cmd: "sheet.delete", title: "Delete"}, sep,56+ {cmd: "sheet.move_left", title: "Move left"}, {cmd: "sheet.move_right", title: "Move right"}, sep,57+ {cmd: "sheet.next"}, {cmd: "sheet.prev"}, {cmd: "sheet.goto"},58+ }},5459 }},5560 {title: "View", accel: 'v', items: []menuItem{5661 {title: "Freeze", items: []menuItem{@@ -65,6 +70,7 @@ var menuBar = []menuDef{
6570 {cmd: "insert.row_above", title: "Row above"}, {cmd: "insert.row_below", title: "Row below"}, sep,6671 {cmd: "insert.col_left", title: "Column left"}, {cmd: "insert.col_right", title: "Column right"}, sep,6772 {cmd: "insert.selection"}, sep,73+ {cmd: "sheet.new", title: "Sheet"}, sep,6874 {cmd: "insert.chart"},6975 }},7076 {title: "Format", accel: 'o', items: []menuItem{@@ -47,7 +47,7 @@ const doubleClick = 400 * time.Millisecond
4747 4848 // Model is the whole application state.4949 type Model struct {50- sheet *sheet.Sheet50+ sheet *sheet.Sheet // the sheet shown; its workbook is the file5151 filename string5252 changed bool5353 saved int // the sheet's StateID when last saved or loaded@@ -78,6 +78,11 @@ type Model struct {
7878 fillTo sheet.Rect // the range that drag would fill7979 shape string // pointer shape last sent to the terminal8080 81+ // Sheets: see tabs.go.82+ home *sheet.Sheet // while pointing into another sheet, the entry's sheet83+ places map[*sheet.Sheet]place // where each sheet's cursor and scroll were left84+ tabLeft int // the first tab shown when they don't all fit85+8186 // tabStart remembers where a run of Tab-committed entries began, so8287 // Enter returns to that column on the next row, as in Sheets.8388 tabStart int@@ -372,7 +377,7 @@ func (m *Model) handleWheel(mouse tea.Mouse) {
372377 // moving corner while extending a selection, otherwise the active cell.373378 func (m *Model) focus() *sheet.Addr {374379 switch {375- case m.mode == modePoint || m.pointing():380+ case m.mode == modePoint || m.pointing() || m.away():376381 return &m.point.at377382 case m.drag == dragFill:378383 return &m.fillAt@@ -18,7 +18,7 @@ var named = map[string]tea.Key{
1818 "tab": {Code: tea.KeyTab}, "delete": {Code: tea.KeyDelete}, "home": {Code: tea.KeyHome}, "end": {Code: tea.KeyEnd},1919 "up": {Code: tea.KeyUp}, "down": {Code: tea.KeyDown}, "left": {Code: tea.KeyLeft}, "right": {Code: tea.KeyRight},2020 "pgdown": {Code: tea.KeyPgDown}, "pgup": {Code: tea.KeyPgUp}, "f1": {Code: tea.KeyF1}, "f2": {Code: tea.KeyF2}, "f5": {Code: tea.KeyF5},21- "f10": {Code: tea.KeyF10}, "space": {Code: tea.KeySpace},21+ "f10": {Code: tea.KeyF10}, "f11": {Code: tea.KeyF11}, "space": {Code: tea.KeySpace},2222 }2323 2424 // press sends keys to m. Named keys go in angle brackets with optional@@ -31,6 +31,10 @@ const (
3131 hitStatus3232 hitFilterButton // the filter mark in a column header3333 hitFillHandle // the corner of the selection that drags out a fill34+ hitTab // a sheet's tab on the status line; addr.Col is its index35+ hitTabAdd // the + after the tabs36+ hitTabPrev // the ‹ before tabs scrolled off to the left37+ hitTabNext // the › after tabs scrolled off to the right3438 )3539 3640 type hit struct {@@ -91,6 +95,9 @@ func (m *Model) hitTest(x, y int) hit {
9195 }9296 return hit{kind: hitCell, addr: a}9397 case y == m.height-1:98+ if sp, ok := m.tabAt(x); ok {99+ return hit{kind: sp.kind, addr: sheet.Addr{Col: sp.index}}100+ }94101 return hit{kind: hitStatus}95102 }96103 return hit{}@@ -224,6 +231,12 @@ func (m *Model) handlePress(mouse tea.Mouse) tea.Cmd {
224231 }225232 226233 func (m *Model) leftPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {234+ if isTabHit(h.kind) && m.mode != modeMenu && m.mode != modePrompt {235+ if m.mode == modeError {236+ m.errMsg, m.mode = "", modeReady237+ }238+ return m.tabPress(h, double)239+ }227240 switch m.mode {228241 case modeError:229242 m.errMsg, m.mode = "", modeReady@@ -242,7 +255,7 @@ func (m *Model) leftPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {
242255 m.point = pointer{at: h.addr, anchor: h.addr}243256 m.drag = dragPoint244257 case hitEditLine:245- m.resumeEntry(m.point.text())258+ m.resumeEntry(m.pointRef())246259 m.setCaret(h.x)247260 }248261 return nil@@ -260,8 +273,10 @@ func (m *Model) leftPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {
260273 case h.kind == hitNone, h.kind == hitPanel, h.kind == hitStatus:261274 return nil262275 }263- // Clicking elsewhere accepts the entry, then acts as in READY.264- if !m.commit() {276+ // Clicking elsewhere accepts the entry, then acts as in READY:277+ // on the entry's sheet, unless the click was on another.278+ away := m.away()279+ if !m.commit() || away {265280 return nil266281 }267282 m.tabbing = false@@ -315,6 +330,11 @@ func (m *Model) readyPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {
315330 return nil316331 }317332 333+// isTabHit reports whether k is a part of the tab strip.334+func isTabHit(k hitKind) bool {335+ return k == hitTab || k == hitTabAdd || k == hitTabPrev || k == hitTabNext336+}337+318338 // setCaret puts the edit caret at display column x of the entry.319339 func (m *Model) setCaret(x int) {320340 w := 0@@ -341,10 +361,13 @@ func (m *Model) handleMotion(mouse tea.Mouse) tea.Cmd {
341361 // dragTo extends the drag in progress to (x, y), starting autoscroll when342362 // the mouse is past the edge of the grid.343363 func (m *Model) dragTo(x, y int) tea.Cmd {344- if m.drag == dragResize {364+ switch m.drag {365+ case dragResize:345366 m.sheet.SetColWidth(m.resizeCol, clamp(x-m.colStart(m.resizeCol)+1, 1, 240))346367 m.changed = true347368 return nil369+ case dragTab:370+ return nil // handleMotion tracks the tab under the mouse348371 }349372 a, dc, dr := m.dragTarget(x, y)350373 switch m.drag {@@ -375,7 +398,7 @@ func autoscrollTick() tea.Cmd {
375398 // handleAutoscroll moves a drag that's past the edge of the grid one more376399 // step and keeps ticking until the mouse comes back or is released.377400 func (m *Model) handleAutoscroll() tea.Cmd {378- if m.drag == dragNone || m.drag == dragResize {401+ if m.drag == dragNone || m.drag == dragResize || m.drag == dragTab {379402 m.autoscrolling = false380403 return nil381404 }@@ -403,8 +426,11 @@ func (m *Model) handleAutoscroll() tea.Cmd {
403426 }404427 405428 func (m *Model) handleRelease() tea.Cmd {406- if m.drag == dragFill {429+ switch m.drag {430+ case dragFill:407431 m.finishFill()432+ case dragTab:433+ m.dropTab()408434 }409435 m.drag, m.autoscrolling = dragNone, false410436 if m.selecting && m.whole == wholeNone && m.ext == m.cur {@@ -453,7 +479,8 @@ func (m *Model) pointerShape() tea.Cmd {
453479 shape = "cell"454480 case m.hover.kind == hitFormulaBar, m.hover.kind == hitEditLine:455481 shape = "text"456- case m.hover.kind == hitColHeader, m.hover.kind == hitRowHeader, m.hover.kind == hitCorner, m.hover.kind == hitFilterButton:482+ case m.hover.kind == hitColHeader, m.hover.kind == hitRowHeader, m.hover.kind == hitCorner, m.hover.kind == hitFilterButton,483+ isTabHit(m.hover.kind):457484 shape = "pointer"458485 }459486 if shape == m.shape {@@ -128,7 +128,8 @@ func TestPointerShapes(t *testing.T) {
128128 {rowHdrW + sheet.DefaultWidth - 1, headerLine, "col-resize"},129129 {cellX(1), headerLine, "pointer"},130130 {formulaBarTextX() + 1, formulaLine, "text"},131- {1, m.height - 1, "default"},131+ {m.width - 1, m.height - 1, "default"},132+ {1, m.height - 1, "pointer"}, // the sheet tab132133 } {133134 send(m, tea.MouseMotionMsg{X: tt.x, Y: tt.y})134135 if m.shape != tt.want {@@ -58,15 +58,18 @@ func namesItems(m *Model, sel sheet.Rect) []pickItem {
5858 switch users := m.sheet.NameUsers(n.Name); {5959 case n.Lost:6060 desc = "Its cells were deleted; formulas using it show #REF!"61+ case n.Gone():62+ desc = "Its sheet was deleted; formulas using it show #REF!"6163 case users == 1:6264 desc += ", used in 1 formula"6365 case users > 1:6466 desc += ", used in " + strconv.Itoa(users) + " formulas"6567 }6668 items = append(items, pickItem{67- title: n.Name, name: len(n.Name), detail: n.Ref(), desc: desc, off: n.Lost,69+ title: n.Name, name: len(n.Name), detail: n.Ref(), desc: desc, off: n.Gone(),6870 pick: func(m *Model) tea.Cmd {6971 m.closeOverlay()72+ m.showSheet(n.Sheet)7073 m.selectRect(n.Range)7174 return nil7275 },@@ -91,7 +91,10 @@ func TestPaletteTitleMatchesFirst(t *testing.T) {
9191 seenPathOnly := false9292 for _, pm := range shown {9393 inTitle := strings.Contains(strings.ToLower(pm.item.title), "sel")94- if !inTitle && pm.item.title != "Select all" {94+ // Titles matching letter by letter ("Move sheet left") are95+ // title matches too, just not literal ones.96+ pathOnly := len(pm.inTitle) < len("sel")97+ if pathOnly && pm.item.title != "Select all" {9598 seenPathOnly = true9699 } else if seenPathOnly && inTitle {97100 t.Errorf("%q ranked after a path-only match", pm.item.title)@@ -114,18 +114,21 @@ func formulaBarTextX() int { return nameBoxW + 1 }
114114 // edited here with the terminal cursor, and shown in the cell too.115115 func (m *Model) formulaBar() string {116116 name := m.cur.String()117+ if m.away() {118+ name = sheet.Qualified(m.home.Name(), sheet.Rect{From: m.cur, To: m.cur})119+ }117120 if m.hasRange() && (m.mode == modeReady || m.mode == modeMenu) {118121 name = m.selection().String()119122 }120123 if n, ok := m.namedSelection(); ok && (m.mode == modeReady || m.mode == modeMenu) {121124 name = ansi.Truncate(n, nameBoxW-1, "…")122125 }123- box := m.th.header.Render(padRight(" "+name, nameBoxW)) + " "126+ box := m.th.header.Render(padRight(" "+ansi.Truncate(name, nameBoxW-1, "…"), nameBoxW)) + " "124127 switch m.mode {125128 case modeEnter, modeEdit:126129 return box + string(m.buf)127130 case modePoint:128- return box + m.pointPrefix + m.th.selection.Render(m.point.text()) + m.pointSuffix131+ return box + m.pointPrefix + m.th.selection.Render(m.pointRef()) + m.pointSuffix129132 }130133 if c := m.sheet.Cell(m.cur); c != nil {131134 return box + c.Input@@ -249,11 +252,21 @@ func (m *Model) cursorPos() (x, y int, ok bool) {
249252 }250253 251254 func (m *Model) statusLine() string {255+ line, _ := m.statusLayout()256+ return line257+}258+259+// statusLayout is the status line and where its sheet tabs are. From the260+// left: the tabs, the file name and its state, then selection statistics261+// or the ways in to everything else on the right. When space runs out,262+// the right side gives up detail first, then the file name, then tabs263+// scroll.264+func (m *Model) statusLayout() (string, []tabSpan) {252265 if m.mode == modeError {253- return m.th.error.Render(m.errMsg) + m.th.muted.Render(" press any key")266+ return m.th.error.Render(m.errMsg) + m.th.muted.Render(" press any key"), nil254267 }255268 if m.xfer.job != nil {256- return m.importStatus()269+ return m.importStatus(), nil257270 }258271 desc, keys, floating := m.assistStatus()259272 if m.overlay != nil {@@ -266,26 +279,64 @@ func (m *Model) statusLine() string {
266279 if room := m.width - ansi.StringWidth(keys) - 3; room >= 12 {267280 desc = ansi.Truncate(desc, room, "…")268281 }269- return m.spread(desc, keys)282+ return m.spread(desc, keys), nil283+ }284+ }285+ name, state := m.displayName(), m.statusState()286+ infos := []string{name + state, strings.TrimPrefix(state, " ")}287+ // First try to show every tab, then half the line of them, then just288+ // the one shown.289+ for _, need := range []int{m.allTabs(), min(m.allTabs(), m.width/2), m.minTabs()} {290+ for _, info := range infos {291+ if info != "" {292+ info = " " + info293+ }294+ for _, right := range m.statusRights() {295+ room := m.width - ansi.StringWidth(info)296+ if right != "" {297+ room -= ansi.StringWidth(right) + 3298+ }299+ if room < need && (right != "" || info != "") {300+ continue301+ }302+ tabs, spans := m.tabStrip(room)303+ left := tabs + info304+ gap := max(m.width-ansi.StringWidth(left)-ansi.StringWidth(right), 1)305+ return left + strings.Repeat(" ", gap) + right, spans306+ }270307 }271308 }272- left := m.displayName()309+ return "", nil // not reached: the last choice has no right side or info310+}311+312+// statusState is what the status line says about the file after its313+// name: modified, decimal arithmetic, a circular reference, rows hidden314+// by the filter, JEV at work. Each part starts with two spaces.315+func (m *Model) statusState() string {316+ var b strings.Builder273317 if m.changed {274- left += m.th.muted.Render(" modified")318+ b.WriteString(m.th.muted.Render(" modified"))275319 }276- if m.sheet.Book().Decimal() {277- left += m.th.muted.Render(" decimal")320+ if m.book().Decimal() {321+ b.WriteString(m.th.muted.Render(" decimal"))278322 }279- if m.sheet.Book().Circular {280- left += " " + m.th.warning.Render("Circular reference")323+ if m.book().Circular {324+ b.WriteString(" " + m.th.warning.Render("Circular reference"))281325 }282326 if n := m.sheet.HiddenRows(); n > 0 {283- left += " " + m.th.hint.Render("Filter hides "+rowCount(n))327+ b.WriteString(" " + m.th.hint.Render("Filter hides "+rowCount(n)))284328 }285329 if busy := m.jevBusy(); busy != "" {286- left += " " + m.th.hint.Render(busy)330+ b.WriteString(" " + m.th.hint.Render(busy))287331 }288- var right string332+ return b.String()333+}334+335+// statusRights are the choices for the right of the status line, most336+// detailed first, ending with nothing: statistics of the selection, or337+// the keys that open the palette, the shortcuts and the menu.338+func (m *Model) statusRights() []string {339+ var out []string289340 if m.hasRange() && m.mode == modeReady {290341 r := m.selection()291342 st := m.sheet.RangeStats(r)@@ -298,24 +349,15 @@ func (m *Model) statusLine() string {
298349 count := m.th.muted.Render("Count ") + strconv.Itoa(st.Count)299350 // As many stats as fit: Avg goes first, then Sum, then Count.300351 for _, parts := range [][]string{{rng, sum, avg, count}, {rng, sum, count}, {rng, count}, {rng}} {301- right = strings.Join(slices.DeleteFunc(parts, func(p string) bool { return p == "" }), " ")302- if ansi.StringWidth(left)+3+ansi.StringWidth(right) <= m.width {303- break304- }305- }306- } else {307- // The ways in to everything else, as many as fit.308- pairs := []string{shortcut("palette"), "search", shortcut("help"), "shortcuts", shortcut("menu"), "menu"}309- for len(pairs) > 0 {310- right = m.keyHints(pairs...)311- if ansi.StringWidth(left)+3+ansi.StringWidth(right) <= m.width {312- break313- }314- pairs, right = pairs[:len(pairs)-2], ""352+ out = append(out, strings.Join(slices.DeleteFunc(parts, func(p string) bool { return p == "" }), " "))315353 }354+ return append(out, "")316355 }317- gap := max(m.width-ansi.StringWidth(left)-ansi.StringWidth(right), 1)318- return left + strings.Repeat(" ", gap) + right356+ pairs := []string{shortcut("palette"), "search", shortcut("help"), "shortcuts", shortcut("menu"), "menu"}357+ for ; len(pairs) > 0; pairs = pairs[:len(pairs)-2] {358+ out = append(out, m.keyHints(pairs...))359+ }360+ return append(out, "")319361 }320362 321363 // fmtStat formats a status line statistic with at most two decimals, as@@ -32,6 +32,7 @@ const (
3232 dragPoint // dragging out a range in POINT mode or a range prompt3333 dragResize // dragging a column header border3434 dragFill // dragging the fill handle35+ dragTab // dragging a sheet's tab to move it3536 )3637 3738 // selection returns the selected range; just the active cell when nothing@@ -0,0 +1,513 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// Sheets work as in Google Sheets: Ctrl+PgDn and Ctrl+PgUp (or Alt+Right14+// and Alt+Left, for terminals that keep Ctrl+PgUp/PgDn for their own15+// tabs) move between sheets, Shift+F11 adds one, and each sheet remembers16+// its cursor and scroll position. The tabs sit at the left of the status17+// line, the way tmux lists windows: the sheet shown is highlighted, a +18+// adds a sheet, and when they don't all fit, ‹ and › step through them.19+// Clicking a tab shows it, double-clicking renames it, right-clicking20+// opens its menu, and dragging it moves it.21+//22+// While a formula is being typed, switching sheets points into the other23+// sheet, as clicking a tab does in Sheets: the entry stays with its cell24+// (m.home), and the pointer inserts references such as Sheet2!A1. Enter25+// stores the formula and returns to its sheet.26+27+// place is where a sheet's cursor and scroll position were when it was28+// last shown.29+type place struct {30+ cur sheet.Addr31+ top, left int32+}33+34+// maxTabName is how much of a long sheet name a tab shows.35+const maxTabName = 2036+37+func init() {38+ register(39+ &command{id: "sheet.new", title: "New sheet", desc: "Add an empty sheet after the others", run: func(m *Model) tea.Cmd {40+ s, err := m.book().AddSheet("", m.book().Len())41+ if err != nil {42+ m.fail(err.Error())43+ return nil44+ }45+ m.showSheet(s)46+ m.note = "Added " + s.Name()47+ return nil48+ }},49+ &command{id: "sheet.next", title: "Next sheet", desc: "Show the sheet to the right", enabled: func(m *Model) bool { return m.sheetAt(1) != nil },50+ run: func(m *Model) tea.Cmd { return m.stepSheet(1) }},51+ &command{id: "sheet.prev", title: "Previous sheet", desc: "Show the sheet to the left", enabled: func(m *Model) bool { return m.sheetAt(-1) != nil },52+ run: func(m *Model) tea.Cmd { return m.stepSheet(-1) }},53+ &command{id: "sheet.goto", title: "Go to sheet", desc: "Pick a sheet by name", run: func(m *Model) tea.Cmd {54+ m.openSheetPicker()55+ return nil56+ }},57+ &command{id: "sheet.rename", title: "Rename sheet", desc: "Give the sheet a new name; formulas that use it follow", run: func(m *Model) tea.Cmd {58+ m.openRename()59+ return nil60+ }},61+ &command{id: "sheet.duplicate", title: "Duplicate sheet", desc: "Copy the sheet, with its formatting and charts, to a new sheet after it", run: func(m *Model) tea.Cmd {62+ cp, err := m.book().DuplicateSheet(m.sheet)63+ if err != nil {64+ m.fail(err.Error())65+ return nil66+ }67+ m.showSheet(cp)68+ m.note = "Duplicated as " + cp.Name()69+ return nil70+ }},71+ &command{id: "sheet.delete", title: "Delete sheet", desc: "Delete the sheet; formulas that use it show #REF!",72+ enabled: func(m *Model) bool { return m.book().Len() > 1 }, run: (*Model).confirmDeleteSheet},73+ &command{id: "sheet.move_left", title: "Move sheet left", desc: "Move the sheet's tab one place left", enabled: func(m *Model) bool { return m.sheetAt(-1) != nil },74+ run: func(m *Model) tea.Cmd { return m.moveSheet(-1) }},75+ &command{id: "sheet.move_right", title: "Move sheet right", desc: "Move the sheet's tab one place right", enabled: func(m *Model) bool { return m.sheetAt(1) != nil },76+ run: func(m *Model) tea.Cmd { return m.moveSheet(1) }},77+ )78+ keymap["shift+f11"] = "sheet.new"79+ keymap["ctrl+pgdown"] = "sheet.next"80+ keymap["alt+right"] = "sheet.next"81+ keymap["ctrl+pgup"] = "sheet.prev"82+ keymap["alt+left"] = "sheet.prev"83+ keymap["alt+shift+k"] = "sheet.goto"84+ keyAliases["alt+K"] = "alt+shift+k"85+}86+87+// tabMenu is the menu of a sheet's tab, as in Sheets.88+var tabMenu = []menuItem{89+ {cmd: "sheet.rename", title: "Rename"}, {cmd: "sheet.duplicate", title: "Duplicate"}, {cmd: "sheet.delete", title: "Delete"}, sep,90+ {cmd: "sheet.move_left", title: "Move left"}, {cmd: "sheet.move_right", title: "Move right"}, sep,91+ {cmd: "sheet.new"}, {cmd: "sheet.goto"},92+}93+94+func (m *Model) book() *sheet.Workbook { return m.sheet.Book() }95+96+// sheetAt returns the sheet d tabs from the one shown, or nil past the97+// ends: moving between sheets doesn't wrap around, as in Sheets.98+func (m *Model) sheetAt(d int) *sheet.Sheet {99+ i := m.book().Index(m.sheet) + d100+ if i < 0 || i >= m.book().Len() {101+ return nil102+ }103+ return m.book().Sheet(i)104+}105+106+func (m *Model) stepSheet(d int) tea.Cmd {107+ if s := m.sheetAt(d); s != nil {108+ m.switchTo(s)109+ }110+ return nil111+}112+113+func (m *Model) moveSheet(d int) tea.Cmd {114+ i := m.book().Index(m.sheet)115+ m.book().MoveSheet(m.sheet, i+d)116+ return nil117+}118+119+// switchTo shows s: plainly in READY, or pointing into it while a formula120+// is typed.121+func (m *Model) switchTo(s *sheet.Sheet) {122+ if m.editing() {123+ m.pointInto(s)124+ return125+ }126+ m.showSheet(s)127+}128+129+// showSheet shows s where it was left, remembering where the sheet shown130+// now is. The selection doesn't carry over; the copy marker and a trace131+// stay with their own sheets.132+func (m *Model) showSheet(s *sheet.Sheet) {133+ if s == m.sheet || s == nil {134+ return135+ }136+ m.leave()137+ m.sheet = s138+ p := m.places[s]139+ m.cur, m.top, m.left = p.cur, p.top, p.left140+ m.clearSelection()141+ m.lastChart = -1142+ m.book().SetActive(s)143+}144+145+// leave remembers where the sheet shown is, before another is shown.146+func (m *Model) leave() {147+ if m.places == nil {148+ m.places = map[*sheet.Sheet]place{}149+ }150+ cur := m.cur151+ if m.away() {152+ cur = m.point.at // the entry's cell is on another sheet153+ }154+ m.places[m.sheet] = place{cur: cur, top: m.top, left: m.left}155+}156+157+// away reports whether an entry is being typed for a cell on another158+// sheet than the one shown.159+func (m *Model) away() bool { return m.home != nil && m.home != m.sheet }160+161+// entrySheet is the sheet the entry being typed goes to.162+func (m *Model) entrySheet() *sheet.Sheet {163+ if m.home != nil {164+ return m.home165+ }166+ return m.sheet167+}168+169+// pointInto shows s while a formula is being typed, pointing at the cell170+// last active there so arrows go on from it. An entry that isn't a171+// formula ready for a reference is stored first, as a click elsewhere172+// would.173+func (m *Model) pointInto(s *sheet.Sheet) {174+ if s == m.sheet {175+ return176+ }177+ if m.mode != modePoint {178+ if !m.isFormula() || !m.canPoint() {179+ if m.commit() {180+ m.showSheet(s)181+ }182+ return183+ }184+ m.pointPrefix = string(m.buf[:m.bufPos])185+ m.pointSuffix = string(m.buf[m.bufPos:])186+ m.mode = modePoint187+ }188+ if m.home == nil {189+ m.home = m.sheet190+ }191+ cur := m.cur192+ m.leave()193+ m.sheet = s194+ p := m.places[s]195+ if s == m.home {196+ p.cur = cur // back home, pointing starts at the entry's cell197+ }198+ m.top, m.left = p.top, p.left199+ m.cur = cur200+ m.point = pointer{at: p.cur}201+ m.book().SetActive(s)202+}203+204+// returnHome shows the entry's sheet again once the entry is stored or205+// cancelled.206+func (m *Model) returnHome() {207+ home := m.home208+ m.home = nil209+ if home == nil || home == m.sheet || !home.Live() {210+ return211+ }212+ cur := m.cur213+ m.places[m.sheet] = place{cur: m.point.at, top: m.top, left: m.left}214+ m.sheet = home215+ p := m.places[home]216+ m.cur, m.top, m.left = cur, p.top, p.left217+ m.book().SetActive(home)218+}219+220+// pointRef is the reference being pointed at as it goes into the formula:221+// with the sheet's name when it's on another sheet than the entry's.222+func (m *Model) pointRef() string {223+ if m.away() {224+ return sheet.Qualified(m.sheet.Name(), m.point.rect())225+ }226+ return m.point.text()227+}228+229+// afterSheetsChange keeps a sheet on screen after undo, redo or a delete230+// took the shown one away, and forgets deleted sheets' places.231+func (m *Model) afterSheetsChange(prefer *sheet.Sheet, index int) {232+ book := m.book()233+ switch {234+ case prefer != nil && prefer.Live():235+ m.showSheet(prefer)236+ case !m.sheet.Live():237+ s := book.Sheet(clamp(index, 0, book.Len()-1))238+ m.sheet = s // the old sheet is gone: nothing to remember of it239+ p := m.places[s]240+ m.cur, m.top, m.left = p.cur, p.top, p.left241+ m.clearSelection()242+ m.lastChart = -1243+ book.SetActive(s)244+ }245+}246+247+// confirmDeleteSheet deletes the sheet shown, asking first when it has248+// contents. Undo brings it back either way.249+func (m *Model) confirmDeleteSheet() tea.Cmd {250+ s := m.sheet251+ del := func(m *Model) tea.Cmd {252+ i := m.book().Index(s)253+ if err := m.book().DeleteSheet(s); err != nil {254+ m.fail(err.Error())255+ return nil256+ }257+ m.afterSheetsChange(nil, i)258+ m.note = "Deleted " + s.Name() + "; Ctrl+Z brings it back"259+ return nil260+ }261+ n := s.Len()262+ if n == 0 && len(s.Charts()) == 0 {263+ return del(m)264+ }265+ what := "Delete " + s.Name() + " and its " + cellCount(n) + "?"266+ if n == 0 {267+ what = "Delete " + s.Name() + " and its charts?"268+ }269+ m.openOverlay(&choiceBar{msg: what, warn: true, choices: []choice{270+ {key: "enter", label: "Delete", run: del},271+ {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},272+ }})273+ return nil274+}275+276+// openRename asks for the sheet's new name on the context line.277+func (m *Model) openRename() {278+ s := m.sheet279+ m.openText("Rename sheet:", s.Name(), func(m *Model, text string) tea.Cmd {280+ old := s.Name()281+ if err := m.book().RenameSheet(s, text); err != nil {282+ m.fail(err.Error())283+ return nil284+ }285+ if old != s.Name() {286+ m.note = "Renamed " + old + " to " + s.Name()287+ }288+ return nil289+ })290+}291+292+// openSheetPicker lists the sheets, fzf style, with the one shown293+// highlighted.294+func (m *Model) openSheetPicker() {295+ var items []pickItem296+ sel := 0297+ for i, s := range m.book().Sheets() {298+ if s == m.sheet {299+ sel = i300+ }301+ detail := "empty"302+ if used, ok := s.UsedRange(); ok {303+ detail = used.String() + ", " + cellCount(s.Len())304+ }305+ items = append(items, pickItem{306+ title: s.Name(), name: len(s.Name()), detail: detail, desc: "Show " + s.Name(),307+ pick: func(m *Model) tea.Cmd {308+ m.closeOverlay()309+ m.showSheet(s)310+ return nil311+ },312+ })313+ }314+ p := newPicker(m, "Go to sheet", "Type a sheet name", 60, items)315+ p.action = "show"316+ p.sel = sel317+ m.openOverlay(p)318+}319+320+// sheetKey handles the keys that switch sheets while an entry is typed.321+func (m *Model) sheetKey(key string) bool {322+ switch keymap[key] {323+ case "sheet.next":324+ if s := m.sheetAt(1); s != nil {325+ m.pointInto(s)326+ }327+ case "sheet.prev":328+ if s := m.sheetAt(-1); s != nil {329+ m.pointInto(s)330+ }331+ default:332+ return false333+ }334+ return true335+}336+337+// tabSpan is a clickable part of the tab strip on the status line.338+type tabSpan struct {339+ kind hitKind // hitTab, hitTabAdd, hitTabPrev or hitTabNext340+ index int // the sheet, for hitTab341+ x, w int342+}343+344+// tabLabel is a sheet's name as its tab shows it.345+func tabLabel(s *sheet.Sheet) string {346+ return " " + ansi.Truncate(s.Name(), maxTabName, "…") + " "347+}348+349+// minTabs is the narrowest the tab strip gets: the shown sheet's tab,350+// the arrows and the +.351+func (m *Model) minTabs() int {352+ w := ansi.StringWidth(tabLabel(m.sheet)) + 4353+ if m.book().Len() > 1 {354+ w += 4355+ }356+ return w357+}358+359+// allTabs is how wide the tab strip is with every tab showing.360+func (m *Model) allTabs() int {361+ w := 3 // the +362+ for _, s := range m.book().Sheets() {363+ w += ansi.StringWidth(tabLabel(s)) + 1364+ }365+ return w366+}367+368+// tabStrip lays out the tabs in room columns from the left of the status369+// line: as many as fit, always the shown one, the first shown staying put370+// until the shown one would fall off (m.tabLeft), ‹ and › where tabs are371+// hidden, then the +.372+func (m *Model) tabStrip(room int) (string, []tabSpan) {373+ sheets := m.book().Sheets()374+ active := m.book().Index(m.sheet)375+ widths := make([]int, len(sheets))376+ for i, s := range sheets {377+ widths[i] = ansi.StringWidth(tabLabel(s))378+ }379+ const add, arrow = 4, 2 // " + " and "‹ ", each with a space before the next part380+ fits := func(first int) (last int, ok bool) {381+ w := add382+ if first > 0 {383+ w += arrow384+ }385+ last = first - 1386+ for i := first; i < len(sheets); i++ {387+ more := 0388+ if i < len(sheets)-1 {389+ more = arrow390+ }391+ if w+widths[i]+1+more > room {392+ break393+ }394+ w += widths[i] + 1395+ last = i396+ }397+ return last, last >= active398+ }399+ first := clamp(m.tabLeft, 0, active)400+ last, ok := fits(first)401+ for !ok && first < active {402+ first++403+ last, ok = fits(first)404+ }405+ m.tabLeft = first406+ if last < active {407+ last = active // a name too long for the room is cut below408+ }409+410+ var b strings.Builder411+ var spans []tabSpan412+ x := 0413+ part := func(kind hitKind, index int, text string) {414+ spans = append(spans, tabSpan{kind: kind, index: index, x: x, w: ansi.StringWidth(text)})415+ b.WriteString(text + " ")416+ x += ansi.StringWidth(text) + 1417+ }418+ if first > 0 {419+ part(hitTabPrev, 0, m.th.muted.Render("‹"))420+ }421+ for i := first; i <= last; i++ {422+ style := m.th.tab423+ switch {424+ case i == active:425+ style = m.th.tabActive426+ case m.drag == dragTab && m.hover.kind == hitTab && m.hover.addr.Col == i,427+ m.drag == dragNone && m.hover.kind == hitTab && m.hover.addr.Col == i:428+ style = m.th.tabHover429+ }430+ label := tabLabel(sheets[i])431+ if i == active && x+ansi.StringWidth(label)+add > room {432+ label = ansi.Truncate(label, max(room-x-add, 3), "…")433+ }434+ part(hitTab, i, style.Render(label))435+ }436+ if last < len(sheets)-1 {437+ part(hitTabNext, 0, m.th.muted.Render("›"))438+ }439+ addStyle := m.th.tab440+ if m.hover.kind == hitTabAdd {441+ addStyle = m.th.tabHover442+ }443+ part(hitTabAdd, 0, addStyle.Render(" + "))444+ return strings.TrimSuffix(b.String(), " "), spans445+}446+447+// tabAt returns the part of the tab strip at x on the status line.448+func (m *Model) tabAt(x int) (tabSpan, bool) {449+ _, spans := m.statusLayout()450+ for _, sp := range spans {451+ if x >= sp.x && x < sp.x+sp.w {452+ return sp, true453+ }454+ }455+ return tabSpan{}, false456+}457+458+// tabPress handles a click on the tab strip: show the tab (renaming it on459+// a double click, or starting to drag it), add a sheet, or step through460+// tabs with the arrows.461+func (m *Model) tabPress(h hit, double bool) tea.Cmd {462+ switch h.kind {463+ case hitTabPrev:464+ m.stepEntrySheet(-1)465+ case hitTabNext:466+ m.stepEntrySheet(1)467+ case hitTabAdd:468+ if m.editing() && !m.commit() {469+ return nil470+ }471+ return m.runCommand("sheet.new")472+ case hitTab:473+ s := m.book().Sheet(h.addr.Col)474+ if m.editing() {475+ m.pointInto(s)476+ return nil477+ }478+ m.showSheet(s)479+ if double {480+ m.openRename()481+ return nil482+ }483+ m.drag = dragTab484+ }485+ return nil486+}487+488+func (m *Model) stepEntrySheet(d int) {489+ if s := m.sheetAt(d); s != nil {490+ m.switchTo(s)491+ }492+}493+494+// dropTab moves the dragged sheet to the tab it was released over.495+func (m *Model) dropTab() {496+ if m.hover.kind == hitTab && m.hover.addr.Col != m.book().Index(m.sheet) {497+ to := m.hover.addr.Col498+ m.book().MoveSheet(m.sheet, to)499+ m.note = "Moved " + m.sheet.Name() + " to position " + strconv.Itoa(to+1)500+ }501+}502+503+// tabRightClick shows the tab's sheet and opens its menu.504+func (m *Model) tabRightClick(h hit, x, y int) {505+ if h.kind != hitTab {506+ if h.kind == hitTabAdd {507+ m.showContextMenu([]menuItem{{cmd: "sheet.new"}, {cmd: "sheet.goto"}}, x, y)508+ }509+ return510+ }511+ m.showSheet(m.book().Sheet(h.addr.Col))512+ m.showContextMenu(tabMenu, x, y)513+}@@ -0,0 +1,337 @@
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+// status is the status line, where the tabs are.14+func status(m *Model) string { return line(m, m.height-1) }15+16+// tabX is the screen x of the middle of a sheet's tab, or of the + for17+// name "+".18+func tabX(t *testing.T, m *Model, name string) int {19+ t.Helper()20+ _, spans := m.statusLayout()21+ for _, sp := range spans {22+ if name == "+" && sp.kind == hitTabAdd || sp.kind == hitTab && m.book().Sheet(sp.index).Name() == name {23+ return sp.x + sp.w/224+ }25+ }26+ t.Fatalf("no tab %q in %q", name, status(m))27+ return 028+}29+30+// sheetNames lists the workbook's sheets in tab order.31+func sheetNames(m *Model) string {32+ var names []string33+ for _, s := range m.book().Sheets() {34+ names = append(names, s.Name())35+ }36+ return strings.Join(names, ",")37+}38+39+func TestTabKeys(t *testing.T) {40+ m := newModel()41+ if s := status(m); !strings.HasPrefix(s, " Sheet1 + untitled") {42+ t.Fatalf("status %q", s)43+ }44+ press(t, m, "<down>", "<down>", "<f11>") // plain F11 does nothing45+ press(t, m, "<shift+f11>")46+ if m.sheet.Name() != "Sheet2" || m.cur != addr("A1") || !strings.HasPrefix(status(m), " Sheet1 Sheet2 +") {47+ t.Fatalf("new sheet: %s at %v, status %q", m.sheet.Name(), m.cur, status(m))48+ }49+ press(t, m, "x", "<enter>", "<right>")50+ // Each sheet remembers where its cursor was.51+ press(t, m, "<ctrl+pgup>")52+ if m.sheet.Name() != "Sheet1" || m.cur != addr("A3") || input(m, "A1") != "" {53+ t.Errorf("previous: %s at %v", m.sheet.Name(), m.cur)54+ }55+ press(t, m, "<ctrl+pgup>") // no wrapping56+ if m.sheet.Name() != "Sheet1" {57+ t.Errorf("wrapped to %s", m.sheet.Name())58+ }59+ press(t, m, "<alt+right>")60+ if m.sheet.Name() != "Sheet2" || m.cur != addr("B2") || input(m, "A1") != "x" {61+ t.Errorf("next: %s at %v", m.sheet.Name(), m.cur)62+ }63+ press(t, m, "<alt+left>", "<ctrl+pgdown>")64+ if m.sheet.Name() != "Sheet2" {65+ t.Errorf("ctrl+pgdown: %s", m.sheet.Name())66+ }67+ // Undo is workbook-wide and shows the sheet it changes.68+ press(t, m, "<ctrl+pgup>", "<ctrl+z>")69+ if m.sheet.Name() != "Sheet2" || input(m, "A1") != "" || !strings.Contains(line(m, contextLine), "Undid: edit A1") {70+ t.Errorf("undo: %s, %q", m.sheet.Name(), line(m, contextLine))71+ }72+ press(t, m, "<ctrl+z>")73+ if m.book().Len() != 1 || m.sheet.Name() != "Sheet1" || m.cur != addr("A3") {74+ t.Errorf("undo the new sheet: %d sheets, on %s at %v", m.book().Len(), m.sheet.Name(), m.cur)75+ }76+ press(t, m, "<ctrl+y>")77+ if m.book().Len() != 2 || m.sheet.Name() != "Sheet2" {78+ t.Errorf("redo: %d sheets, on %s", m.book().Len(), m.sheet.Name())79+ }80+}81+82+func TestTabMouse(t *testing.T) {83+ m := newModel()84+ leftClick(m, tabX(t, m, "+"), m.height-1)85+ leftClick(m, tabX(t, m, "+"), m.height-1)86+ if sheetNames(m) != "Sheet1,Sheet2,Sheet3" || m.sheet.Name() != "Sheet3" {87+ t.Fatalf("+ twice: %s, on %s", sheetNames(m), m.sheet.Name())88+ }89+ leftClick(m, tabX(t, m, "Sheet1"), m.height-1)90+ if m.sheet.Name() != "Sheet1" {91+ t.Errorf("click: on %s", m.sheet.Name())92+ }93+ // Double-click renames, on the context line.94+ x := tabX(t, m, "Sheet2")95+ leftClick(m, x, m.height-1)96+ leftClick(m, x, m.height-1)97+ if m.mode != modePrompt || !strings.HasPrefix(line(m, contextLine), "Rename sheet: Sheet2") {98+ t.Fatalf("double-click: mode %v, %q", m.mode, line(m, contextLine))99+ }100+ press(t, m, "Q3 plan", "<enter>")101+ if sheetNames(m) != "Sheet1,Q3 plan,Sheet3" || !strings.Contains(line(m, contextLine), "Renamed Sheet2 to Q3 plan") {102+ t.Errorf("renamed: %s, %q", sheetNames(m), line(m, contextLine))103+ }104+ // Dragging a tab onto another moves it there.105+ x = tabX(t, m, "Sheet3")106+ send(m, tea.MouseClickMsg{X: x, Y: m.height - 1, Button: tea.MouseLeft})107+ to := tabX(t, m, "Sheet1")108+ send(m, tea.MouseMotionMsg{X: to, Y: m.height - 1, Button: tea.MouseLeft})109+ send(m, tea.MouseReleaseMsg{X: to, Y: m.height - 1, Button: tea.MouseLeft})110+ if sheetNames(m) != "Sheet3,Sheet1,Q3 plan" || m.sheet.Name() != "Sheet3" {111+ t.Errorf("drag: %s, on %s", sheetNames(m), m.sheet.Name())112+ }113+ // Right-click opens the tab's menu over the status line.114+ send(m, tea.MouseClickMsg{X: tabX(t, m, "Q3 plan"), Y: m.height - 1, Button: tea.MouseRight})115+ o := openMenu(t, m)116+ if m.sheet.Name() != "Q3 plan" || o.levels[0].items[0].label() != "Rename" {117+ t.Fatalf("right-click: on %s, menu %v", m.sheet.Name(), o.levels[0].items)118+ }119+ press(t, m, "<down>", "<enter>") // Duplicate120+ if sheetNames(m) != "Sheet3,Sheet1,Q3 plan,Copy of Q3 plan" || m.sheet.Name() != "Copy of Q3 plan" {121+ t.Errorf("duplicate: %s, on %s", sheetNames(m), m.sheet.Name())122+ }123+}124+125+func TestRenameErrors(t *testing.T) {126+ m := newModel()127+ press(t, m, "<shift+f11>")128+ m.runCommand("sheet.rename")129+ press(t, m, "sheet1", "<enter>")130+ if m.mode != modeError || !strings.Contains(status(m), "There's already a sheet named Sheet1") {131+ t.Errorf("duplicate name: mode %v, %q", m.mode, status(m))132+ }133+ press(t, m, "x")134+ m.runCommand("sheet.rename")135+ press(t, m, "<esc>")136+ if m.mode != modeReady || m.sheet.Name() != "Sheet2" {137+ t.Errorf("esc: mode %v, %s", m.mode, m.sheet.Name())138+ }139+}140+141+func TestDeleteSheetAsks(t *testing.T) {142+ m := newModel()143+ press(t, m, "<shift+f11>", "1", "<enter>", "2", "<enter>")144+ if !commands["sheet.delete"].available(m) {145+ t.Fatal("delete unavailable with two sheets")146+ }147+ m.runCommand("sheet.delete")148+ if l := line(m, contextLine); !strings.Contains(l, "Delete Sheet2 and its 2 cells?") {149+ t.Fatalf("question %q", l)150+ }151+ press(t, m, "<enter>")152+ if sheetNames(m) != "Sheet1" || m.sheet.Name() != "Sheet1" || commands["sheet.delete"].available(m) {153+ t.Errorf("deleted: %s, on %s", sheetNames(m), m.sheet.Name())154+ }155+ press(t, m, "<ctrl+z>")156+ if sheetNames(m) != "Sheet1,Sheet2" || m.sheet.Name() != "Sheet2" || input(m, "A2") != "2" {157+ t.Errorf("undo: %s, on %s", sheetNames(m), m.sheet.Name())158+ }159+ // An empty sheet goes without asking.160+ press(t, m, "<shift+f11>")161+ m.runCommand("sheet.delete")162+ if m.mode != modeReady || sheetNames(m) != "Sheet1,Sheet2" {163+ t.Errorf("empty sheet: mode %v, %s", m.mode, sheetNames(m))164+ }165+}166+167+// Typing a formula and switching sheets points into the other sheet, as168+// clicking a tab while editing does in Sheets.169+func TestPointIntoAnotherSheet(t *testing.T) {170+ m := newModel()171+ press(t, m, "<shift+f11>", "<down>", "5", "<enter>", "<ctrl+pgup>")172+ press(t, m, "<right>", "=")173+ press(t, m, "<ctrl+pgdown>")174+ if m.mode != modePoint || m.sheet.Name() != "Sheet2" || bar(m) != "=Sheet2!A3" {175+ t.Fatalf("pointing: mode %v on %s, bar %q", m.mode, m.sheet.Name(), bar(m))176+ }177+ if name := strings.TrimSpace(ansi.Cut(line(m, formulaLine), 0, nameBoxW)); name != "Sheet1!B1" {178+ t.Errorf("name box %q", name)179+ }180+ press(t, m, "<up>", "*2")181+ if m.mode != modeEnter || bar(m) != "=Sheet2!A2*2" || m.sheet.Name() != "Sheet2" {182+ t.Fatalf("typing on: mode %v on %s, bar %q", m.mode, m.sheet.Name(), bar(m))183+ }184+ // A click on the other sheet points too.185+ press(t, m, "+")186+ click(m, cellX(0), gridTop+1, 0)187+ if bar(m) != "=Sheet2!A2*2+Sheet2!A2" {188+ t.Errorf("click: bar %q", bar(m))189+ }190+ // Back on the entry's sheet, references have no sheet.191+ press(t, m, "<ctrl+pgup>")192+ if m.sheet.Name() != "Sheet1" || bar(m) != "=Sheet2!A2*2+B1" {193+ t.Errorf("back home: on %s, bar %q", m.sheet.Name(), bar(m))194+ }195+ press(t, m, "<left>", "<enter>")196+ if m.sheet.Name() != "Sheet1" || m.cur != addr("B2") || input(m, "B1") != "=Sheet2!A2*2+A1" || m.sheet.Value(addr("B1")).Num != 10 {197+ t.Errorf("stored: on %s at %v, B1 %q = %v", m.sheet.Name(), m.cur, input(m, "B1"), m.sheet.Value(addr("B1")))198+ }199+ // Enter while pointing on another sheet stores the formula and200+ // returns to its cell's sheet.201+ press(t, m, "=", "<alt+right>", "<enter>")202+ if m.sheet.Name() != "Sheet1" || input(m, "B2") != "=Sheet2!A2" || m.cur != addr("B3") {203+ t.Errorf("enter away: on %s, B2 %q, at %v", m.sheet.Name(), input(m, "B2"), m.cur)204+ }205+ // Esc while typing on another sheet cancels and returns.206+ press(t, m, "=", "<alt+right>", "<esc>", "<esc>")207+ if m.sheet.Name() != "Sheet1" || m.mode != modeReady || input(m, "B3") != "" {208+ t.Errorf("esc away: on %s, mode %v", m.sheet.Name(), m.mode)209+ }210+ // Text isn't a formula: switching stores it first.211+ press(t, m, "hello", "<alt+right>")212+ if m.sheet.Name() != "Sheet2" || m.mode != modeReady || m.book().Sheet(0).Cell(addr("B3")).Input != "hello" {213+ t.Errorf("text: on %s, mode %v", m.sheet.Name(), m.mode)214+ }215+}216+217+func TestGotoOtherSheet(t *testing.T) {218+ m := newModel()219+ press(t, m, "<shift+f11>")220+ m.runCommand("sheet.rename")221+ press(t, m, "Q3 plan", "<enter>", "<ctrl+pgup>")222+ m.runCommand("goto")223+ press(t, m, "'Q3 plan'!C4", "<enter>")224+ if m.sheet.Name() != "Q3 plan" || m.cur != addr("C4") {225+ t.Errorf("goto: %s %v", m.sheet.Name(), m.cur)226+ }227+ m.runCommand("goto")228+ press(t, m, "Sheet1!B2:C3", "<enter>")229+ if m.sheet.Name() != "Sheet1" || m.selection() != sheet.NewRect(addr("B2"), addr("C3")) {230+ t.Errorf("goto range: %s %v", m.sheet.Name(), m.selection())231+ }232+ m.runCommand("goto")233+ press(t, m, "Nope!A1", "<enter>")234+ if m.mode != modeError || !strings.Contains(status(m), "There's no sheet named Nope") {235+ t.Errorf("unknown sheet: %q", status(m))236+ }237+}238+239+func TestSheetPicker(t *testing.T) {240+ m := newModel()241+ for range 3 {242+ press(t, m, "<shift+f11>")243+ }244+ press(t, m, "<alt+shift+k>")245+ p := openPicker(t, m)246+ if len(p.shown) != 4 || p.sel != 3 {247+ t.Fatalf("picker: %d shown, %d selected", len(p.shown), p.sel)248+ }249+ press(t, m, "sheet2", "<enter>")250+ if m.overlay != nil || m.sheet.Name() != "Sheet2" {251+ t.Errorf("picked %s", m.sheet.Name())252+ }253+}254+255+func TestManyTabsScroll(t *testing.T) {256+ m := newModel()257+ m.Update(tea.WindowSizeMsg{Width: 60, Height: 16})258+ for range 11 {259+ press(t, m, "<shift+f11>")260+ }261+ s := status(m)262+ if !strings.HasPrefix(s, "‹ Sheet") || !strings.Contains(s, " Sheet12 ") || strings.Contains(s, " Sheet1 ") {263+ t.Errorf("last sheet shown: %q", s)264+ }265+ for range 11 {266+ press(t, m, "<ctrl+pgup>")267+ }268+ s = status(m)269+ if !strings.HasPrefix(s, " Sheet1 ") || !strings.Contains(s, "›") {270+ t.Errorf("first sheet shown: %q", s)271+ }272+ // The arrows step through the sheets.273+ leftClick(m, strings.Index(s, "›"), m.height-1)274+ if m.sheet.Name() != "Sheet2" {275+ t.Errorf("› went to %s", m.sheet.Name())276+ }277+}278+279+func TestCopyAndCutBetweenSheets(t *testing.T) {280+ m := newModel()281+ press(t, m, "5", "<tab>", "=A1*2", "<enter>")282+ press(t, m, "<up>", "<shift+right>", "<ctrl+c>", "<shift+f11>", "<ctrl+v>")283+ if input(m, "B1") != "=A1*2" || m.sheet.Value(addr("B1")).Num != 10 {284+ t.Errorf("paste: B1 %q = %v", input(m, "B1"), m.sheet.Value(addr("B1")))285+ }286+ press(t, m, "<ctrl+pgup>", "<ctrl+home>", "<ctrl+x>", "<ctrl+pgdown>", "<down>", "<down>")287+ if !strings.Contains(line(m, contextLine), "Cut Sheet1!A1") {288+ t.Errorf("cut on another sheet: %q", line(m, contextLine))289+ }290+ press(t, m, "<ctrl+v>")291+ one := m.book().Sheet(0)292+ if input(m, "A3") != "5" || one.Cell(addr("B1")).Input != "=Sheet2!A3*2" || one.Value(addr("B1")).Num != 10 {293+ t.Errorf("move: A3 %q, Sheet1!B1 %q", input(m, "A3"), one.Cell(addr("B1")).Input)294+ }295+}296+297+func TestFindAllSheets(t *testing.T) {298+ m := newModel()299+ press(t, m, "rent", "<enter>", "<shift+f11>", "<down>", "rental", "<enter>", "<ctrl+pgup>")300+ press(t, m, "<ctrl+f>", "rent")301+ f := findBarOf(t, m)302+ if len(f.matches) != 1 || !strings.Contains(line(m, contextLine), "in Sheet1") {303+ t.Fatalf("this sheet: %d matches, %q", len(f.matches), line(m, contextLine))304+ }305+ press(t, m, "<alt+s>")306+ if len(f.matches) != 2 || !strings.Contains(line(m, contextLine), "in all sheets") {307+ t.Fatalf("all sheets: %d matches, %q", len(f.matches), line(m, contextLine))308+ }309+ press(t, m, "<enter>")310+ if m.sheet.Name() != "Sheet2" || m.cur != addr("A2") || !strings.Contains(line(m, contextLine), "2 of 2 on Sheet2") {311+ t.Errorf("next: on %s at %v, %q", m.sheet.Name(), m.cur, line(m, contextLine))312+ }313+ if !m.found(addr("A2")) || m.found(addr("A1")) {314+ t.Error("highlights not per sheet")315+ }316+ press(t, m, "<ctrl+h>", "lease", "<ctrl+enter>")317+ if input(m, "A2") != "leaseal" || m.book().Sheet(0).Cell(addr("A1")).Input != "lease" {318+ t.Errorf("replace all: %q", input(m, "A2"))319+ }320+ press(t, m, "<esc>", "<ctrl+z>")321+ if input(m, "A2") != "rental" || m.book().Sheet(0).Cell(addr("A1")).Input != "rent" {322+ t.Errorf("one undo: %q", input(m, "A2"))323+ }324+}325+326+func TestTraceIntoOtherSheet(t *testing.T) {327+ m := newModel()328+ press(t, m, "<shift+f11>", "7", "<enter>", "<ctrl+pgup>", "=Sheet2!A1*2", "<enter>", "<up>")329+ press(t, m, "<alt+,>")330+ if m.sheet.Name() != "Sheet2" || m.cur != addr("A1") || !strings.Contains(line(m, contextLine), "1 precedent of Sheet1!A1: Sheet2!A1") {331+ t.Errorf("trace: on %s at %v, %q", m.sheet.Name(), m.cur, line(m, contextLine))332+ }333+ press(t, m, "<esc>")334+ if m.sheet.Name() != "Sheet1" || m.cur != addr("A1") {335+ t.Errorf("esc: on %s at %v", m.sheet.Name(), m.cur)336+ }337+}@@ -48,6 +48,12 @@ type theme struct {
4848 // frozenLine divides frozen rows and columns from the scrolling ones,4949 // like a tmux pane border.5050 frozenLine lipgloss.Style51+ // Sheet tabs on the status line: tabActive is the sheet shown (also52+ // set apart by bold text), tabHover a tab under the mouse or where a53+ // dragged tab would go.54+ tab lipgloss.Style55+ tabActive lipgloss.Style56+ tabHover lipgloss.Style5157 // filterOn is the filter mark in the header of a column whose filter5258 // hides something (the mark itself also changes, from ▾ to ▼).5359 filterOn lipgloss.Style@@ -154,6 +160,9 @@ func newTheme(dark bool) theme {
154160 copied: lipgloss.NewStyle().Foreground(lipgloss.Magenta).155161 UnderlineStyle(lipgloss.UnderlineDashed).UnderlineSpaces(true),156162 frozenLine: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),163+ tab: lipgloss.NewStyle().Background(headerBg).Foreground(headerFg),164+ tabActive: accent.Bold(true),165+ tabHover: lipgloss.NewStyle().Background(headerBg).Foreground(lipgloss.Cyan).Bold(true),157166 filterOn: lipgloss.NewStyle().Background(headerBg).Foreground(filterFg).Bold(true),158167 159168 menuBar: lipgloss.NewStyle(),@@ -20,9 +20,10 @@ import (
2020 2121 type trace struct {2222 dependents bool23+ home *sheet.Sheet // the traced cell's sheet2324 origin sheet.Addr24- targets []sheet.Rect25- at int // the target the active cell is on25+ targets []sheet.Target // on any sheet: stepping to one shows its sheet26+ at int // the target the active cell is on2627 }2728 2829 func init() {@@ -45,11 +46,9 @@ func init() {
4546 func (m *Model) stepTrace(dependents bool) {4647 t := m.trace4748 if t == nil || t.dependents != dependents {48- t = &trace{dependents: dependents, origin: m.cur, at: -1}49+ t = &trace{dependents: dependents, home: m.sheet, origin: m.cur, at: -1}4950 if dependents {50- for _, a := range m.sheet.Dependents(m.cur) {51- t.targets = append(t.targets, sheet.Rect{From: a, To: a})52- }51+ t.targets = m.sheet.Dependents(m.cur)5352 } else {5453 t.targets = m.sheet.Precedents(m.cur)5554 }@@ -64,8 +63,9 @@ func (m *Model) stepTrace(dependents bool) {
6463 m.trace = t6564 }6665 t.at = (t.at + 1) % len(t.targets)66+ m.showSheet(t.targets[t.at].Sheet)6767 m.clearSelection()68- m.cur = t.targets[t.at].From68+ m.cur = t.targets[t.at].Range.From6969 }7070 7171 // traceKey ends the trace on any key but the trace commands, and returns@@ -78,10 +78,11 @@ func (m *Model) traceKey(k tea.KeyPressMsg) bool {
7878 if m.mode == modeReady && (id == "data.precedents" || id == "data.dependents") {7979 return false8080 }81- origin := m.trace.origin81+ t := m.trace8282 m.trace = nil8383 if m.mode == modeReady && k.String() == "esc" {84- m.cur = origin84+ m.showSheet(t.home)85+ m.cur = t.origin8586 return true8687 }8788 return false@@ -92,8 +93,8 @@ func (m *Model) traced(a sheet.Addr) bool {
9293 if m.trace == nil {9394 return false9495 }95- for _, r := range m.trace.targets {96- if r.Contains(a) {96+ for _, t := range m.trace.targets {97+ if t.Sheet == m.sheet && t.Range.Contains(a) {9798 return true9899 }99100 }@@ -112,15 +113,19 @@ func (m *Model) traceLine() (left, right string) {
112113 noun += "s"113114 }114115 right = m.keyHints(shortcut(id), "next", "Esc", "back")115- left = strconv.Itoa(len(t.targets)) + " " + noun + " of " + t.origin.String() + ": "116+ origin := t.origin.String()117+ if t.home != m.sheet {118+ origin = sheet.Qualified(t.home.Name(), sheet.Rect{From: t.origin, To: t.origin})119+ }120+ left = strconv.Itoa(len(t.targets)) + " " + noun + " of " + origin + ": "116121 room := m.width - ansi.StringWidth(right) - 3117122 if room < ansi.StringWidth(left)+12 {118123 right, room = "", m.width119124 }120125 var b strings.Builder121126 b.WriteString(left)122- for i, r := range t.targets {123- part := m.rangeLabel(r)127+ for i, tg := range t.targets {128+ part := m.rangeLabel(tg, t.home)124129 if i == t.at {125130 part = m.th.key.Render(part)126131 }@@ -137,13 +142,16 @@ func (m *Model) traceLine() (left, right string) {
137142 return b.String(), right138143 }139144 140-// rangeLabel names a range the way formulas refer to it: by its name if141-// it has one.142-func (m *Model) rangeLabel(r sheet.Rect) string {145+// rangeLabel names a range the way a formula on from refers to it: by146+// its name if it has one, with its sheet if it's on another.147+func (m *Model) rangeLabel(t sheet.Target, from *sheet.Sheet) string {143148 for _, n := range m.sheet.Names() {144- if !n.Lost && n.Range == r && r.From != r.To {149+ if !n.Gone() && n.Sheet == t.Sheet && n.Range == t.Range && t.Range.From != t.Range.To {145150 return n.Name146151 }147152 }148- return r.String()153+ if t.Sheet != from {154+ return sheet.Qualified(t.Sheet.Name(), t.Range)155+ }156+ return t.Range.String()149157 }@@ -505,6 +505,9 @@ func (m *Model) confirmReplace(msg string, do func(*Model) tea.Cmd, replaces boo
505505 // download snapshots the sheet and writes it in the background.506506 func (m *Model) download(name string, k fileio.Kind, r sheet.Rect, table string) tea.Cmd {507507 snap := fileio.Snap(m.sheet, r, filepath.Base(m.displayBase()))508+ if k == fileio.XLSX && r == (sheet.Rect{}) {509+ snap = fileio.SnapBook(m.sheet) // every sheet, as Sheets' .xlsx download510+ }508511 return func() tea.Msg {509512 res, err := fileio.Export(context.Background(), name, k, snap, fileio.ExportOptions{Table: table})510513 return exportedMsg{name: name, kind: k, res: res, err: err}@@ -58,7 +58,7 @@ func TestImportPicker(t *testing.T) {
5858 if l := line(m, contextLine); l != "Imported sales.csv (2 rows)" {5959 t.Errorf("context line %q", l)6060 }61- if !strings.HasPrefix(line(m, m.height-1), "sales.csv") || m.changed {61+ if l := line(m, m.height-1); !strings.HasPrefix(l, " sales ") || !strings.Contains(l, "sales.csv") || m.changed {6262 t.Errorf("status %q changed %v", line(m, m.height-1), m.changed)6363 }6464 @@ -14,7 +14,7 @@ import (
1414 // pointing, otherwise the active cell (which stays put while a selection is1515 // extended, as in Sheets).1616 func (m *Model) active() sheet.Addr {17- if m.mode == modePoint || m.pointing() {17+ if m.mode == modePoint || m.pointing() || m.away() {1818 return m.point.at1919 }2020 if a, ok := m.barActive(); ok {@@ -29,6 +29,8 @@ func (m *Model) highlight() (sheet.Rect, bool) {
2929 switch {3030 case m.mode == modePoint || m.pointing():3131 return m.point.rect(), true32+ case m.away(): // the selection is on the entry's sheet33+ return sheet.Rect{}, false3234 case m.drag == dragFill:3335 return m.fillTo, true3436 }@@ -166,7 +168,7 @@ func (m *Model) cellsText(row, first int, spans []span, focus sheet.Addr, sel sh
166168 if m.copyMarked(a) {167169 base, colored = base.Inherit(m.th.copied), true168170 }169- if a == m.cur && (m.mode == modeEnter || m.mode == modeEdit) {171+ if a == m.cur && (m.mode == modeEnter || m.mode == modeEdit) && !m.away() {170172 sp = span{text: m.inCellText(m.sheet.ColWidth(a.Col))}171173 } else {172174 m.decorate(&sp, row) // links and error marks, see links.go@@ -403,6 +405,7 @@ func keyLabel(k string) string {
403405 case "esc":404406 return "Esc"405407 }408+ k = strings.NewReplacer("pgdown", "PgDn", "pgup", "PgUp").Replace(k)406409 parts := strings.Split(k, "+")407410 for i, p := range parts {408411 switch {