- commit
- 5a0b9b1b3b50b879e9b49cd12962c9e66c1d763f
- parent
- 97caaa7353423a0942e07d05227e1f05837def93
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 3 | +- |
| M | docs/ | 6 | + |
| M | docs/ | 3 | +- |
| M | docs/ | 15 | ++- |
| M | internal/ | 2 | + |
| M | internal/ | 9 | ++ |
| M | internal/ | 34 | +++++++ |
| M | internal/ | 6 | + |
| A | internal/ | 84 | ++++++++++++++++++ |
| A | internal/ | 90 | +++++++++++++++++++ |
| M | internal/ | 15 | ++- |
| M | internal/ | 7 | +- |
| M | internal/ | 2 | + |
| M | internal/ | 6 | +- |
| M | internal/ | 4 | +- |
| A | internal/ | 113 | ++++++++++++++++++++++++ |
| A | internal/ | 144 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 30 | ++++++ |
| M | internal/ | 3 | + |
| M | internal/ | 3 | +- |
| M | internal/ | 6 | + |
| M | internal/ | 36 | +++++++- |
| M | internal/ | 4 | + |
| M | internal/ | 7 | +- |
| M | internal/ | 41 | ++++++--- |
| M | internal/ | 2 | +- |
| M | internal/ | 21 | +++- |
| M | internal/ | 3 | + |
@@ -40,7 +40,8 @@ searches every command. `012 config edit` opens the settings file.
4040 type; number formats, bold, italic, underline, strikethrough, alignment.4141 - **Several sheets.** Sheet tabs on the status line, references between4242 sheets (`=Sheet2!A1`) that follow renames, pointing into another sheet43- while typing a formula, and Sheets' keys for moving between them.43+ while typing a formula, hiding sheets, and Sheets' keys for moving44+ between them.4445 - **Data tools.** Freeze rows and columns, multi-column sort, filters with4546 value pickers and conditions, find and replace with regular expressions,4647 tracing precedents and dependents, live pivot tables and frequency@@ -84,6 +84,12 @@ arithmetic, the sheet shown when saved) stay at the top:
8484 8585 Older builds refuse version 4 files rather than lose sheets.8686 87+A hidden sheet (Hide sheet on its tab) has `"hidden": true` after its88+name. It needs no version bump: builds without hidden sheets ignore the89+field and show the sheet. A file whose sheets are all hidden opens with90+the first one shown. XLSX downloads write hidden sheets hidden, and91+sheets hidden in Excel import hidden.92+8793 ## Pivot tables8894 8995 A workbook with a pivot table is version 5: version 4 with a `pivot`@@ -43,6 +43,7 @@ the app uses, so it never drifts from what the keys do.
4343 | Ctrl+PgDn / Ctrl+PgUp, Alt+Right / Alt+Left | Next / previous sheet; while typing a formula, point into it to insert `Sheet2!A1` |4444 | Shift+F11 | New sheet (also Insert > Sheet) |4545 | Alt+Shift+K | Go to a sheet by name |46+| Right-click a tab > Hide sheet, View > Hidden sheets | Hide a sheet (formulas still read it; next, previous and the tabs skip it), and list the hidden ones to show one again |4647 | Alt+F, Alt+E, Alt+V, Alt+I, Alt+O, Alt+H, F10, click a title | Open a menu (arrows move, Enter runs, Esc closes) |4748 | Ctrl+K, Alt+/, Ctrl+Shift+P | Search the menus: find and run any command |4849 | Right-click, Shift+F10 | Cell, column or row menu |@@ -125,7 +126,7 @@ bar in terminals that support it (OSC 22).
125126 126127 The sheet tabs at the left of the status line take the mouse too: click a127128 tab to show its sheet, double-click to rename it, right-click for its menu128-(rename, duplicate, delete, move left or right), drag it onto another tab to129+(rename, duplicate, delete, hide, move left or right), drag it onto another tab to129130 move it there, and click `+` to add a sheet. When the tabs don't all fit,130131 `‹` and `›` step through them. While typing a formula, clicking a tab points131132 into that sheet, as in Sheets.@@ -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.@@ -24,6 +24,7 @@ type Snapshot struct {
2424 // named ranges as name and Excel reference, e.g. Q3!$B$2:$B$9.2525 Sheets []*Snapshot2626 Names [][2]string27+ Hidden bool // a hidden sheet of Sheets, written hidden2728 }2829 2930 // SnapCell is one non-blank cell of a snapshot.@@ -75,6 +76,7 @@ func SnapBook(s *sheet.Sheet) *Snapshot {
7576 all := []*Snapshot{}7677 for _, t := range s.Book().Sheets() {7778 sn := Snap(t, sheet.Rect{}, t.Name())79+ sn.Hidden = t.Hidden() && t != s7880 if t == s {7981 out = sn8082 }@@ -74,6 +74,11 @@ func importXLSX(ctx context.Context, name string, opt Options) (*Result, error)
7474 notes = append(notes, importXLSXNames(x, book)...)7575 active := book.Sheet(clamp(x.GetActiveSheetIndex(), 0, book.Len()-1))7676 book.SetActive(active)77+ for i, ws := range names {78+ if shown, err := x.GetSheetVisible(ws); err == nil && !shown && book.Sheet(i) != active {79+ book.HideSheet(book.Sheet(i)) // hidden in Excel, hidden here80+ }81+ }7782 b.s = active7883 prog.setRows(done)7984 s, notes := b.finish(notes)@@ -254,6 +259,10 @@ func exportXLSX(_ context.Context, name string, snap *Snapshot, _ ExportOptions)
254259 res.Rows += rows255260 if sn == snap || snap.Sheets == nil {256261 x.SetActiveSheet(i)262+ } else if sn.Hidden {263+ if err := x.SetSheetVisible(ws, false); err != nil {264+ return nil, err265+ }257266 }258267 }259268 for _, n := range snap.Names {@@ -357,3 +357,37 @@ func TestXLSXMissingSheetAsValue(t *testing.T) {
357357 t.Errorf("B1 value %q", got)358358 }359359 }360+361+// Hidden sheets go out to Excel hidden, and come back hidden.362+func TestXLSXHiddenSheets(t *testing.T) {363+ src := build(t, map[string]string{"A1": "=Data!A1*2"})364+ book := src.Book()365+ data, _ := book.AddSheet("Data", 1)366+ data.Set(addr(t, "A1"), "21")367+ if err := book.HideSheet(data); err != nil {368+ t.Fatal(err)369+ }370+ name := filepath.Join(t.TempDir(), "hidden.xlsx")371+ if _, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{}); err != nil {372+ t.Fatal(err)373+ }374+ x, err := excelize.OpenFile(name)375+ if err != nil {376+ t.Fatal(err)377+ }378+ if shown, _ := x.GetSheetVisible("Data"); shown {379+ t.Error("Data is visible in Excel")380+ }381+ if shown, _ := x.GetSheetVisible(x.GetSheetName(0)); !shown {382+ t.Error("the first sheet is hidden in Excel")383+ }384+ x.Close()385+ got, err := Import(context.Background(), name, Options{})386+ if err != nil {387+ t.Fatal(err)388+ }389+ gb := got.Sheet.Book()390+ if !gb.Lookup("Data").Hidden() || got.Sheet.Hidden() || shown(got.Sheet, addr(t, "A1")) != "42" {391+ t.Errorf("read back: Data hidden %v, shown %s hidden %v", gb.Lookup("Data").Hidden(), got.Sheet.Name(), got.Sheet.Hidden())392+ }393+}@@ -60,6 +60,7 @@ type fileFormat struct {
6060 // fileSheet is one sheet of a file.6161 type fileSheet struct {6262 Name string `json:"name,omitempty"`63+ Hidden bool `json:"hidden,omitempty"` // version 4, ignored by older builds6364 Widths map[string]int `json:"widths,omitempty"`6465 fileView6566 Cells map[string]json.RawMessage `json:"cells"`@@ -212,6 +213,9 @@ func (w *Workbook) Write(out io.Writer) error {
212213 b.WriteByte(',')213214 }214215 fmt.Fprintf(&b, "\n {\n \"name\": %s,\n", jsonString(s.name))216+ if s.tabHidden {217+ b.WriteString(" \"hidden\": true,\n")218+ }215219 if err := s.writeBody(&b, " ", ""); err != nil {216220 return err217221 }@@ -392,6 +396,7 @@ func ReadBook(r io.Reader) (*Workbook, error) {
392396 }393397 }394398 w.active = clampInt(f.Active, 0, len(w.sheets)-1)399+ w.settleHidden()395400 // Anything but "decimal" (say, a mode from a later build) computes in396401 // binary, as the file would in a build without the setting.397402 w.decimal = f.Arithmetic == "decimal"@@ -434,6 +439,7 @@ func (w *Workbook) readNames(names map[string]string) error {
434439 435440 // read fills an empty sheet from its part of a file.436441 func (s *Sheet) read(f fileSheet, version int) error {442+ s.tabHidden = f.Hidden437443 for name, width := range f.Widths {438444 c, ok := ParseCol(name)439445 if !ok {@@ -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+}@@ -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+}@@ -10,16 +10,20 @@ import (
1010 // Undo steps that add, delete, rename or reorder sheets snapshot the1111 // workbook's list of sheets.1212 13-// sheetList is the order and names of the sheets.13+// sheetList is the order and names of the sheets, and which are hidden.1414 type sheetList struct {15- order []*Sheet16- names map[*Sheet]string15+ order []*Sheet16+ names map[*Sheet]string17+ hidden map[*Sheet]bool1718 }1819 1920 func (w *Workbook) sheetList() *sheetList {20- l := &sheetList{order: slices.Clone(w.sheets), names: map[*Sheet]string{}}21+ l := &sheetList{order: slices.Clone(w.sheets), names: map[*Sheet]string{}, hidden: map[*Sheet]bool{}}2122 for _, s := range w.sheets {2223 l.names[s] = s.name24+ if s.tabHidden {25+ l.hidden[s] = true26+ }2327 }2428 return l2529 }@@ -36,6 +40,7 @@ func (w *Workbook) setSheets(l *sheetList) {
3640 w.sheets = slices.Clone(l.order)3741 for _, s := range w.sheets {3842 s.name = l.names[s]43+ s.tabHidden = l.hidden[s]3944 if s.live {4045 w.byKey[formula.SheetKey(s.name)] = s4146 } else {@@ -46,5 +51,5 @@ func (w *Workbook) setSheets(l *sheetList) {
4651 }4752 4853 func (l *sheetList) equal(m *sheetList) bool {49- return slices.Equal(l.order, m.order) && maps.Equal(l.names, m.names)54+ return slices.Equal(l.order, m.order) && maps.Equal(l.names, m.names) && maps.Equal(l.hidden, m.hidden)5055 }@@ -51,9 +51,10 @@ func (c *Cell) Blank() bool { return c == nil || c.Input == "" }
5151 5252 // Sheet is a sparse worksheet, one of a Workbook's sheets.5353 type Sheet struct {54- wb *Workbook55- name string56- live bool // in the workbook's list; false once deleted54+ wb *Workbook55+ name string56+ live bool // in the workbook's list; false once deleted57+ tabHidden bool // left out of the tabs; see hidden.go5758 5859 cells cellStore // see store.go5960 widths map[int]int@@ -244,6 +244,8 @@ func (w *Workbook) DeleteSheet(s *Sheet) error {
244244 return errors.New("That sheet was already deleted")245245 case len(w.sheets) == 1:246246 return errors.New("A spreadsheet needs at least one sheet")247+ case !s.tabHidden && w.visibleCount() == 1:248+ return errLastVisible247249 }248250 w.change(s, "delete sheet "+s.name, Rect{}, func() {249251 w.recordSheets()@@ -115,7 +115,7 @@ func (m *Model) openFind(replace bool) {
115115 f = &findBar{cur: -1}116116 }117117 f.scope, f.home = nil, m.sheet118- if f.where == inRange || f.where == inAll && m.book().Len() == 1 {118+ if f.where == inRange || f.where == inAll && len(m.book().Visible()) == 1 {119119 f.where = inSheet120120 }121121 if m.hasRange() {@@ -170,7 +170,7 @@ func (f *findBar) options() sheet.FindOptions {
170170 func (f *findBar) sheets(m *Model) []*sheet.Sheet {171171 switch f.where {172172 case inAll:173- return m.book().Sheets()173+ return m.book().Visible() // a match on a hidden sheet couldn't be shown174174 case inRange:175175 if f.home.Live() {176176 return []*sheet.Sheet{f.home}@@ -237,7 +237,7 @@ func (f *findBar) step(m *Model, d int) {
237237 // the sheet, then all sheets (if there are several).238238 func (f *findBar) nextScope(m *Model) findScope {239239 order := []findScope{inSheet}240- if m.book().Len() > 1 {240+ if len(m.book().Visible()) > 1 {241241 order = append(order, inAll)242242 }243243 if f.scope != nil {@@ -47,7 +47,7 @@ func (f *findBar) parts(m *Model) []findPart {
4747 parts = append(parts, chip(true, "in "+f.scope.String(), "alt+s"))4848 case f.where == inAll:4949 parts = append(parts, chip(true, "in all sheets", "alt+s"))50- case f.scope != nil || m.book().Len() > 1:50+ case f.scope != nil || len(m.book().Visible()) > 1:5151 parts = append(parts, chip(false, "in "+m.sheet.Name(), "alt+s"))5252 }5353 return parts@@ -166,7 +166,7 @@ func (f *findBar) status(m *Model) (string, string) {
166166 pairs = []string{"Enter", "replace", "Ctrl+Enter", "all", "Tab", "field", "Esc", "close"}167167 }168168 desc := "Alt+C/W/R/= options"169- if f.scope != nil || m.book().Len() > 1 {169+ if f.scope != nil || len(m.book().Visible()) > 1 {170170 desc = "Alt+C/W/R/=/S options"171171 }172172 for {@@ -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+}@@ -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+}@@ -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+}@@ -131,6 +131,9 @@ func (m *Model) recordingCommand(c *command) tea.Cmd {
131131 cmd := c.run(m)132132 r.depth--133133 _, choosing := m.overlay.(*choiceBar)134+ if p, ok := m.overlay.(*picker); ok && p.answers {135+ choosing = true136+ }134137 switch {135138 case m.mode == modePrompt || choosing:136139 r.pending = c.id // recorded once answered@@ -49,7 +49,7 @@ var menuBar = []menuDef{
4949 {cmd: "clear"}, {cmd: "select.all"}, {cmd: "goto"}, sep,5050 {cmd: "delete.row", title: "Delete row"}, {cmd: "delete.col", title: "Delete column"}, {cmd: "delete.selection"}, sep,5151 {title: "Sheet", items: []menuItem{52- {cmd: "sheet.rename", title: "Rename"}, {cmd: "sheet.duplicate", title: "Duplicate"}, {cmd: "sheet.delete", title: "Delete"}, sep,52+ {cmd: "sheet.rename", title: "Rename"}, {cmd: "sheet.duplicate", title: "Duplicate"}, {cmd: "sheet.delete", title: "Delete"}, {cmd: "sheet.hide"}, sep,5353 {cmd: "sheet.move_left", title: "Move left"}, {cmd: "sheet.move_right", title: "Move right"}, sep,5454 {cmd: "sheet.next"}, {cmd: "sheet.prev"}, {cmd: "sheet.goto"},5555 }},@@ -61,6 +61,7 @@ var menuBar = []menuDef{
6161 {cmd: "view.freeze_cols0", title: "No columns"}, {cmd: "view.freeze_cols1", title: "1 column"},6262 {cmd: "view.freeze_cols2", title: "2 columns"}, {cmd: "view.freeze_cols_cur", title: "Up to current column"},6363 }}, sep,64+ {cmd: "sheet.unhide"}, sep,6465 {cmd: "palette", title: "Command palette"}, {cmd: "help"},6566 }},6667 {title: "Insert", accel: 'i', items: []menuItem{@@ -69,6 +69,9 @@ func namesItems(m *Model, sel sheet.Rect) []pickItem {
6969 title: n.Name, name: len(n.Name), detail: n.Ref(), desc: desc, off: n.Gone(),7070 pick: func(m *Model) tea.Cmd {7171 m.closeOverlay()72+ if m.refuseHidden(n.Sheet) {73+ return nil74+ }7275 m.showSheet(n.Sheet)7376 m.selectRect(n.Range)7477 return nil@@ -151,6 +154,9 @@ func (m *Model) editName(n sheet.Name, sel sheet.Rect) {
151154 return nil152155 }153156 r := n.Range157+ if m.refuseHidden(n.Sheet) {158+ return nil159+ }154160 if n.Sheet.Live() {155161 m.showSheet(n.Sheet) // the range is pointed at on its own sheet156162 }@@ -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 }@@ -307,6 +307,10 @@ func (e *pivotEditor) editSource(m *Model) {
307307 e.msg = "The source sheet " + p.Source + " doesn't exist"308308 return309309 }310+ if src.Hidden() {311+ e.msg = hiddenMsg(src)312+ return313+ }310314 m.closeOverlay()311315 m.showSheet(src)312316 if !p.Lost {@@ -231,7 +231,9 @@ func (m *Model) gotoText(text string) bool {
231231 return true232232 }233233 if text = rest; text == "" {234- m.showSheet(target)234+ if !m.refuseHidden(target) {235+ m.showSheet(target)236+ }235237 return true236238 }237239 }@@ -242,6 +244,9 @@ func (m *Model) gotoText(text string) bool {
242244 if !ok {243245 return false244246 }247+ if m.refuseHidden(target) {248+ return true249+ }245250 m.showSheet(target)246251 if r.From == r.To {247252 m.clearSelection()@@ -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 }@@ -19,7 +19,7 @@ func tabX(t *testing.T, m *Model, name string) int {
1919 t.Helper()2020 _, spans := m.statusLayout()2121 for _, sp := range spans {22- if name == "+" && sp.kind == hitTabAdd || sp.kind == hitTab && m.book().Sheet(sp.index).Name() == name {22+ if name == "+" && sp.kind == hitTabAdd || sp.kind == hitTab && m.tabSheet(sp.index).Name() == name {2323 return sp.x + sp.w/22424 }2525 }@@ -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 }@@ -1,6 +1,7 @@
11 package ui22 33 import (4+ "slices"45 "strconv"56 "strings"67 @@ -53,6 +54,8 @@ func (m *Model) stepTrace(dependents bool) {
5354 } else {5455 t.targets = m.sheet.Precedents(m.cur)5556 }57+ // Cells on hidden sheets can't be shown, so the trace skips them.58+ t.targets = slices.DeleteFunc(t.targets, func(tg sheet.Target) bool { return tg.Sheet.Hidden() })5659 if len(t.targets) == 0 {5760 m.trace = nil5861 m.note = "No formulas read " + m.cur.String()