cat / 012
clone

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

Add charts to the engine: data, undo, rows and columns, file

Dami committed 429c814
commit
429c8147c06872f566ae5739c171c6f9b91620a1
parent
ea724bf661a66729899fc72efce9a3f35cf30db3
author
Dami <dami.etoile@gmail.com>
6 files changed, +632 -4
Ainternal/sheet/chart.go321++++++++++++++++++++++++++++++
Ainternal/sheet/chart_test.go217++++++++++++++++++++
Minternal/sheet/file.go67++++++-
Minternal/sheet/history.go28++-
Minternal/sheet/ops.go1+
Minternal/sheet/sheet.go2+
A internal/sheet/chart.go +321 -0
@@ -0,0 +1,321 @@
1+package sheet2+3+import (4+ "math"5+ "slices"6+ "strconv"7+ "strings"8+)9+10+// Charts float over the grid like Sheets' charts: each draws a range of11+// cells, sits with its top-left corner on a cell, and has a size in12+// terminal cells. They belong to the sheet, so they're saved with it,13+// follow inserted and deleted rows and columns, and every change to them14+// is an undo step.15+16+// ChartType is how a chart draws its series.17+type ChartType int18+19+const (20+ ChartColumn ChartType = iota // vertical bars, one group per category21+ ChartBar // horizontal bars22+ ChartLine // one line per series23+ ChartPie // the first series as slices of a whole24+)25+26+// ChartTypes lists the types in the order the chart editor offers them.27+var ChartTypes = []ChartType{ChartColumn, ChartBar, ChartLine, ChartPie}28+29+var chartTypeNames = [...]string{"column", "bar", "line", "pie"}30+31+func (t ChartType) String() string {32+ if t < 0 || int(t) >= len(chartTypeNames) {33+ return chartTypeNames[0]34+ }35+ return chartTypeNames[t]36+}37+38+// Title is the type's name as the chart editor shows it, e.g. "Column".39+func (t ChartType) Title() string {40+ s := t.String()41+ return strings.ToUpper(s[:1]) + s[1:]42+}43+44+// ParseChartType parses a type name as String writes it.45+func ParseChartType(s string) (ChartType, bool) {46+ i := slices.Index(chartTypeNames[:], strings.ToLower(s))47+ return ChartType(max(i, 0)), i >= 048+}49+50+// Chart is a chart floating over the grid.51+type Chart struct {52+ Type ChartType53+ Data Rect54+ // ByRow puts each series in a row instead of a column, like Sheets'55+ // "Switch rows / columns".56+ ByRow bool57+ // Header takes series names from the first row of the data (the first58+ // column when ByRow).59+ Header bool60+ // Labels takes category labels from the first column of the data (the61+ // first row when ByRow).62+ Labels bool63+ Title string64+ At Addr // the cell under the top-left corner65+ W, H int // size in terminal cells66+}67+68+// Chart size limits, in terminal cells.69+const (70+ MinChartW, MinChartH = 20, 871+ MaxChartW, MaxChartH = 240, 12072+)73+74+// ChartSeries is one named run of values. Blank and non-numeric cells are75+// NaN, drawn as gaps.76+type ChartSeries struct {77+ Name string78+ Values []float6479+}80+81+// ChartData is what a chart draws: category labels and series of the same82+// length, and the number format of the values, for axis labels.83+type ChartData struct {84+ Categories []string85+ Series []ChartSeries86+ Format Format87+}88+89+// maxChartPoints caps the categories a chart reads, so a chart over whole90+// columns stays quick to draw.91+const maxChartPoints = 50092+93+// ChartData reads a chart's cells. Series names and labels are the cells'94+// text as displayed.95+func (s *Sheet) ChartData(c Chart) ChartData {96+ r := s.clipToUsed(c.Data)97+ // Lay the range out as rows of cells with series going down columns,98+ // transposing when series run along rows.99+ at := func(i, j int) Addr { return Addr{Col: r.From.Col + j, Row: r.From.Row + i} }100+ n, m := r.To.Row-r.From.Row+1, r.To.Col-r.From.Col+1101+ if c.ByRow {102+ at = func(i, j int) Addr { return Addr{Col: r.From.Col + i, Row: r.From.Row + j} }103+ n, m = m, n104+ }105+ first, col0 := 0, 0106+ if c.Header && n > 1 {107+ first = 1108+ }109+ if c.Labels && m > 1 {110+ col0 = 1111+ }112+ n = min(n, first+maxChartPoints)113+ var d ChartData114+ for i := first; i < n; i++ {115+ label := strconv.Itoa(i - first + 1)116+ if col0 == 1 {117+ label = s.displayText(at(i, 0))118+ }119+ d.Categories = append(d.Categories, label)120+ }121+ for j := col0; j < m; j++ {122+ sr := ChartSeries{Name: "Series " + strconv.Itoa(j-col0+1)}123+ if first == 1 {124+ if name := s.displayText(at(0, j)); name != "" {125+ sr.Name = name126+ }127+ }128+ for i := first; i < n; i++ {129+ a := at(i, j)130+ v := s.Value(a)131+ switch v.Kind {132+ case Number:133+ sr.Values = append(sr.Values, v.Num)134+ if d.Format.IsZero() {135+ d.Format = s.DisplayFormat(a)136+ }137+ default:138+ sr.Values = append(sr.Values, math.NaN())139+ }140+ }141+ d.Series = append(d.Series, sr)142+ }143+ return d144+}145+146+// displayText is a cell's value as its format shows it, without padding.147+func (s *Sheet) displayText(a Addr) string {148+ v := s.Value(a)149+ if v.Kind == Text || v.Kind == Empty {150+ return v.Str151+ }152+ text, _ := Display(v, s.DisplayFormat(a), 64)153+ return strings.TrimSpace(text)154+}155+156+// clipToUsed trims a range to the used part of the sheet, so a chart over157+// whole columns reads only the filled rows.158+func (s *Sheet) clipToUsed(r Rect) Rect {159+ used, ok := s.UsedRange()160+ if !ok {161+ return Rect{r.From, r.From}162+ }163+ r.To.Col = max(min(r.To.Col, used.To.Col), r.From.Col)164+ r.To.Row = max(min(r.To.Row, used.To.Row), r.From.Row)165+ return r166+}167+168+// Region returns the block of data around a, as Sheets picks the data for169+// a chart inserted from a single cell: the smallest range around a whose170+// border touches no filled cell.171+func (s *Sheet) Region(a Addr) Rect {172+ r := Rect{a, a}173+ filled := func(a Addr) bool { return a.Valid() && !s.cells[a].Blank() }174+ for grew := true; grew; {175+ grew = false176+ for _, side := range []struct{ dc0, dr0, dc1, dr1 int }{{-1, 0, 0, 0}, {0, 0, 1, 0}, {0, -1, 0, 0}, {0, 0, 0, 1}} {177+ next := Rect{178+ From: Addr{Col: r.From.Col + side.dc0, Row: r.From.Row + side.dr0},179+ To: Addr{Col: r.To.Col + side.dc1, Row: r.To.Row + side.dr1},180+ }181+ if !next.From.Valid() || !next.To.Valid() || next == r {182+ continue183+ }184+ // Only the new line of cells can add data.185+ line := next186+ switch {187+ case side.dc0 < 0:188+ line.To.Col = next.From.Col189+ case side.dc1 > 0:190+ line.From.Col = next.To.Col191+ case side.dr0 < 0:192+ line.To.Row = next.From.Row193+ default:194+ line.From.Row = next.To.Row195+ }196+ // Diagonal neighbors count, as in Sheets.197+ if side.dc0 != 0 || side.dc1 != 0 {198+ line.From.Row, line.To.Row = max(line.From.Row-1, 0), min(line.To.Row+1, MaxRows-1)199+ } else {200+ line.From.Col, line.To.Col = max(line.From.Col-1, 0), min(line.To.Col+1, MaxCols-1)201+ }202+ for _, b := range s.cellsIn(line) {203+ if filled(b) && !r.Contains(b) {204+ r, grew = union(r, Rect{b, b}), true205+ break206+ }207+ }208+ }209+ }210+ return r211+}212+213+// GuessChart sets up a chart over r the way Sheets does: series down214+// columns, a header row when the first row is text over numbers, and215+// category labels when the first column is text.216+func (s *Sheet) GuessChart(r Rect) Chart {217+ r = s.clipToUsed(r)218+ c := Chart{Type: ChartColumn, Data: r}219+ isText := func(a Addr) bool { return s.Value(a).Kind == Text }220+ if r.To.Col > r.From.Col {221+ labels := false222+ for row := r.From.Row + 1; row <= r.To.Row; row++ {223+ if isText(Addr{Col: r.From.Col, Row: row}) {224+ labels = true225+ }226+ }227+ c.Labels = labels || r.From.Row == r.To.Row && isText(r.From)228+ }229+ if r.To.Row > r.From.Row {230+ from := r.From.Col231+ if c.Labels {232+ from++233+ }234+ texts, nums := 0, 0235+ for col := from; col <= r.To.Col; col++ {236+ switch s.Value(Addr{Col: col, Row: r.From.Row}).Kind {237+ case Text:238+ texts++239+ case Number, Bool:240+ nums++241+ }242+ }243+ c.Header = texts > 0 && nums == 0244+ }245+ d := s.ChartData(c)246+ if len(d.Series) <= 2 && c.Header {247+ names := make([]string, len(d.Series))248+ for i, sr := range d.Series {249+ names[i] = sr.Name250+ }251+ c.Title = strings.Join(names, " and ")252+ }253+ return c254+}255+256+// Charts returns the sheet's charts, bottom first.257+func (s *Sheet) Charts() []Chart { return slices.Clone(s.charts) }258+259+// AddChart adds a chart on top of the others and returns its index.260+func (s *Sheet) AddChart(c Chart) int {261+ s.change("insert chart", c.Data, func() {262+ s.recordCharts()263+ s.charts = append(s.charts, c.clamped())264+ })265+ return len(s.charts) - 1266+}267+268+// SetChart replaces chart i, as one undo step described by label, e.g.269+// "move chart".270+func (s *Sheet) SetChart(i int, c Chart, label string) {271+ if i < 0 || i >= len(s.charts) || s.charts[i] == c.clamped() {272+ return273+ }274+ s.change(label, c.Data, func() {275+ s.recordCharts()276+ s.charts[i] = c.clamped()277+ })278+}279+280+// DeleteChart removes chart i.281+func (s *Sheet) DeleteChart(i int) {282+ if i < 0 || i >= len(s.charts) {283+ return284+ }285+ s.change("delete chart", s.charts[i].Data, func() {286+ s.recordCharts()287+ s.charts = slices.Delete(slices.Clone(s.charts), i, i+1)288+ })289+}290+291+// clamped keeps a chart's size within limits and its corner on the sheet.292+func (c Chart) clamped() Chart {293+ c.W = clampInt(c.W, MinChartW, MaxChartW)294+ c.H = clampInt(c.H, MinChartH, MaxChartH)295+ c.At.Col = clampInt(c.At.Col, 0, MaxCols-1)296+ c.At.Row = clampInt(c.At.Row, 0, MaxRows-1)297+ return c298+}299+300+// shiftCharts moves charts with inserted or deleted rows or columns: the301+// data range grows or shrinks as a formula's range would, and the corner302+// moves with its cell. A chart whose data is deleted entirely goes too.303+func (s *Sheet) shiftCharts(cell func(Addr) (Addr, bool), rng func(Rect) (Rect, bool)) {304+ if len(s.charts) == 0 {305+ return306+ }307+ s.recordCharts()308+ var next []Chart309+ for _, c := range s.charts {310+ data, ok := rng(c.Data)311+ if !ok {312+ continue313+ }314+ c.Data = data315+ if at, ok := cell(c.At); ok {316+ c.At = at317+ }318+ next = append(next, c.clamped())319+ }320+ s.charts = next321+}
A internal/sheet/chart_test.go +217 -0
@@ -0,0 +1,217 @@
1+package sheet2+3+import (4+ "bytes"5+ "math"6+ "reflect"7+ "strings"8+ "testing"9+)10+11+// budgetCells is a small table: labels down A, two series with headers.12+var budgetCells = map[string]string{13+ "A1": "Month", "B1": "Rent", "C1": "Food",14+ "A2": "Jan", "B2": "1450", "C2": "600",15+ "A3": "Feb", "B3": "1450", "C3": "",16+ "A4": "Mar", "B4": "1500", "C4": "=C2+12",17+}18+19+func rng(s string) Rect {20+ r, ok := ParseRange(s)21+ if !ok {22+ panic(s)23+ }24+ return r25+}26+27+// sameFloats compares with NaN equal to NaN.28+func sameFloats(a, b []float64) bool {29+ if len(a) != len(b) {30+ return false31+ }32+ for i := range a {33+ if a[i] != b[i] && !(math.IsNaN(a[i]) && math.IsNaN(b[i])) {34+ return false35+ }36+ }37+ return true38+}39+40+func TestChartData(t *testing.T) {41+ nan := math.NaN()42+ s := sheetOf(t, budgetCells)43+ tests := []struct {44+ name string45+ chart Chart46+ cats []string47+ names []string48+ values [][]float6449+ }{50+ {"columns with header and labels", Chart{Data: rng("A1:C4"), Header: true, Labels: true},51+ []string{"Jan", "Feb", "Mar"}, []string{"Rent", "Food"},52+ [][]float64{{1450, 1450, 1500}, {600, nan, 612}}},53+ {"no header: the first row is data", Chart{Data: rng("B2:C4")},54+ []string{"1", "2", "3"}, []string{"Series 1", "Series 2"},55+ [][]float64{{1450, 1450, 1500}, {600, nan, 612}}},56+ {"series in rows", Chart{Data: rng("A1:C4"), ByRow: true, Header: true, Labels: true},57+ []string{"Rent", "Food"}, []string{"Jan", "Feb", "Mar"},58+ [][]float64{{1450, 600}, {1450, nan}, {1500, 612}}},59+ {"whole columns stop at the data", Chart{Data: rng("B1:B8192"), Header: true},60+ []string{"1", "2", "3"}, []string{"Rent"},61+ [][]float64{{1450, 1450, 1500}}},62+ {"text in a series is a gap", Chart{Data: rng("A2:B4")},63+ []string{"1", "2", "3"}, []string{"Series 1", "Series 2"},64+ [][]float64{{nan, nan, nan}, {1450, 1450, 1500}}},65+ }66+ for _, tt := range tests {67+ t.Run(tt.name, func(t *testing.T) {68+ d := s.ChartData(tt.chart)69+ if !reflect.DeepEqual(d.Categories, tt.cats) {70+ t.Errorf("categories %q, want %q", d.Categories, tt.cats)71+ }72+ if len(d.Series) != len(tt.names) {73+ t.Fatalf("%d series, want %d", len(d.Series), len(tt.names))74+ }75+ for i, sr := range d.Series {76+ if sr.Name != tt.names[i] || !sameFloats(sr.Values, tt.values[i]) {77+ t.Errorf("series %d = %q %v, want %q %v", i, sr.Name, sr.Values, tt.names[i], tt.values[i])78+ }79+ }80+ })81+ }82+}83+84+func TestChartDataFormat(t *testing.T) {85+ s := sheetOf(t, map[string]string{"A1": "$1,200", "A2": "$900"})86+ if d := s.ChartData(Chart{Data: rng("A1:A2")}); d.Format.Kind != FmtCurrency {87+ t.Errorf("format %v, want currency", d.Format.Kind)88+ }89+}90+91+func TestGuessChart(t *testing.T) {92+ s := sheetOf(t, budgetCells)93+ tests := []struct {94+ r string95+ header, labels bool96+ title string97+ }{98+ {"A1:C4", true, true, "Rent and Food"},99+ {"B1:B4", true, false, "Rent"},100+ {"B2:C4", false, false, ""},101+ {"A2:B4", false, true, ""},102+ {"A1:D9", true, true, "Rent and Food"}, // trimmed to the data103+ }104+ for _, tt := range tests {105+ c := s.GuessChart(rng(tt.r))106+ if c.Header != tt.header || c.Labels != tt.labels || c.Title != tt.title || c.Type != ChartColumn {107+ t.Errorf("%s: header %v labels %v title %q, want %v %v %q", tt.r, c.Header, c.Labels, c.Title, tt.header, tt.labels, tt.title)108+ }109+ }110+}111+112+func TestRegion(t *testing.T) {113+ s := sheetOf(t, map[string]string{114+ "B2": "x", "C2": "1", "C3": "2", "D4": "3", // D4 touches C3 diagonally115+ "F2": "far",116+ })117+ tests := []struct{ from, want string }{118+ {"C3", "B2:D4"},119+ {"B2", "B2:D4"},120+ {"F2", "F2"},121+ {"H9", "H9"},122+ }123+ for _, tt := range tests {124+ if got := s.Region(at(tt.from)).String(); got != tt.want {125+ t.Errorf("Region(%s) = %s, want %s", tt.from, got, tt.want)126+ }127+ }128+}129+130+func TestChartUndo(t *testing.T) {131+ s := sheetOf(t, budgetCells)132+ c := s.GuessChart(rng("A1:C4"))133+ c.At, c.W, c.H = at("E2"), 40, 12134+ i := s.AddChart(c)135+ moved := c136+ moved.At = at("F9")137+ s.SetChart(i, moved, "move chart")138+ s.DeleteChart(i)139+ if len(s.Charts()) != 0 {140+ t.Fatal("chart not deleted")141+ }142+ for _, want := range []struct {143+ label string144+ at string145+ n int146+ }{{"delete chart", "F9", 1}, {"move chart", "E2", 1}, {"insert chart", "", 0}} {147+ ch, ok := s.Undo()148+ if !ok || ch.Label != want.label {149+ t.Fatalf("undo %q, want %q", ch.Label, want.label)150+ }151+ if got := s.Charts(); len(got) != want.n || want.n > 0 && got[0].At.String() != want.at {152+ t.Fatalf("after undoing %s: %+v", want.label, got)153+ }154+ }155+ s.Redo()156+ if got := s.Charts(); len(got) != 1 || got[0].At != at("E2") {157+ t.Fatalf("redo: %+v", got)158+ }159+ // Setting a chart to what it is isn't a step.160+ id := s.StateID()161+ s.SetChart(0, s.Charts()[0], "edit chart")162+ if s.StateID() != id {163+ t.Error("no-op edit made an undo step")164+ }165+}166+167+func TestChartsFollowRowsAndColumns(t *testing.T) {168+ s := sheetOf(t, budgetCells)169+ s.AddChart(Chart{Data: rng("A1:C4"), At: at("E2"), W: 30, H: 10})170+ s.InsertRows(2, 1)171+ s.InsertCols(0, 2)172+ c := s.Charts()[0]173+ if c.Data.String() != "C1:E5" || c.At.String() != "G2" {174+ t.Errorf("after inserts: data %s at %s", c.Data, c.At)175+ }176+ s.DeleteCols(2, 3)177+ if n := len(s.Charts()); n != 0 {178+ t.Errorf("chart over deleted columns kept (%d)", n)179+ }180+ s.Undo()181+ if n := len(s.Charts()); n != 1 {182+ t.Errorf("undo didn't bring the chart back (%d)", n)183+ }184+}185+186+func TestChartFileRoundTrip(t *testing.T) {187+ s := sheetOf(t, budgetCells)188+ want := []Chart{189+ {Type: ChartLine, Data: rng("A1:C4"), Header: true, Labels: true, Title: "Spend", At: at("E2"), W: 44, H: 14},190+ {Type: ChartPie, Data: rng("B2:B4"), ByRow: true, At: at("A20"), W: 30, H: 12},191+ }192+ for _, c := range want {193+ s.AddChart(c)194+ }195+ var buf bytes.Buffer196+ if err := s.Write(&buf); err != nil {197+ t.Fatal(err)198+ }199+ if !strings.Contains(buf.String(), `"charts": [`+"\n "+`{"type":"line","data":"A1:C4","at":"E2","width":44,"height":14,"header":true,"labels":true,"title":"Spend"}`) {200+ t.Errorf("charts not one per line:\n%s", buf.String())201+ }202+ got, err := Read(&buf)203+ if err != nil {204+ t.Fatal(err)205+ }206+ if !reflect.DeepEqual(got.Charts(), want) {207+ t.Errorf("round trip:\n got %+v\nwant %+v", got.Charts(), want)208+ }209+ // Older files have no charts field.210+ old, err := Read(strings.NewReader(`{"version": 2, "cells": {"A1": "1"}}`))211+ if err != nil || len(old.Charts()) != 0 {212+ t.Errorf("old file: %v %v", err, old.Charts())213+ }214+ if _, err := Read(strings.NewReader(`{"version": 2, "cells": {}, "charts": [{"type": "radar", "data": "A1", "at": "A1"}]}`)); err == nil {215+ t.Error("unknown chart type accepted")216+ }217+}
M internal/sheet/file.go +66 -1
@@ -32,6 +32,49 @@ type fileFormat struct {
3232 Widths map[string]int `json:"widths,omitempty"`3333 Names map[string]string `json:"names,omitempty"`3434 Cells map[string]json.RawMessage `json:"cells"`35+ Charts []fileChart `json:"charts,omitempty"`36+}37+38+// fileChart is a chart, one per line after the cells:39+//40+// {"type": "column", "data": "A1:C7", "at": "E2", "width": 44, "height": 14, "header": true, "labels": true}41+//42+// Older versions of 012 ignore the field and drop the charts.43+type fileChart struct {44+ Type string `json:"type"`45+ Data string `json:"data"`46+ At string `json:"at"`47+ Width int `json:"width"`48+ Height int `json:"height"`49+ ByRow bool `json:"byRow,omitempty"`50+ Header bool `json:"header,omitempty"`51+ Labels bool `json:"labels,omitempty"`52+ Title string `json:"title,omitempty"`53+}54+55+func encodeChart(c Chart) ([]byte, error) {56+ return json.Marshal(fileChart{57+ Type: c.Type.String(), Data: c.Data.String(), At: c.At.String(), Width: c.W, Height: c.H,58+ ByRow: c.ByRow, Header: c.Header, Labels: c.Labels, Title: c.Title,59+ })60+}61+62+func decodeChart(fc fileChart) (Chart, error) {63+ t, ok := ParseChartType(fc.Type)64+ if !ok {65+ return Chart{}, fmt.Errorf("unknown chart type %q", fc.Type)66+ }67+ data, ok := ParseRange(fc.Data)68+ if !ok {69+ return Chart{}, fmt.Errorf("invalid chart range %q", fc.Data)70+ }71+ at, ok := ParseAddr(fc.At)72+ if !ok {73+ return Chart{}, fmt.Errorf("invalid chart position %q", fc.At)74+ }75+ c := Chart{Type: t, Data: data, At: at, W: fc.Width, H: fc.Height,76+ ByRow: fc.ByRow, Header: fc.Header, Labels: fc.Labels, Title: fc.Title}77+ return c.clamped(), nil3578 }3679 3780 type fileCell struct {
@@ -146,7 +189,22 @@ func (s *Sheet) Write(w io.Writer) error {
146189 if len(addrs) > 0 {147190 b.WriteString("\n ")148191 }149- b.WriteString("}\n}\n")192+ b.WriteString("}")193+ if len(s.charts) > 0 {194+ b.WriteString(",\n \"charts\": [")195+ for i, c := range s.charts {196+ raw, err := encodeChart(c)197+ if err != nil {198+ return err199+ }200+ if i > 0 {201+ b.WriteByte(',')202+ }203+ fmt.Fprintf(&b, "\n %s", raw)204+ }205+ b.WriteString("\n ]")206+ }207+ b.WriteString("\n}\n")150208 _, err := w.Write(b.Bytes())151209 return err152210 }
@@ -202,6 +260,13 @@ func Read(r io.Reader) (*Sheet, error) {
202260 }203261 s.place(a, c)204262 }263+ for i, fc := range f.Charts {264+ c, err := decodeChart(fc)265+ if err != nil {266+ return nil, fmt.Errorf("chart %d: %w", i+1, err)267+ }268+ s.charts = append(s.charts, c)269+ }205270 s.RecalcAll()206271 return s, nil207272 }
M internal/sheet/history.go +25 -3
@@ -1,5 +1,7 @@
11 package sheet22 3+import "slices"4+35 // MaxUndo is how many steps of undo history a sheet keeps.46 const MaxUndo = 10057
@@ -31,13 +33,18 @@ type step struct {
3133 cells map[Addr]*Cell // before the step; nil for blank3234 widths map[int]int // before the step; 0 for the default width3335 names map[string]*Name // before the step, by key; nil for undefined36+ // charts is every chart before the step, when it changed any.37+ charts []Chart38+ chartsSaved bool3439 }3540 3641 func newStep(label string, focus Rect) *step {3742 return &step{label: label, focus: focus, cells: map[Addr]*Cell{}, widths: map[int]int{}, names: map[string]*Name{}}3843 }3944 40-func (st *step) empty() bool { return len(st.cells) == 0 && len(st.widths) == 0 && len(st.names) == 0 }45+func (st *step) empty() bool {46+ return len(st.cells) == 0 && len(st.widths) == 0 && len(st.names) == 0 && !st.chartsSaved47+}4148 4249 func (c *Cell) clone() *Cell {4350 if c == nil {
@@ -120,6 +127,14 @@ func (s *Sheet) recordName(k string) {
120127 }121128 }122129 130+// recordCharts saves the charts before their first change in the open131+// step. Charts are few, so the step keeps them all.132+func (s *Sheet) recordCharts() {133+ if st := s.hist.open; st != nil && !st.chartsSaved {134+ st.charts, st.chartsSaved = slices.Clone(s.charts), true135+ }136+}137+123138 // push adds a finished step to the undo stack and clears redo, dropping124139 // no-op changes.125140 func (s *Sheet) push(st *step) {
@@ -139,13 +154,16 @@ func (s *Sheet) push(st *step) {
139154 delete(st.names, k)140155 }141156 }157+ if st.chartsSaved && slices.Equal(st.charts, s.charts) {158+ st.charts, st.chartsSaved = nil, false159+ }142160 if st.empty() {143161 return144162 }145163 h.redo = nil146164 h.lastID++147- widthOnly := len(st.cells) == 0 && len(st.names) == 0148- if top := h.top(); widthOnly && h.mergeWidths && top != nil && len(top.cells) == 0 && len(top.names) == 0 {165+ widthOnly := len(st.cells) == 0 && len(st.names) == 0 && !st.chartsSaved166+ if top := h.top(); widthOnly && h.mergeWidths && top != nil && len(top.cells) == 0 && len(top.names) == 0 && !top.chartsSaved {149167 for c, w := range st.widths {150168 if _, ok := top.widths[c]; !ok {151169 top.widths[c] = w
@@ -231,6 +249,10 @@ func (s *Sheet) swap(from, to *[]*step) (Change, bool) {
231249 inv.names[k] = s.namePtr(k)232250 changed = append(changed, s.putName(k, n)...)233251 }252+ if st.chartsSaved {253+ inv.charts, inv.chartsSaved = s.charts, true254+ s.charts = slices.Clone(st.charts)255+ }234256 s.recalc(changed)235257 *to = append(*to, inv)236258 s.hist.mergeWidths = false
M internal/sheet/ops.go +1 -0
M internal/sheet/sheet.go +2 -0