Spilled values draw in their own theme role (the progress bar's cyan or blue), the formula bar shows a spilled cell's formula dimmed, as Sheets does, and the context line says "Spilled from B2" there and "Spills into B2:C9" on the formula's own cell. Commands writing spilled cells are refused with the formula to edit, through the pivot guard; those that move or format cells, and edits of a range holding the formula, go ahead. Co-Authored-By: Claude <noreply@anthropic.com>
- commit
- 998955f3e73e10568ef00491239e6ffc370e419d
- parent
- 6c720034b15ba0f4de87c96b9fe1e470961a9cc8
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 2 | +- |
| M | internal/ | 12 | ++++- |
| M | internal/ | 12 | +++--- |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 12 | +++--- |
| M | internal/ | 14 | ++++++ |
| M | internal/ | 8 | ++++ |
| M | internal/ | 21 | +++++++-- |
| M | internal/ | 2 | +- |
| A | internal/ | 66 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 2 | + |
| M | internal/ | 2 | + |
@@ -139,7 +139,7 @@ func (m *Model) pasteText(content string) bool {
139139 m.fail(sheet.ErrPasteEdge.Error())140140 return true141141 }142- if m.refusePivot(r) {142+ if m.refuseEdit(r, false) {143143 return true144144 }145145 m.sheet.Batch(sheet.Change{Label: "paste into " + r.String(), Focus: r}, func() error {@@ -30,9 +30,15 @@ type command struct {
3030 checked func(m *Model) bool3131 3232 // edits, when set, is the range of the sheet shown that the command33- // changes. A command that would change a pivot table's results is34- // refused, as Sheets refuses (see pivot.go).33+ // changes. A command that would change a pivot table's results or34+ // cells an array formula spills is refused, as Sheets refuses (see35+ // pivot.go).3536 edits func(m *Model) sheet.Rect37+ // keepsSpills is set when the command moves the cells of edits38+ // (inserting or deleting rows or columns) or changes only their39+ // formatting: an array spilling there keeps its values, so only40+ // pivots refuse it.41+ keepsSpills bool3642 3743 // macro says how recording and scripts treat the command; see3844 // macroUse.@@ -168,7 +174,7 @@ func (m *Model) runCommand(id string) tea.Cmd {
168174 // recalculation, a pivot refresh, a sort.169175 span := m.spans.Start("command", slog.String("id", id))170176 defer span.End()171- if c.edits != nil && m.refusePivot(c.edits(m)) {177+ if c.edits != nil && m.refuseEdit(c.edits(m), c.keepsSpills) {172178 return nil173179 }174180 if m.rec != nil {@@ -26,34 +26,34 @@ func init() {
2626 &command{id: "edit.fill_right", title: "Fill right", desc: "Copy the left column of the selection into the columns right of it", edits: (*Model).selection, run: func(m *Model) tea.Cmd {2727 return m.fill(m.sheet.FillRight)2828 }},29- &command{id: "insert.row_above", title: "Insert rows above", desc: "Insert as many rows above the selection as it spans", edits: rowsTarget, run: func(m *Model) tea.Cmd {29+ &command{id: "insert.row_above", title: "Insert rows above", desc: "Insert as many rows above the selection as it spans", edits: rowsTarget, keepsSpills: true, run: func(m *Model) tea.Cmd {3030 r := m.selection()3131 return m.structural(m.sheet.InsertRows(r.From.Row, r.To.Row-r.From.Row+1))3232 }},33- &command{id: "insert.row_below", title: "Insert rows below", desc: "Insert as many rows below the selection as it spans", edits: rowsBelowTarget, run: func(m *Model) tea.Cmd {33+ &command{id: "insert.row_below", title: "Insert rows below", desc: "Insert as many rows below the selection as it spans", edits: rowsBelowTarget, keepsSpills: true, run: func(m *Model) tea.Cmd {3434 r := m.selection()3535 if r.To.Row == sheet.MaxRows-1 {3636 return m.structural(sheet.ErrPushedOff)3737 }3838 return m.structural(m.sheet.InsertRows(r.To.Row+1, r.To.Row-r.From.Row+1))3939 }},40- &command{id: "insert.col_left", title: "Insert columns left", desc: "Insert as many columns left of the selection as it spans", edits: colsTarget, run: func(m *Model) tea.Cmd {40+ &command{id: "insert.col_left", title: "Insert columns left", desc: "Insert as many columns left of the selection as it spans", edits: colsTarget, keepsSpills: true, run: func(m *Model) tea.Cmd {4141 r := m.selection()4242 return m.structural(m.sheet.InsertCols(r.From.Col, r.To.Col-r.From.Col+1))4343 }},44- &command{id: "insert.col_right", title: "Insert columns right", desc: "Insert as many columns right of the selection as it spans", edits: colsRightTarget, run: func(m *Model) tea.Cmd {44+ &command{id: "insert.col_right", title: "Insert columns right", desc: "Insert as many columns right of the selection as it spans", edits: colsRightTarget, keepsSpills: true, run: func(m *Model) tea.Cmd {4545 r := m.selection()4646 if r.To.Col == sheet.MaxCols-1 {4747 return m.structural(sheet.ErrPushedOff)4848 }4949 return m.structural(m.sheet.InsertCols(r.To.Col+1, r.To.Col-r.From.Col+1))5050 }},51- &command{id: "delete.row", title: "Delete rows", desc: "Delete the selected rows; references to them become #REF!", edits: rowsTarget, run: func(m *Model) tea.Cmd {51+ &command{id: "delete.row", title: "Delete rows", desc: "Delete the selected rows; references to them become #REF!", edits: rowsTarget, keepsSpills: true, run: func(m *Model) tea.Cmd {5252 r := m.selection()5353 m.sheet.DeleteRows(r.From.Row, r.To.Row-r.From.Row+1)5454 return nil5555 }},56- &command{id: "delete.col", title: "Delete columns", desc: "Delete the selected columns; references to them become #REF!", edits: colsTarget, run: func(m *Model) tea.Cmd {56+ &command{id: "delete.col", title: "Delete columns", desc: "Delete the selected columns; references to them become #REF!", edits: colsTarget, keepsSpills: true, run: func(m *Model) tea.Cmd {5757 r := m.selection()5858 m.sheet.DeleteCols(r.From.Col, r.To.Col-r.From.Col+1)5959 return nil@@ -73,7 +73,7 @@ func (m *Model) dragFillTo(a sheet.Addr) {
7373 func (m *Model) finishFill() {7474 m.mouse.drag = dragNone7575 src, dst := m.selection(), m.mouse.fillTo76- if dst == src || m.refusePivot(dst) {76+ if dst == src || m.refuseEdit(dst, false) {7777 return7878 }7979 m.recordFlush()@@ -58,7 +58,7 @@ var alignments = []struct {
5858 func init() {5959 for _, nf := range numberFormats {6060 f, title := nf.f, nf.title61- register(&command{id: nf.id, title: title, desc: nf.desc, edits: (*Model).selection, run: func(m *Model) tea.Cmd {61+ register(&command{id: nf.id, title: title, desc: nf.desc, edits: (*Model).selection, keepsSpills: true, run: func(m *Model) tea.Cmd {6262 r := m.selection()6363 m.sheet.SetFormat(r, f)6464 m.formatted(title + " format")@@ -67,7 +67,7 @@ func init() {
6767 }6868 for _, ts := range textStyles {6969 get, title := ts.get, ts.title70- register(&command{id: ts.id, title: title, desc: ts.desc, edits: (*Model).selection, run: func(m *Model) tea.Cmd {70+ register(&command{id: ts.id, title: title, desc: ts.desc, edits: (*Model).selection, keepsSpills: true, run: func(m *Model) tea.Cmd {7171 cur := m.sheet.CellStyle(m.cur)7272 on := !*get(&cur)7373 r := m.selection()@@ -85,7 +85,7 @@ func init() {
8585 }8686 for _, al := range alignments {8787 a, title := al.a, al.title88- register(&command{id: al.id, title: title, desc: al.desc, edits: (*Model).selection, run: func(m *Model) tea.Cmd {88+ register(&command{id: al.id, title: title, desc: al.desc, edits: (*Model).selection, keepsSpills: true, run: func(m *Model) tea.Cmd {8989 r := m.selection()9090 m.sheet.Batch(sheet.Change{Label: "align " + r.String() + " " + strings.ToLower(title), Focus: r}, func() error {9191 m.sheet.SetStyle(r, func(s *sheet.Style) { s.Align = a })@@ -96,17 +96,17 @@ func init() {
9696 }})9797 }9898 register(99- &command{id: "format.decimals_more", title: "Increase decimal places", desc: "Show one more decimal place", edits: (*Model).selection, run: func(m *Model) tea.Cmd {99+ &command{id: "format.decimals_more", title: "Increase decimal places", desc: "Show one more decimal place", edits: (*Model).selection, keepsSpills: true, run: func(m *Model) tea.Cmd {100100 m.sheet.AdjustDecimals(m.selection(), 1)101101 m.formatted("One more decimal place")102102 return nil103103 }},104- &command{id: "format.decimals_less", title: "Decrease decimal places", desc: "Show one less decimal place", edits: (*Model).selection, run: func(m *Model) tea.Cmd {104+ &command{id: "format.decimals_less", title: "Decrease decimal places", desc: "Show one less decimal place", edits: (*Model).selection, keepsSpills: true, run: func(m *Model) tea.Cmd {105105 m.sheet.AdjustDecimals(m.selection(), -1)106106 m.formatted("One less decimal place")107107 return nil108108 }},109- &command{id: "format.clear", title: "Clear formatting", desc: "Reset number formats and text styles, keeping contents", edits: (*Model).selection, run: func(m *Model) tea.Cmd {109+ &command{id: "format.clear", title: "Clear formatting", desc: "Reset number formats and text styles, keeping contents", edits: (*Model).selection, keepsSpills: true, run: func(m *Model) tea.Cmd {110110 m.sheet.ClearFormatting(m.selection())111111 m.formatted("Formatting cleared")112112 return nil@@ -44,6 +44,20 @@ func spanStyle(th *theme.Theme, base lipgloss.Style, sp rowtext.Span) lipgloss.S
4444 return s4545 }4646 47+// spillLine says where the active cell's value comes from when an array48+// formula spilled it, or where the active cell's array goes: "Spilled49+// from B2", "Spills into B2:C9".50+func (m *Model) spillLine() string {51+ if anchor, ok := m.sheet.SpillAnchor(m.cur); ok {52+ return m.th.Muted.Render("Spilled from ") + m.th.Key.Render(anchor.String()) +53+ m.th.Muted.Render("; edit the formula there")54+ }55+ if area, ok := m.sheet.SpillArea(m.cur); ok {56+ return m.th.Muted.Render("Spills into ") + m.th.Key.Render(area.String())57+ }58+ return ""59+}60+4761 // errorLine explains the active cell's error on the context line, e.g.4862 // "#DIV/0! Division by zero in B3/0".4963 func (m *Model) errorLine() string {@@ -185,6 +185,11 @@ func (m *Model) formulaBar() string {
185185 case modePoint:186186 return box + m.entry.prefix + m.th.Selection.Render(m.pointRef()) + m.entry.suffix187187 }188+ if anchor, ok := m.sheet.SpillAnchor(m.cur); ok {189+ // A spilled cell shows the formula it spills from, dimmed, as190+ // Sheets does.191+ return box + m.th.Muted.Render(m.sheet.Cell(anchor).Input)192+ }188193 if c := m.sheet.Cell(m.cur); c != nil {189194 return box + c.Input190195 }@@ -218,6 +223,9 @@ func (m *Model) contextLineText() string {
218223 if left == "" {219224 left = m.errorLine()220225 }226+ if left == "" {227+ left = m.spillLine()228+ }221229 if left == "" {222230 left = m.recordingLine()223231 }@@ -36,13 +36,24 @@ func init() {
3636 keyAliases["alt+F"] = "alt+shift+f"3737 }3838 39-// refusePivot reports whether r overlaps a pivot table's results, which40-// can't be edited, saying so on the context line.41-func (m *Model) refusePivot(r sheet.Rect) bool {42- if !m.sheet.InPivot(r) {39+// refuseEdit reports whether r overlaps a pivot table's results or40+// cells an array formula spills (unless r holds the formula too, or41+// keepsSpills says the edit moves or formats cells rather than writing42+// them), which can't be edited, saying so on the context line.43+func (m *Model) refuseEdit(r sheet.Rect, keepsSpills bool) bool {44+ if m.sheet.InPivot(r) {45+ m.note = sheet.ErrPivotEdit.Error()46+ return true47+ }48+ if keepsSpills {49+ return false50+ }51+ a, ok := m.sheet.InSpill(r)52+ if !ok {4353 return false4454 }45- m.note = sheet.ErrPivotEdit.Error()55+ anchor, _ := m.sheet.SpillAnchor(a)56+ m.note = a.String() + " shows part of the array " + anchor.String() + " spills: edit the formula in " + anchor.String()4657 return true4758 }4859 @@ -62,7 +62,7 @@ func (m *Model) sort(r sheet.Rect, keys []sheet.SortKey) tea.Cmd {
6262 m.note = "Nothing to sort"6363 return nil6464 }65- if m.refusePivot(r) {65+ if m.refuseEdit(r, false) {6666 return nil6767 }6868 span := m.spans.Start("sort", slog.Int("rows", r.To.Row-r.From.Row+1), slog.Int("cols", r.To.Col-r.From.Col+1), slog.Int("keys", len(keys)))@@ -0,0 +1,66 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+)7+8+// An array formula spills: the spilled cells say where they come from,9+// show its formula dimmed, and refuse edits as Sheets' array results do,10+// while the formula's own cell and a range holding it stay editable.11+func TestSpillInGrid(t *testing.T) {12+ m := newModel()13+ press(t, m, "=SEQUENCE(3, 2)", "<enter>", "<up>")14+ if shows(m, "B3") != "6" {15+ t.Fatalf("B3 shows %q", shows(m, "B3"))16+ }17+ if !strings.Contains(line(m, contextLine), "Spills into A1:B3") {18+ t.Errorf("anchor's context line %q", line(m, contextLine))19+ }20+ press(t, m, "<down>", "<right>")21+ if got := line(m, contextLine); !strings.Contains(got, "Spilled from A1") {22+ t.Errorf("spilled cell's context line %q", got)23+ }24+ if got := bar(m); got != "=SEQUENCE(3, 2)" {25+ t.Errorf("formula bar %q, want the anchor's formula", got)26+ }27+ for _, keys := range [][]string{{"x"}, {"<delete>"}, {"<enter>"}, {"<ctrl+d>"}} {28+ press(t, m, keys...)29+ if m.mode != modeReady || !strings.Contains(line(m, contextLine), "B2 shows part of the array A1 spills") {30+ t.Errorf("%v: mode %v, %q", keys, m.mode, line(m, contextLine))31+ }32+ press(t, m, "<esc>")33+ }34+ // Formatting a spilled cell keeps its value.35+ press(t, m, "<ctrl+b>")36+ if c := m.sheet.Cell(addr("B2")); shows(m, "B2") != "4" || c == nil || !c.Style.Bold {37+ t.Errorf("B2 after bold: %q %+v", shows(m, "B2"), c)38+ }39+ // Inserting a row through the spill recomputes it.40+ press(t, m, "<ctrl+alt+=>")41+ if strings.Contains(m.note, "spills") || shows(m, "B3") != "6" || shows(m, "B4") != "" {42+ t.Errorf("after inserting a row: %q, B3 %q, B4 %q", m.note, shows(m, "B3"), shows(m, "B4"))43+ }44+ // Copying pastes values; clearing the whole spill clears the formula.45+ press(t, m, "<ctrl+g>", "A1:B4", "<enter>", "<delete>")46+ if shows(m, "A1") != "" || shows(m, "B3") != "" {47+ t.Errorf("after clearing the spill: A1 %q B3 %q", shows(m, "A1"), shows(m, "B3"))48+ }49+}50+51+// Cells in the way of a spill make the formula #REF!, with Sheets'52+// explanation, until they're cleared.53+func TestSpillBlockedInGrid(t *testing.T) {54+ m := newModel()55+ press(t, m, "<down>", "<down>", "x", "<enter>", "<ctrl+home>", "=SEQUENCE(3)", "<enter>", "<up>")56+ if shows(m, "A1") != "#REF!" {57+ t.Fatalf("A1 shows %q", shows(m, "A1"))58+ }59+ if got := line(m, contextLine); !strings.Contains(got, "would overwrite data in A3") {60+ t.Errorf("context line %q", got)61+ }62+ press(t, m, "<down>", "<down>", "<delete>")63+ if shows(m, "A1") != "1" || shows(m, "A3") != "3" {64+ t.Errorf("after clearing A3: A1 %q A3 %q", shows(m, "A1"), shows(m, "A3"))65+ }66+}@@ -43,6 +43,7 @@ type Theme struct {
4343 // supports colored underlines.4444 ErrorMark lipgloss.Style4545 Link lipgloss.Style // a cell's URL or HYPERLINK label, layered on the cell's role46+ Spilled lipgloss.Style // values an array formula spilled into the cells below and right of it4647 Found lipgloss.Style // cells matching an open search4748 Traced lipgloss.Style // precedents or dependents being traced4849 Argument lipgloss.Style // the argument at the caret in a function's signature@@ -181,6 +182,7 @@ func New(dark bool) Theme {
181182 ErrorCell: lipgloss.NewStyle().Foreground(lipgloss.Red),182183 ErrorMark: lipgloss.NewStyle().UnderlineStyle(lipgloss.UnderlineCurly).UnderlineColor(lipgloss.Red),183184 Link: lipgloss.NewStyle().Foreground(link).Underline(true),185+ Spilled: lipgloss.NewStyle().Foreground(bar),184186 Found: lipgloss.NewStyle().Background(lipgloss.Yellow).Foreground(lipgloss.Black),185187 Traced: lipgloss.NewStyle().Background(lipgloss.Green).Foreground(lipgloss.Black),186188 Argument: lipgloss.NewStyle().Bold(true).Underline(true),@@ -163,6 +163,8 @@ func (m *Model) cellsText(row, first int, spans []rowtext.Span, focus sheet.Addr
163163 base = m.th.Muted164164 case m.sheet.Value(a).Kind == sheet.Error:165165 base = m.th.ErrorCell166+ case sp.Text != "" && m.sheet.Cell(sheet.Addr{Col: sp.Owner, Row: row}).Spilled():167+ base = m.th.Spilled166168 default:167169 colored = false168170 }