cat / 012
clone

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

Hide and unhide sheets: tab menu Hide sheet and View > Hidden sheets

Dami committed 5a0b9b1
commit
5a0b9b1b3b50b879e9b49cd12962c9e66c1d763f
parent
97caaa7353423a0942e07d05227e1f05837def93
author
Dami <dami.etoile@gmail.com>
28 files changed, +652 -47
MREADME.md3+-
Mdocs/files.md6+
Mdocs/keys.md3+-
Mdocs/macros.md15++-
Minternal/fileio/snapshot.go2+
Minternal/fileio/xlsx.go9++
Minternal/fileio/xlsx_test.go34+++++++
Minternal/sheet/file.go6+
Ainternal/sheet/hidden.go84++++++++++++++++++
Ainternal/sheet/hidden_test.go90+++++++++++++++++++
Minternal/sheet/historysheets.go15++-
Minternal/sheet/sheet.go7+-
Minternal/sheet/workbook.go2+
Minternal/ui/find.go6+-
Minternal/ui/findview.go4+-
Ainternal/ui/hidden.go113++++++++++++++++++++++++
Ainternal/ui/hidden_test.go144++++++++++++++++++++++++++++++
Minternal/ui/macrohostnav.go30++++++
Minternal/ui/macrorec.go3+
Minternal/ui/menubar.go3+-
Minternal/ui/names.go6+
Minternal/ui/palette.go36+++++++-
Minternal/ui/pivotactions.go4+
Minternal/ui/selection.go7+-
Minternal/ui/tabs.go41++++++---
Minternal/ui/tabs_test.go2+-
Minternal/ui/tabstrip.go21+++-
Minternal/ui/trace.go3+
M README.md +2 -1
M docs/files.md +6 -0
M docs/keys.md +2 -1
M docs/macros.md +8 -7
@@ -182,9 +182,9 @@ is already in the formulas that use it.
182182 183183 | Function | Does |184184 |---|---|185-| `sheets()` | The sheet names, in tab order |185+| `sheets()` | The sheet names, in tab order, hidden ones included |186186 | `active_sheet()` | The name of the sheet shown |187-| `activate_sheet(name)` | Show a sheet |187+| `activate_sheet(name)` | Show a sheet (not a hidden one: `run("sheet.unhide", answer=name)` shows it again) |188188 | `add_sheet(name=None)` | Add a sheet after the one shown and show it; returns its name |189189 | `move_sheet(position)` | Move the sheet shown to a position, counting from 1 |190190 | `set_width(cols, width)` | Set column widths, 1 to 240: `set_width("B", 14)`, `set_width("B:D", 8)` |
@@ -194,10 +194,11 @@ is already in the formulas that use it.
194194 `run(id, answer=None)` runs any command from the menus or palette by its195195 id, on the selection, as if picked. A command that asks something (a196196 width, a name, a confirmation) needs `answer`: the text you'd type, a range197-for a range question, or the key of a choice (`"enter"`, `"d"`) or its198-label. Commands that open a dialog or picker (Sort range, Insert chart,199-Named ranges) can't run in a script; nor can files, menus, help, undo or200-macros. The ids are those in the recorded scripts; a few:197+for a range question, the key of a choice (`"enter"`, `"d"`) or its198+label, or the item picked from a list (View > Hidden sheets takes the199+sheet's name). Commands that open a dialog or another picker (Sort200+range, Insert chart, Named ranges) can't run in a script; nor can files,201+menus, help, undo or macros. The ids are those in the recorded scripts; a few:201202 202203 | Id | Command |203204 |---|---|
@@ -210,7 +211,7 @@ macros. The ids are those in the recorded scripts; a few:
210211 | `column.width` (answer: the width), `column.reset` | Column widths |211212 | `data.sort_sheet_az`, `data.sort_range_az`, `data.sort_range_za`, `data.filter`, `data.filter_remove` | Sorting and filters |212213 | `data.define_name` (answer: the name) | Name the selection |213-| `sheet.new`, `sheet.duplicate`, `sheet.rename` (answer: the name), `sheet.delete` (answer: `"enter"` when it asks) | Sheets |214+| `sheet.new`, `sheet.duplicate`, `sheet.rename` (answer: the name), `sheet.delete` (answer: `"enter"` when it asks), `sheet.hide`, `sheet.unhide` (answer: the sheet's name) | Sheets |214215 | `view.freeze_rows1`, `view.freeze_cols1`, `view.freeze_rows0`, ... | Frozen panes |215216 216217 Record a macro that uses a command to see its id.
M internal/fileio/snapshot.go +2 -0
M internal/fileio/xlsx.go +9 -0
M internal/fileio/xlsx_test.go +34 -0
M internal/sheet/file.go +6 -0
A internal/sheet/hidden.go +84 -0
@@ -0,0 +1,84 @@
1+package sheet2+3+import (4+ "errors"5+ "slices"6+)7+8+// Hidden sheets, as Sheets' Hide sheet: a hidden sheet keeps its cells9+// and stays in formulas, named ranges and pivot sources; it is left out10+// of the tabs and of moving between sheets, and View > Hidden sheets11+// shows it again. At least one sheet is always visible. Hiding and12+// showing are undo steps that snapshot the sheet list (historysheets.go),13+// and files keep the flag in an optional field older builds ignore.14+15+// Hidden reports whether the sheet is hidden.16+func (s *Sheet) Hidden() bool { return s.tabHidden }17+18+// Visible returns the sheets that aren't hidden, in tab order.19+func (w *Workbook) Visible() []*Sheet {20+ return slices.DeleteFunc(slices.Clone(w.sheets), (*Sheet).Hidden)21+}22+23+// HiddenSheets returns the hidden sheets, in tab order.24+func (w *Workbook) HiddenSheets() []*Sheet {25+ return slices.DeleteFunc(slices.Clone(w.sheets), func(s *Sheet) bool { return !s.tabHidden })26+}27+28+// visibleCount is how many sheets aren't hidden.29+func (w *Workbook) visibleCount() int {30+ n := 031+ for _, s := range w.sheets {32+ if !s.tabHidden {33+ n++34+ }35+ }36+ return n37+}38+39+// errLastVisible is why the last visible sheet can't be hidden or deleted.40+var errLastVisible = errors.New("A spreadsheet needs at least one visible sheet")41+42+// HideSheet hides s, as one undo step. The last visible sheet can't be43+// hidden.44+func (w *Workbook) HideSheet(s *Sheet) error {45+ switch {46+ case !s.live:47+ return errors.New("That sheet was deleted")48+ case s.tabHidden:49+ return nil50+ case w.visibleCount() == 1:51+ return errLastVisible52+ }53+ w.change(s, "hide sheet "+s.name, Rect{}, func() {54+ w.recordSheets()55+ s.tabHidden = true56+ })57+ return nil58+}59+60+// UnhideSheet shows s again, as one undo step.61+func (w *Workbook) UnhideSheet(s *Sheet) error {62+ switch {63+ case !s.live:64+ return errors.New("That sheet was deleted")65+ case !s.tabHidden:66+ return nil67+ }68+ w.change(s, "show sheet "+s.name, Rect{}, func() {69+ w.recordSheets()70+ s.tabHidden = false71+ })72+ return nil73+}74+75+// settleHidden keeps a loaded workbook showable: if every sheet is76+// hidden the first is shown, and a hidden sheet isn't the active one.77+func (w *Workbook) settleHidden() {78+ if w.visibleCount() == 0 {79+ w.sheets[0].tabHidden = false80+ }81+ if w.sheets[w.Active()].tabHidden {82+ w.active = w.Index(w.Visible()[0])83+ }84+}
A internal/sheet/hidden_test.go +90 -0
@@ -0,0 +1,90 @@
1+package sheet2+3+import (4+ "bytes"5+ "strings"6+ "testing"7+)8+9+// Hiding a sheet keeps it in formulas, is one undo step each way, and10+// the last visible sheet can't be hidden or deleted.11+func TestHideSheet(t *testing.T) {12+ w := bookOf(t,13+ page{"Summary", map[string]string{"A1": "=Data!A1*2"}},14+ page{"Data", map[string]string{"A1": "21"}},15+ )16+ data, summary := w.Lookup("Data"), w.Lookup("Summary")17+ if err := w.HideSheet(data); err != nil {18+ t.Fatal(err)19+ }20+ if !data.Hidden() || len(w.Visible()) != 1 || w.HiddenSheets()[0] != data {21+ t.Fatalf("hidden %v, visible %d", data.Hidden(), len(w.Visible()))22+ }23+ if got := show(t, w, "Summary!A1"); got != "42" {24+ t.Errorf("formula reading the hidden sheet = %s", got)25+ }26+ data.Set(at("A1"), "5")27+ if got := show(t, w, "Summary!A1"); got != "10" {28+ t.Errorf("after editing the hidden sheet = %s", got)29+ }30+ if err := w.HideSheet(summary); err != errLastVisible {31+ t.Errorf("hiding the last visible sheet: %v", err)32+ }33+ if err := w.DeleteSheet(summary); err != errLastVisible {34+ t.Errorf("deleting the last visible sheet: %v", err)35+ }36+ c, ok := w.Undo()37+ if !ok || c.Label != "edit A1" {38+ t.Fatalf("undo %+v", c)39+ }40+ c, ok = w.Undo()41+ if !ok || c.Label != "hide sheet Data" || data.Hidden() {42+ t.Fatalf("undo hide: %+v, hidden %v", c, data.Hidden())43+ }44+ w.Redo()45+ if !data.Hidden() {46+ t.Error("redo didn't hide")47+ }48+ if err := w.UnhideSheet(data); err != nil || data.Hidden() {49+ t.Fatalf("unhide: %v", err)50+ }51+ if c, _ := w.Undo(); c.Label != "show sheet Data" || !data.Hidden() {52+ t.Errorf("undo unhide: %+v", c)53+ }54+}55+56+// Hidden sheets are saved in an optional field; a file whose sheets are57+// all hidden, or that was showing a hidden one, opens on a visible one.58+func TestHiddenSheetFile(t *testing.T) {59+ w := bookOf(t,60+ page{"Summary", map[string]string{"A1": "=Data!A1*2"}},61+ page{"Data", map[string]string{"A1": "21"}},62+ )63+ w.HideSheet(w.Lookup("Data"))64+ var b bytes.Buffer65+ if err := w.Write(&b); err != nil {66+ t.Fatal(err)67+ }68+ out := b.String()69+ if !strings.Contains(out, `"version": 4,`) || strings.Count(out, `"hidden": true`) != 1 ||70+ !strings.Contains(out, "\"name\": \"Data\",\n \"hidden\": true,") {71+ t.Fatalf("file:\n%s", out)72+ }73+ r, err := ReadBook(strings.NewReader(out))74+ if err != nil {75+ t.Fatal(err)76+ }77+ if !r.Lookup("Data").Hidden() || r.Lookup("Summary").Hidden() || show(t, r, "Summary!A1") != "42" {78+ t.Errorf("read back: Data hidden %v", r.Lookup("Data").Hidden())79+ }80+81+ all := strings.Replace(out, `"name": "Summary",`, `"name": "Summary", "hidden": true,`, 1)82+ all = strings.Replace(all, `"sheets"`, `"active": 1, "sheets"`, 1)83+ r, err = ReadBook(strings.NewReader(all))84+ if err != nil {85+ t.Fatal(err)86+ }87+ if r.Sheet(0).Hidden() || !r.Sheet(1).Hidden() || r.Active() != 0 {88+ t.Errorf("every sheet hidden: first hidden %v, active %d", r.Sheet(0).Hidden(), r.Active())89+ }90+}
M internal/sheet/historysheets.go +10 -5
M internal/sheet/sheet.go +4 -3
M internal/sheet/workbook.go +2 -0
M internal/ui/find.go +3 -3
M internal/ui/findview.go +2 -2
A internal/ui/hidden.go +113 -0
@@ -0,0 +1,113 @@
1+package ui2+3+import (4+ tea "charm.land/bubbletea/v2"5+6+ "github.com/FelineStateMachine/012/internal/sheet"7+)8+9+// Hidden sheets, as in Sheets: the tab menu's Hide sheet takes a sheet10+// out of the tabs, and View > Hidden sheets lists them to show one11+// again. Formulas still read a hidden sheet; moving between sheets, the12+// tab strip, Go to sheet and Find in all sheets skip it, and going to a13+// cell on it (Go to, a named range) says it's hidden rather than show it.14+15+func init() {16+ register(17+ &command{id: "sheet.hide", title: "Hide sheet", desc: "Take the sheet out of the tabs; formulas still read it, and View > Hidden sheets shows it again",18+ enabled: func(m *Model) bool { return len(m.book().Visible()) > 1 }, run: (*Model).hideSheet},19+ &command{id: "sheet.unhide", title: "Hidden sheets", desc: "List the hidden sheets and show one again",20+ enabled: func(m *Model) bool { return len(m.book().HiddenSheets()) > 0 }, run: func(m *Model) tea.Cmd {21+ m.openHiddenPicker()22+ return nil23+ }},24+ )25+}26+27+// hideSheet hides the sheet shown and shows the next visible one.28+func (m *Model) hideSheet() tea.Cmd {29+ s := m.sheet30+ i := m.book().Index(s)31+ if err := m.book().HideSheet(s); err != nil {32+ m.fail(err.Error())33+ return nil34+ }35+ m.afterSheetsChange(nil, i)36+ m.note = "Hid " + s.Name() + "; View > Hidden sheets shows it again"37+ return nil38+}39+40+// openHiddenPicker lists the hidden sheets; Enter shows the one picked41+// again. A macro records the pick as the command's answer.42+func (m *Model) openHiddenPicker() {43+ var items []pickItem44+ for _, s := range m.book().HiddenSheets() {45+ detail := "empty"46+ if used, ok := s.UsedRange(); ok {47+ detail = used.String() + ", " + cellCount(s.Len())48+ }49+ items = append(items, pickItem{50+ title: s.Name(), name: len(s.Name()), detail: detail, desc: "Show " + s.Name() + " again",51+ pick: func(m *Model) tea.Cmd {52+ m.closeOverlay()53+ if err := m.book().UnhideSheet(s); err != nil {54+ m.fail(err.Error())55+ return nil56+ }57+ m.showSheet(s)58+ m.note = "Unhid " + s.Name()59+ return nil60+ },61+ })62+ }63+ p := newPicker(m, "Hidden sheets", "Type a sheet name", 60, items)64+ p.action = "unhide"65+ p.answers = true66+ m.openOverlay(p)67+}68+69+// refuseHidden says so and reports true when s is a hidden sheet, which70+// isn't shown until it's unhidden.71+func (m *Model) refuseHidden(s *sheet.Sheet) bool {72+ if s == nil || !s.Hidden() {73+ return false74+ }75+ m.fail(hiddenMsg(s))76+ return true77+}78+79+func hiddenMsg(s *sheet.Sheet) string {80+ return s.Name() + " is hidden; View > Hidden sheets shows it again"81+}82+83+// visibleSheets are the sheets with tabs, and the sheet shown if it84+// somehow isn't among them, so the tab strip always has it.85+func (m *Model) visibleSheets() []*sheet.Sheet {86+ if !m.sheet.Hidden() {87+ return m.book().Visible()88+ }89+ var out []*sheet.Sheet90+ for _, s := range m.book().Sheets() {91+ if !s.Hidden() || s == m.sheet {92+ out = append(out, s)93+ }94+ }95+ return out96+}97+98+// nearVisible is the visible sheet at index i of the workbook or the99+// nearest one after it, else before it.100+func (m *Model) nearVisible(i int) *sheet.Sheet {101+ book := m.book()102+ for j := max(i, 0); j < book.Len(); j++ {103+ if s := book.Sheet(j); !s.Hidden() {104+ return s105+ }106+ }107+ for j := min(i, book.Len()-1); j >= 0; j-- {108+ if s := book.Sheet(j); !s.Hidden() {109+ return s110+ }111+ }112+ return book.Sheet(0)113+}
A internal/ui/hidden_test.go +144 -0
@@ -0,0 +1,144 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+)7+8+// hiddenBook is a model of three sheets, Sheet1 reading Data, showing9+// Data.10+func hiddenBook(t *testing.T) *Model {11+ t.Helper()12+ m := newModel()13+ press(t, m, "=", "D", "a", "t", "a", "!", "A", "1", "*", "2", "<enter>")14+ press(t, m, "<shift+f11>")15+ run(m, m.runCommand("sheet.rename"))16+ m.line.set("Data")17+ press(t, m, "<enter>", "2", "1", "<enter>", "<shift+f11>", "<ctrl+pgup>")18+ if sheetNames(m) != "Sheet1,Data,Sheet3" || m.sheet.Name() != "Data" {19+ t.Fatalf("sheets %s, on %s", sheetNames(m), m.sheet.Name())20+ }21+ return m22+}23+24+func TestHideSheetCommand(t *testing.T) {25+ m := hiddenBook(t)26+ run(m, m.runCommand("sheet.hide"))27+ if m.sheet.Name() != "Sheet3" || !m.book().Lookup("Data").Hidden() {28+ t.Fatalf("after hiding: on %s", m.sheet.Name())29+ }30+ if s := status(m); strings.Contains(s, "Data") || !strings.HasPrefix(s, " Sheet1 Sheet3 +") {31+ t.Errorf("tabs %q", s)32+ }33+ if !strings.Contains(line(m, contextLine), "Hid Data; View > Hidden sheets shows it again") {34+ t.Errorf("context %q", line(m, contextLine))35+ }36+ press(t, m, "<ctrl+pgup>") // skips Data37+ if m.sheet.Name() != "Sheet1" || m.sheet.Value(addr("A1")).Num != 42 {38+ t.Errorf("previous: %s, A1 = %v", m.sheet.Name(), m.sheet.Value(addr("A1")))39+ }40+ press(t, m, "<ctrl+pgdown>")41+ if m.sheet.Name() != "Sheet3" {42+ t.Errorf("next: %s", m.sheet.Name())43+ }44+ // Move left goes past the hidden sheet to the visible neighbour.45+ run(m, m.runCommand("sheet.move_left"))46+ if sheetNames(m) != "Sheet3,Sheet1,Data" {47+ t.Errorf("moved left: %s", sheetNames(m))48+ }49+ run(m, m.runCommand("edit.undo"))50+51+ // The last visible sheet can't be hidden or deleted.52+ run(m, m.runCommand("sheet.hide"))53+ if m.sheet.Name() != "Sheet1" || commands["sheet.hide"].available(m) || commands["sheet.delete"].available(m) {54+ t.Fatalf("one visible sheet left: on %s", m.sheet.Name())55+ }56+ if !strings.HasPrefix(status(m), " Sheet1 +") {57+ t.Errorf("tabs %q", status(m))58+ }59+60+ // Going to a cell on a hidden sheet says it's hidden.61+ run(m, m.runCommand("goto"))62+ m.line.set("Data!A1")63+ press(t, m, "<enter>")64+ if m.sheet.Name() != "Sheet1" || m.mode != modeError || m.errMsg != "Data is hidden; View > Hidden sheets shows it again" {65+ t.Errorf("goto: on %s, %q", m.sheet.Name(), m.errMsg)66+ }67+ press(t, m, "<esc>")68+69+ // Undo shows the sheet again; redo hides it and shows another.70+ press(t, m, "<ctrl+z>")71+ if m.sheet.Name() != "Sheet3" || m.sheet.Hidden() {72+ t.Errorf("undo: on %s", m.sheet.Name())73+ }74+ press(t, m, "<ctrl+y>")75+ if m.sheet.Hidden() || m.sheet.Name() != "Sheet1" {76+ t.Errorf("redo: on %s (hidden %v)", m.sheet.Name(), m.sheet.Hidden())77+ }78+}79+80+func TestHiddenSheetsPicker(t *testing.T) {81+ m := hiddenBook(t)82+ if commands["sheet.unhide"].available(m) {83+ t.Error("Hidden sheets available with none hidden")84+ }85+ run(m, m.runCommand("sheet.hide"))86+ run(m, m.runCommand("sheet.unhide"))87+ p, ok := m.overlay.(*picker)88+ if !ok || p.title != "Hidden sheets" || len(p.items) != 1 || p.items[0].title != "Data" {89+ t.Fatalf("overlay %#v", m.overlay)90+ }91+ if !strings.Contains(screen(m), "Hidden sheets") || !strings.Contains(screen(m), "A1, 1 cell") {92+ t.Errorf("picker:\n%s", screen(m))93+ }94+ press(t, m, "<enter>")95+ if m.overlay != nil || m.sheet.Name() != "Data" || m.sheet.Hidden() {96+ t.Fatalf("unhid: on %s", m.sheet.Name())97+ }98+ if !strings.HasPrefix(status(m), " Sheet1 Data Sheet3") || !strings.Contains(line(m, contextLine), "Unhid Data") {99+ t.Errorf("status %q, context %q", status(m), line(m, contextLine))100+ }101+ press(t, m, "<ctrl+z>")102+ if !m.book().Lookup("Data").Hidden() || m.sheet.Name() == "Data" {103+ t.Errorf("undo unhide: on %s", m.sheet.Name())104+ }105+}106+107+// Hiding is recorded as a command, unhiding with the sheet picked as108+// its answer, and both replay.109+func TestRecordHideAndUnhide(t *testing.T) {110+ m := hiddenBook(t)111+ send(m, nil)112+ run(m, m.runCommand("macro.record"))113+ run(m, m.runCommand("sheet.hide"))114+ run(m, m.runCommand("sheet.unhide"))115+ press(t, m, "<enter>")116+ run(m, m.runCommand("macro.stop"))117+ m.line.set("Toggle")118+ press(t, m, "<enter>")119+ m.line.set("")120+ press(t, m, "<enter>")121+ mc, ok := m.book().Macro("Toggle")122+ if !ok {123+ t.Fatalf("no macro: %q", m.errMsg)124+ }125+ want := "run(\"sheet.hide\")\nrun(\"sheet.unhide\", answer=\"Data\")"126+ if !strings.Contains(mc.Source, want) {127+ t.Errorf("script:\n%s\nwant it to contain\n%s", mc.Source, want)128+ }129+130+ m = hiddenBook(t)131+ script(t, m, `132+run("sheet.hide")133+set("A2", active_sheet())134+run("sheet.unhide", answer="data")135+`)136+ if m.sheet.Name() != "Data" || m.sheet.Hidden() || m.book().Lookup("Sheet3").Value(addr("A2")).Str != "Sheet3" {137+ t.Errorf("replay: on %s, %q", m.sheet.Name(), m.errMsg)138+ }139+ script(t, m, `run("sheet.hide")140+run("sheet.unhide", answer="Nope")`)141+ if !strings.Contains(m.warn, `Hidden sheets asks for one of Data: give run("sheet.unhide", answer=...)`) {142+ t.Errorf("bad answer: %q", m.warn)143+ }144+}
M internal/ui/macrohostnav.go +30 -0
@@ -22,6 +22,9 @@ func (h scriptHost) Select(ref, active string) error {
2222 if err != nil {2323 return err2424 }25+ if s.Hidden() {26+ return errors.New(hiddenMsg(s))27+ }2528 m.showSheet(s)2629 m.selectRect(r)2730 if active == "" {
@@ -162,6 +165,9 @@ func (h scriptHost) ActivateSheet(name string) error {
162165 if s == nil {163166 return fmt.Errorf("there's no sheet named %s", name)164167 }168+ if s.Hidden() {169+ return errors.New(hiddenMsg(s))170+ }165171 h.m.showSheet(s)166172 return nil167173 }
@@ -289,6 +295,9 @@ func (h scriptHost) answer(c *command, answer *string) error {
289295 // overlays (dialogs, pickers) can't be answered by a script.290296 func (h scriptHost) choose(c *command, answer *string) error {291297 m := h.m298+ if p, ok := m.overlay.(*picker); ok && p.answers {299+ return h.pickAnswer(c, p, answer)300+ }292301 bar, ok := m.overlay.(*choiceBar)293302 if !ok {294303 m.closeOverlay()
@@ -309,3 +318,24 @@ func (h scriptHost) choose(c *command, answer *string) error {
309318 m.closeOverlay()310319 return fmt.Errorf("%s asks %q: give run(%q, answer=...) with one of its keys", c.title, bar.msg, c.id)311320 }321+322+// pickAnswer answers a picker that asks a command's question with the323+// title of one of its items.324+func (h scriptHost) pickAnswer(c *command, p *picker, answer *string) error {325+ m := h.m326+ if answer != nil {327+ if cmd, ok := p.answer(m, *answer); ok {328+ m.macros.cmds = append(m.macros.cmds, cmd)329+ if m.mode == modeError {330+ return h.failure()331+ }332+ return nil333+ }334+ }335+ m.closeOverlay()336+ var titles []string337+ for _, it := range p.items {338+ titles = append(titles, it.title)339+ }340+ return fmt.Errorf("%s asks for one of %s: give run(%q, answer=...)", c.title, strings.Join(titles, ", "), c.id)341+}
M internal/ui/macrorec.go +3 -0
M internal/ui/menubar.go +2 -1
M internal/ui/names.go +6 -0
M internal/ui/palette.go +33 -3
@@ -107,6 +107,11 @@ type picker struct {
107107 // enter, when set, gets the first look at Enter with the search text,108108 // e.g. to take a typed path; it reports whether it handled it.109109 enter func(m *Model, query string) (tea.Cmd, bool)110+111+ // answers marks a picker that is the question of a command that112+ // changes the workbook: a recording keeps the title picked as the113+ // command's answer, and scripts answer it with a title.114+ answers bool110115 }111116 112117 const (
@@ -217,7 +222,7 @@ func (p *picker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
217222 case "enter":218223 return p.pick(m)219224 case "esc":220- m.closeOverlay()225+ p.close(m)221226 default:222227 before := m.line.text()223228 m.line.key(k)
@@ -237,7 +242,32 @@ func (p *picker) pick(m *Model) tea.Cmd {
237242 if p.sel >= len(p.shown) || p.shown[p.sel].item.off {238243 return nil239244 }240- return p.shown[p.sel].item.pick(m)245+ it := p.shown[p.sel].item246+ cmd := it.pick(m)247+ if p.answers {248+ m.recordAnswer(it.title, false)249+ }250+ return cmd251+}252+253+// close closes the picker without picking, which cancels the question254+// it asks.255+func (p *picker) close(m *Model) {256+ m.closeOverlay()257+ if p.answers {258+ m.recordAnswer("", true)259+ }260+}261+262+// answer picks the item titled title (in any case), as a script answers263+// the picker's question, and reports whether there was one.264+func (p *picker) answer(m *Model, title string) (tea.Cmd, bool) {265+ for i := range p.items {266+ if it := &p.items[i]; strings.EqualFold(it.title, title) && !it.off {267+ return it.pick(m), true268+ }269+ }270+ return nil, false241271 }242272 243273 // Rows of the box: the top border, the search field, a separator, then
@@ -247,7 +277,7 @@ const pickerFirstRow = 3
247277 func (p *picker) mouse(m *Model, e mouseEvent) tea.Cmd {248278 if e.box != pickerID {249279 if e.kind == mousePress {250- m.closeOverlay()280+ p.close(m)251281 }252282 return nil253283 }
M internal/ui/pivotactions.go +4 -0
M internal/ui/selection.go +6 -1
M internal/ui/tabs.go +27 -14
@@ -52,7 +52,7 @@ func init() {
5252 return nil5353 }},5454 &command{id: "sheet.delete", title: "Delete sheet", desc: "Delete the sheet; formulas that use it show #REF!",55- enabled: func(m *Model) bool { return m.book().Len() > 1 }, run: (*Model).confirmDeleteSheet},55+ enabled: func(m *Model) bool { return len(m.book().Visible()) > 1 }, run: (*Model).confirmDeleteSheet},5656 &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 },5757 run: func(m *Model) tea.Cmd { return m.moveSheet(-1) }},5858 &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 },
@@ -69,19 +69,27 @@ func init() {
6969 7070 // tabMenu is the menu of a sheet's tab, as in Sheets.7171 var tabMenu = []menuItem{72- {cmd: "sheet.rename", title: "Rename"}, {cmd: "sheet.duplicate", title: "Duplicate"}, {cmd: "sheet.delete", title: "Delete"}, sep,72+ {cmd: "sheet.rename", title: "Rename"}, {cmd: "sheet.duplicate", title: "Duplicate"}, {cmd: "sheet.delete", title: "Delete"}, {cmd: "sheet.hide"}, sep,7373 {cmd: "sheet.move_left", title: "Move left"}, {cmd: "sheet.move_right", title: "Move right"}, sep,74- {cmd: "sheet.new"}, {cmd: "sheet.goto"},74+ {cmd: "sheet.new"}, {cmd: "sheet.goto"}, {cmd: "sheet.unhide"},7575 }7676 77-// sheetAt returns the sheet d tabs from the one shown, or nil past the78-// ends: moving between sheets doesn't wrap around, as in Sheets.77+// sheetAt returns the sheet d tabs from the one shown, skipping hidden78+// sheets, or nil past the ends: moving between sheets doesn't wrap79+// around, as in Sheets.7980 func (g *grid) sheetAt(d int) *sheet.Sheet {80- i := g.book().Index(g.sheet) + d81- if i < 0 || i >= g.book().Len() {82- return nil81+ book, step, n := g.book(), 1, d82+ if d < 0 {83+ step, n = -1, -d84+ }85+ for i := book.Index(g.sheet) + step; i >= 0 && i < book.Len() && n > 0; i += step {86+ if s := book.Sheet(i); !s.Hidden() {87+ if n--; n == 0 {88+ return s89+ }90+ }8391 }84- return g.book().Sheet(i)92+ return nil8593 }8694 8795 func (m *Model) stepSheet(d int) tea.Cmd {
@@ -91,9 +99,12 @@ func (m *Model) stepSheet(d int) tea.Cmd {
9199 return nil92100 }93101 102+// moveSheet moves the sheet shown past its visible neighbour d tabs103+// away (hidden sheets between keep their order).94104 func (m *Model) moveSheet(d int) tea.Cmd {95- i := m.book().Index(m.sheet)96- m.book().MoveSheet(m.sheet, i+d)105+ if s := m.sheetAt(d); s != nil {106+ m.book().MoveSheet(m.sheet, m.book().Index(s))107+ }97108 return nil98109 }99110
@@ -212,16 +223,18 @@ func (m *Model) pointRef() string {
212223 func (m *Model) afterSheetsChange(prefer *sheet.Sheet, index int) {213224 book := m.book()214225 switch {215- case prefer != nil && prefer.Live():226+ case prefer != nil && prefer.Live() && !prefer.Hidden():216227 m.showSheet(prefer)217228 case !m.sheet.Live():218- s := book.Sheet(clamp(index, 0, book.Len()-1))229+ s := m.nearVisible(clamp(index, 0, book.Len()-1))219230 m.sheet = s // the old sheet is gone: nothing to remember of it220231 p := m.tabs.places[s]221232 m.cur, m.top, m.left = p.cur, p.top, p.left222233 m.clearSelection()223234 m.charts.last = -1224235 book.SetActive(s)236+ case m.sheet.Hidden():237+ m.showSheet(m.nearVisible(book.Index(m.sheet)))225238 }226239 }227240
@@ -275,7 +288,7 @@ func (m *Model) openRename() {
275288 func (m *Model) openSheetPicker() {276289 var items []pickItem277290 sel := 0278- for i, s := range m.book().Sheets() {291+ for i, s := range m.visibleSheets() {279292 if s == m.sheet {280293 sel = i281294 }
M internal/ui/tabs_test.go +1 -1
M internal/ui/tabstrip.go +16 -5
@@ -1,6 +1,7 @@
11 package ui22 33 import (4+ "slices"45 "strconv"56 "strings"67
@@ -50,7 +51,8 @@ type tabView struct {
5051 }5152 5253 func (m *Model) tabView() tabView {53- return tabView{sheets: m.book().Sheets(), active: m.book().Index(m.sheet), hover: m.mouse.hover, drag: m.mouse.drag}54+ sheets := m.visibleSheets()55+ return tabView{sheets: sheets, active: slices.Index(sheets, m.sheet), hover: m.mouse.hover, drag: m.mouse.drag}5456 }5557 5658 // tabSpan is a clickable part of the tab strip on the status line.
@@ -213,7 +215,7 @@ func (m *Model) tabPress(h hit, double bool) tea.Cmd {
213215 }214216 return m.runCommand("sheet.new")215217 case hitTab:216- s := m.book().Sheet(h.addr.Col)218+ s := m.tabSheet(h.addr.Col)217219 if m.editing() {218220 m.pointInto(s)219221 return nil
@@ -229,14 +231,23 @@ func (m *Model) tabPress(h hit, double bool) tea.Cmd {
229231 230232 // dropTab moves the dragged sheet to the tab it was released over.231233 func (m *Model) dropTab() {232- if m.mouse.hover.kind == hitTab && m.mouse.hover.addr.Col != m.book().Index(m.sheet) {233- to := m.mouse.hover.addr.Col234+ if m.mouse.hover.kind != hitTab {235+ return236+ }237+ if to := m.book().Index(m.tabSheet(m.mouse.hover.addr.Col)); to != m.book().Index(m.sheet) {234238 m.book().MoveSheet(m.sheet, to)235239 m.note = "Moved " + m.sheet.Name() + " to position " + strconv.Itoa(to+1)236240 m.record(macro.Call("move_sheet", to+1))237241 }238242 }239243 244+// tabSheet is the sheet of tab i, counting the tabs shown (hidden sheets245+// have none).246+func (m *Model) tabSheet(i int) *sheet.Sheet {247+ tabs := m.visibleSheets()248+ return tabs[clamp(i, 0, len(tabs)-1)]249+}250+240251 // tabRightClick shows the tab's sheet and opens its menu.241252 func (m *Model) tabRightClick(h hit, x, y int) {242253 if h.kind != hitTab {
@@ -245,6 +256,6 @@ func (m *Model) tabRightClick(h hit, x, y int) {
245256 }246257 return247258 }248- m.showSheet(m.book().Sheet(h.addr.Col))259+ m.showSheet(m.tabSheet(h.addr.Col))249260 m.showContextMenu(tabMenu, x, y)250261 }
M internal/ui/trace.go +3 -0