cat / 012
clone

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

Engine: wrapping, borders, row heights and merged cells

Dami committed a30d472
Style gains Wrap (overflow, wrap, clip) and Borders (a line per edge:
thin, thick or double), so both fall back on rows and columns, travel
with copies and go with Clear formatting. SetBorders sets Sheets' kinds
(all, outer, inner, one side, none) and clears the facing edges of
neighbors so the last change shows.

Rows get heights set by hand, kept like column widths: undoable (joined
with a live preview as widths are), moved by inserted and deleted rows,
carried by whole-row copies and moves. Cells whose own style wraps or
draws borders are indexed by row, so the grid can measure the rows it
shows at their cost and a plain sheet costs nothing (Shaped).

Merges live in the view state, replaced whole, so undo, inserted and
deleted lines and the file follow; a copy carries the merges inside it.
Merging keeps each area's top-left value; sorting over merges is refused
and a merge blocks a spill.

All are optional fields of .012 that older builds ignore.
commit
a30d472cb0eb32813a04f52888564a74556bef22
parent
24d313d458adc9a8a0311d3deba8fb68ea2318f3
author
Dami <dami.etoile@gmail.com>
22 files changed, +1347 -97
Mdocs/files/format.md10+-
Ainternal/sheet/borders.go191++++++++++++++++++
Minternal/sheet/cellfile.go50+++++-
Minternal/sheet/cellformat.go2+-
Minternal/sheet/clipfmt.go29+++-
Minternal/sheet/file.go20++-
Minternal/sheet/history.go93+++------
Ainternal/sheet/historyrecord.go59++++++
Minternal/sheet/historysize.go2+-
Ainternal/sheet/layout_test.go288++++++++++++++++++++++++++++
Minternal/sheet/lines.go24++-
Ainternal/sheet/merge.go312++++++++++++++++++++++++++++++
Minternal/sheet/move.go2+
Minternal/sheet/ops.go8+-
Minternal/sheet/pivotlayout.go1+
Ainternal/sheet/rowlayout.go266++++++++++++++++++++++++++
Minternal/sheet/sheet.go15+-
Minternal/sheet/sort.go2+-
Minternal/sheet/spill.go3+
Minternal/sheet/style.go36+++-
Minternal/sheet/view.go26++-
Minternal/sheet/workbook.go5+-
M docs/files/format.md +8 -2
@@ -26,8 +26,11 @@ object:
2626 A formatted cell has its `input` and the formatting that isn't the2727 default: `format` (as `number_format` in2828 [macros](../reference/macro-api.md#cells) names it: `currency`, `percent`, `date`, ...), `decimals`, `pattern` for a29-custom format, `bold`, `italic`, `underline`, `strikethrough` and30-`align` (`left`, `center`, `right`).29+custom format, `bold`, `italic`, `underline`, `strikethrough`,30+`align` (`left`, `center`, `right`), `wrap` (`wrap` or `clip`) and31+`borders`, each edge's line by name:32+`"borders":{"top":"thin","bottom":"double","left":"thick","right":"thin"}`33+([formatting](../sheets/formatting.md#borders)).3134 3235 Only what's typed is saved. What 012 computes is computed again when the3336 file opens: formula results, [pivot tables](#pivot-tables)' results, and
@@ -57,7 +60,10 @@ save), so they raise no version:
5760 |---|---|---|5861 | `note` | a cell | Its [note](../sheets/notes-protection.md#notes) |5962 | `own` | a cell | `true` when its formatting is its own, not its column's or row's (Automatic in a currency column) |63+| `wrap`, `borders` | a cell or a line | How its text [wraps](../sheets/formatting.md#wrapping) and its [borders](../sheets/formatting.md#borders); older builds show the text overflowing, without lines |6064 | `lines` | a sheet | [Column and row formats](#column-and-row-formats) |65+| `heights` | a sheet | [Row heights](../sheets/formatting.md#row-heights) set by hand, in lines, by row number: `"heights": {"3": 2, "7": 4}` |66+| `merges` | a sheet | [Merged cells](../sheets/formatting.md#merged-cells), by range: `"merges": ["A1:C1", "D2:D5"]` |6167 | `name`, `hidden` | a sheet | Its name when renamed; `true` when hidden (a file whose sheets are all hidden opens with the first one shown) |6268 | `charts` | a sheet | One chart per line: `type` (`column`, `bar`, `line`, `pie`, `area`, `scatter`), `data`, `at`, `width`, `height`, `byRow`, `header`, `labels`, `title`, and options left out at their defaults: `stack` (`stacked`, `percent`), `trend`, `min`, `max`, `log`, `gridlines` (only when off), `legend` (`right`, `none`). A build that charts but lacks a chart's type refuses the file |6369 | `protected` | a sheet | [Protected ranges](../sheets/notes-protection.md#protected-sheets-and-ranges): `{"range":"B2:C9","description":"Totals"}`, or `{"sheet":true}` |
A internal/sheet/borders.go +191 -0
@@ -0,0 +1,191 @@
1+package sheet2+3+// Borders, as Sheets' Format > Borders. Each cell keeps the line on each4+// of its four edges in its style, so borders fall back on rows and5+// columns, travel with copies and go with Clear formatting as the rest6+// of the style does. Neighbors share an edge, which shows the heavier of7+// the lines either keeps; setting an edge of a range from inside clears8+// the facing edge of the neighbor outside, so the last change shows.9+10+// Line is how an edge is drawn.11+type Line uint812+13+const (14+ LineNone Line = iota15+ LineThin16+ LineThick17+ LineDouble18+)19+20+var lineNames = [...]string{"", "thin", "thick", "double"}21+22+// String returns the line's name as stored in files.23+func (l Line) String() string {24+ if int(l) < len(lineNames) {25+ return lineNames[l]26+ }27+ return ""28+}29+30+// ParseLine is the inverse of Line.String.31+func ParseLine(s string) (Line, bool) {32+ for i, n := range lineNames {33+ if n == s {34+ return Line(i), true35+ }36+ }37+ return LineNone, false38+}39+40+// Heavier is the line drawn where two edges meet: double over thick over41+// thin.42+func Heavier(a, b Line) Line { return max(a, b) }43+44+// Borders are the lines on a cell's four edges.45+type Borders struct {46+ Top, Bottom, Left, Right Line47+}48+49+// IsZero reports whether no edge has a line.50+func (b Borders) IsZero() bool { return b == Borders{} }51+52+// BorderKind is which edges of a range Format > Borders sets.53+type BorderKind uint854+55+const (56+ BorderAll BorderKind = iota // every edge of every cell57+ BorderOuter // the range's outline58+ BorderInner // the edges between its cells59+ BorderTop // its top edge60+ BorderBottom //61+ BorderLeft //62+ BorderRight //63+ BorderNone // no edges: removes every line64+)65+66+var borderKindNames = [...]string{"all", "outer", "inner", "top", "bottom", "left", "right", "none"}67+68+// String names the kind, as undo labels and macros write it.69+func (k BorderKind) String() string {70+ if int(k) < len(borderKindNames) {71+ return borderKindNames[k]72+ }73+ return ""74+}75+76+// ParseBorderKind is the inverse of BorderKind.String.77+func ParseBorderKind(s string) (BorderKind, bool) {78+ for i, n := range borderKindNames {79+ if n == s {80+ return BorderKind(i), true81+ }82+ }83+ return 0, false84+}85+86+// edgeSet is which of a cell's edges a change sets.87+type edgeSet struct{ top, bottom, left, right bool }88+89+// edges are the edges k sets on the cell at a within r. Along the axis90+// r covers whole (whole columns run down every row), every cell counts91+// as inside, and so do lines (see lineAddr).92+func (k BorderKind) edges(r Rect, a Addr) edgeSet {93+ firstRow, lastRow := a.Row >= 0 && a.Row == r.From.Row, a.Row >= 0 && a.Row == r.To.Row94+ firstCol, lastCol := a.Col >= 0 && a.Col == r.From.Col, a.Col >= 0 && a.Col == r.To.Col95+ if r.AllRows() {96+ firstRow, lastRow = false, false97+ }98+ if r.AllCols() {99+ firstCol, lastCol = false, false100+ }101+ switch k {102+ case BorderAll, BorderNone:103+ return edgeSet{true, true, true, true}104+ case BorderOuter:105+ return edgeSet{firstRow, lastRow, firstCol, lastCol}106+ case BorderInner:107+ return edgeSet{!firstRow, !lastRow, !firstCol, !lastCol}108+ case BorderTop:109+ return edgeSet{top: firstRow}110+ case BorderBottom:111+ return edgeSet{bottom: lastRow}112+ case BorderLeft:113+ return edgeSet{left: firstCol}114+ }115+ return edgeSet{right: lastCol}116+}117+118+// apply sets the edges of e on b to l.119+func (e edgeSet) apply(b *Borders, l Line) {120+ for _, on := range [...]struct {121+ set bool122+ edge *Line123+ }{{e.top, &b.Top}, {e.bottom, &b.Bottom}, {e.left, &b.Left}, {e.right, &b.Right}} {124+ if on.set {125+ *on.edge = l126+ }127+ }128+}129+130+// SetBorders draws the edges k names in r with line l, or removes every131+// line of r for BorderNone, as one undo step. Neighbors outside r lose132+// the lines on the edges they share with the ones set.133+func (s *Sheet) SetBorders(r Rect, k BorderKind, l Line) {134+ label := "borders " + k.String() + " " + r.String()135+ if k == BorderNone {136+ l, label = LineNone, "remove borders from "+r.String()137+ }138+ s.change(label, r, func() {139+ s.eachFormat(r, true, func(a Addr, f *lineFmt) {140+ k.edges(r, a).apply(&f.Style.Borders, l)141+ })142+ s.clearFacing(r, k)143+ })144+}145+146+// clearFacing removes the lines of the neighbors of r on the edges that147+// setting k on r draws.148+func (s *Sheet) clearFacing(r Rect, k BorderKind) {149+ outer := k.edges(r, r.From)150+ last := k.edges(r, r.To)151+ outer.bottom, outer.right = last.bottom, last.right152+ strip := func(from, to Addr, e edgeSet) {153+ n := Rect{From: from, To: to}154+ if !n.From.Valid() || !n.To.Valid() {155+ return156+ }157+ s.eachFormat(n, true, func(_ Addr, f *lineFmt) { e.apply(&f.Style.Borders, LineNone) })158+ }159+ if outer.top && !r.AllRows() {160+ strip(Addr{Col: r.From.Col, Row: r.From.Row - 1}, Addr{Col: r.To.Col, Row: r.From.Row - 1}, edgeSet{bottom: true})161+ }162+ if outer.bottom && !r.AllRows() {163+ strip(Addr{Col: r.From.Col, Row: r.To.Row + 1}, Addr{Col: r.To.Col, Row: r.To.Row + 1}, edgeSet{top: true})164+ }165+ if outer.left && !r.AllCols() {166+ strip(Addr{Col: r.From.Col - 1, Row: r.From.Row}, Addr{Col: r.From.Col - 1, Row: r.To.Row}, edgeSet{right: true})167+ }168+ if outer.right && !r.AllCols() {169+ strip(Addr{Col: r.To.Col + 1, Row: r.From.Row}, Addr{Col: r.To.Col + 1, Row: r.To.Row}, edgeSet{left: true})170+ }171+}172+173+// EdgeAbove is the line on the edge between the cell at a and the one174+// above it: the heavier of their facing lines.175+func (s *Sheet) EdgeAbove(a Addr) Line {176+ l := s.CellStyle(a).Borders.Top177+ if a.Row > 0 {178+ l = Heavier(l, s.CellStyle(Addr{Col: a.Col, Row: a.Row - 1}).Borders.Bottom)179+ }180+ return l181+}182+183+// EdgeLeft is the line on the edge between the cell at a and the one to184+// its left.185+func (s *Sheet) EdgeLeft(a Addr) Line {186+ l := s.CellStyle(a).Borders.Left187+ if a.Col > 0 {188+ l = Heavier(l, s.CellStyle(Addr{Col: a.Col - 1, Row: a.Row}).Borders.Right)189+ }190+ return l191+}
M internal/sheet/cellfile.go +49 -1
@@ -18,6 +18,10 @@ type fileCell struct {
1818 Strikethrough bool `json:"strikethrough,omitempty"`1919 Align string `json:"align,omitempty"`2020 Own bool `json:"own,omitempty"` // not taking its column's or row's formatting21+ // Wrap and Borders need no version bump: earlier builds ignore them22+ // and show the text overflowing, without lines.23+ Wrap string `json:"wrap,omitempty"` // "wrap" or "clip"24+ Borders *fileBorders `json:"borders,omitempty"`2125 // Note needs no version bump: earlier builds ignore it and drop the2226 // note.2327 Note string `json:"note,omitempty"`
@@ -36,6 +40,8 @@ func encodeCell(c *Cell) (json.RawMessage, error) {
3640 Strikethrough: c.Style.Strikethrough,3741 Align: c.Style.Align.String(),3842 Own: c.Style.own,43+ Wrap: c.Style.Wrap.String(),44+ Borders: encodeBorders(c.Style.Borders),3945 Note: c.Note,4046 }4147 if !c.Format.IsZero() {
@@ -87,6 +93,48 @@ func decodeFormatted(fc fileCell) (string, Format, Style, error) {
8793 if !ok {8894 return "", Format{}, Style{}, fmt.Errorf("unknown alignment %q", fc.Align)8995 }90- st := Style{Bold: fc.Bold, Italic: fc.Italic, Underline: fc.Underline, Strikethrough: fc.Strikethrough, Align: al, own: fc.Own}96+ wr, ok := ParseWrap(fc.Wrap)97+ if !ok {98+ return "", Format{}, Style{}, fmt.Errorf("unknown wrapping %q", fc.Wrap)99+ }100+ b, err := decodeBorders(fc.Borders)101+ if err != nil {102+ return "", Format{}, Style{}, err103+ }104+ st := Style{Bold: fc.Bold, Italic: fc.Italic, Underline: fc.Underline, Strikethrough: fc.Strikethrough, Align: al, Wrap: wr, Borders: b, own: fc.Own}91105 return fc.Input, f, st, nil92106 }107+108+// fileBorders are a cell's borders in the file, each edge's line by name:109+// {"top": "thin", "left": "double"}.110+type fileBorders struct {111+ Top string `json:"top,omitempty"`112+ Bottom string `json:"bottom,omitempty"`113+ Left string `json:"left,omitempty"`114+ Right string `json:"right,omitempty"`115+}116+117+func encodeBorders(b Borders) *fileBorders {118+ if b.IsZero() {119+ return nil120+ }121+ return &fileBorders{Top: b.Top.String(), Bottom: b.Bottom.String(), Left: b.Left.String(), Right: b.Right.String()}122+}123+124+func decodeBorders(fb *fileBorders) (Borders, error) {125+ var b Borders126+ if fb == nil {127+ return b, nil128+ }129+ for _, e := range [...]struct {130+ name string131+ to *Line132+ }{{fb.Top, &b.Top}, {fb.Bottom, &b.Bottom}, {fb.Left, &b.Left}, {fb.Right, &b.Right}} {133+ l, ok := ParseLine(e.name)134+ if !ok {135+ return Borders{}, fmt.Errorf("unknown border line %q", e.name)136+ }137+ *e.to = l138+ }139+ return b, nil140+}
M internal/sheet/cellformat.go +1 -1
M internal/sheet/clipfmt.go +26 -3
@@ -11,9 +11,11 @@ import (
1111 // its source showed, storing only what the destination's own lines don't1212 // already show. Copying whole columns or rows and pasting them as whole1313 // lines (at the top of a column, the start of a row, or A1 for the whole14-// sheet) carries the line formats themselves, and cutting them moves the15-// line formats, leaving the source lines plain. Cutting a block leaves16-// its cells plain, over whatever lines cross it.14+// sheet) carries the line formats themselves, and rows their heights,15+// and cutting them moves the line formats and heights, leaving the16+// source lines plain. Cutting a block leaves its cells plain, over17+// whatever lines cross it. Merged cells wholly inside a copy are merged18+// where it lands (merge.go).1719 1820 // clipFormats is the formatting of a copied range, taken at copy time.1921 type clipFormats struct {
@@ -21,6 +23,7 @@ type clipFormats struct {
2123 cells map[Addr]lineFmt // what each stored cell showed, by offset2224 colFmt, rowFmt map[int]lineFmt // formatted columns and rows crossing the range, by offset2325 sheet lineFmt26+ heights map[int]int // whole rows' heights, by offset2427 }2528 2629 // copyFormats takes the formatting of r.
@@ -31,6 +34,9 @@ func (s *Sheet) copyFormats(r Rect) clipFormats {
3134 }3235 f.colFmt = linesIn(s.lines.cols, r.From.Col, r.To.Col)3336 f.rowFmt = linesIn(s.lines.rows, r.From.Row, r.To.Row)37+ if f.rows {38+ f.heights = s.heightsIn(r.From.Row, r.To.Row)39+ }3440 return f3541 }3642
@@ -177,6 +183,12 @@ func (s *Sheet) pasteLines(f *clipFormats, p pasteLayout) {
177183 for _, n := range unionKeys(s.lines.rows, f.rowFmt) {178184 s.setLine(true, n, f.rowFmt[n])179185 }186+ for _, n := range slices.Collect(maps.Keys(s.heights)) {187+ s.setHeight(n, 0)188+ }189+ for n, h := range f.heights {190+ s.setHeight(n, h)191+ }180192 case lineCols:181193 for i := range p.across * p.tw {182194 s.setLine(false, p.dst.From.Col+i, f.carry(f.colFmt[i%p.tw], s.lines.sheet))
@@ -185,6 +197,11 @@ func (s *Sheet) pasteLines(f *clipFormats, p pasteLayout) {
185197 for i := range p.down * p.th {186198 s.setLine(true, p.dst.From.Row+i, f.carry(f.rowFmt[i%p.th], s.lines.sheet))187199 }200+ if len(f.heights) > 0 || len(s.heights) > 0 {201+ for i := range p.down * p.th {202+ s.setHeight(p.dst.From.Row+i, f.heights[i%p.th])203+ }204+ }188205 }189206 }190207
@@ -249,6 +266,9 @@ func (s *Sheet) moveFormats(from *Sheet, f *clipFormats, src, dst Rect) {
249266 for _, n := range slices.Sorted(maps.Keys(from.lines.rows)) {250267 from.setLine(true, n, lineFmt{})251268 }269+ for n := range from.heights {270+ from.setHeight(n, 0)271+ }252272 case lineCols:253273 for n := range f.colFmt {254274 from.setLine(false, src.From.Col+n, lineFmt{})
@@ -257,6 +277,9 @@ func (s *Sheet) moveFormats(from *Sheet, f *clipFormats, src, dst Rect) {
257277 for n := range f.rowFmt {258278 from.setLine(true, src.From.Row+n, lineFmt{})259279 }280+ for n := range f.heights {281+ from.setHeight(src.From.Row+n, 0)282+ }260283 case lineBlock:261284 from.plainSource(src, dst, from == s)262285 }
M internal/sheet/file.go +16 -4
@@ -7,6 +7,7 @@ import (
77 "io"88 "maps"99 "slices"10+ "strconv"1011 "strings"1112 )1213
@@ -59,10 +60,13 @@ type fileFormat struct {
5960 6061 // fileSheet is one sheet of a file.6162 type fileSheet struct {62- Name string `json:"name,omitempty"`63- Hidden bool `json:"hidden,omitempty"` // version 4, ignored by older builds64- Widths map[string]int `json:"widths,omitempty"`65- Lines map[string]json.RawMessage `json:"lines,omitempty"` // column and row formats, see linefile.go63+ Name string `json:"name,omitempty"`64+ Hidden bool `json:"hidden,omitempty"` // version 4, ignored by older builds65+ Widths map[string]int `json:"widths,omitempty"`66+ // Heights need no version: earlier builds ignore them and show each67+ // row one line tall.68+ Heights map[string]int `json:"heights,omitempty"`69+ Lines map[string]json.RawMessage `json:"lines,omitempty"` // column and row formats, see linefile.go6670 fileView6771 Cells map[string]json.RawMessage `json:"cells"`6872 Charts []fileChart `json:"charts,omitempty"`
@@ -188,6 +192,7 @@ func (s *Sheet) writeBody(b *bytes.Buffer, indent, names string) error {
188192 }189193 b.WriteString("},\n")190194 }195+ s.writeHeights(b, indent)191196 if err := s.writeLines(b, indent); err != nil {192197 return err193198 }
@@ -357,6 +362,13 @@ func (s *Sheet) read(f fileSheet, version int) error {
357362 }358363 s.setWidth(c, width)359364 }365+ for name, h := range f.Heights {366+ row, err := strconv.Atoi(name)367+ if err != nil || row < 1 || row > MaxRows {368+ return fmt.Errorf("invalid row %q", name)369+ }370+ s.setHeight(row-1, h)371+ }360372 if err := s.readLines(f.Lines); err != nil {361373 return err362374 }
M internal/sheet/history.go +28 -65
@@ -25,8 +25,8 @@ type history struct {
2525 lastID int2626 bytes int64 // estimated heap held by the undo steps; see historysize.go2727 // mergeWidths lets the next width-only step join the top one, so a28- // live preview of a column width and its final value (or its29- // cancellation) are one step. Seal ends the run.28+ // live preview of a column width or row height and its final value29+ // (or its cancellation) are one step. Seal ends the run.3030 mergeWidths bool3131 }3232
@@ -41,6 +41,7 @@ type step struct {
4141 focus Rect // what the UI selects there4242 cells map[loc]*Cell // before the step; nil for blank4343 widths map[colKey]int // before the step; 0 for the default width44+ heights map[rowKey]int // rows' heights before the step; 0 for none4445 lines map[lineKey]lineFmt // column and row formats before the step4546 names map[string]*Name // before the step, by key; nil for undefined4647 views map[*Sheet]*viewState
@@ -66,15 +67,16 @@ type colKey struct {
6667 }6768 6869 func newStep(label string, s *Sheet, focus Rect) *step {69- return &step{label: label, sheet: s, focus: focus, cells: map[loc]*Cell{}, widths: map[colKey]int{}, lines: map[lineKey]lineFmt{},70+ return &step{label: label, sheet: s, focus: focus, cells: map[loc]*Cell{}, widths: map[colKey]int{}, heights: map[rowKey]int{}, lines: map[lineKey]lineFmt{},7071 names: map[string]*Name{}, views: map[*Sheet]*viewState{}, charts: map[*Sheet][]Chart{}, pivots: map[*Sheet]*Pivot{}, rules: map[*Sheet]rulesState{}}7172 }7273 7374 func (st *step) empty() bool {74- return len(st.cells) == 0 && len(st.widths) == 0 && st.widthOnly()75+ return len(st.cells) == 0 && len(st.widths) == 0 && len(st.heights) == 0 && st.widthOnly()7576 }7677 77-// widthOnly reports whether the step changed nothing but column widths.78+// widthOnly reports whether the step changed nothing but column widths79+// and row heights.7880 func (st *step) widthOnly() bool {7981 return len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.views) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && st.sheets == nil && st.decimal == nil &&8082 st.macros == nil
@@ -167,59 +169,6 @@ func (s *Sheet) record(a Addr) {
167169 s.wb.hist.dirty = append(s.wb.hist.dirty, l)168170 }169171 170-// recordWidth saves column c's width before its first change in the open171-// step.172-func (s *Sheet) recordWidth(c int) {173- st := s.wb.hist.open174- if st == nil {175- return176- }177- k := colKey{s, c}178- if _, seen := st.widths[k]; !seen {179- st.widths[k] = s.widths[c]180- }181-}182-183-// recordName saves the named range with key k before its first change in184-// the open step.185-func (w *Workbook) recordName(k string) {186- st := w.hist.open187- if st == nil {188- return189- }190- if _, seen := st.names[k]; !seen {191- st.names[k] = w.namePtr(k)192- }193-}194-195-// recordCharts saves the sheet's charts before their first change in the196-// open step. Charts are few, so the step keeps them all.197-func (s *Sheet) recordCharts() {198- if st := s.wb.hist.open; st != nil {199- if _, seen := st.charts[s]; !seen {200- st.charts[s] = slices.Clone(s.charts)201- }202- }203-}204-205-// recordSheets saves the sheet list before its first change in the open206-// step, and has the step recalculate everything when it ends.207-func (w *Workbook) recordSheets() {208- w.structural = true209- if st := w.hist.open; st != nil && st.sheets == nil {210- st.sheets = w.sheetList()211- }212-}213-214-// recordDecimal saves the arithmetic setting before its first change in215-// the open step.216-func (w *Workbook) recordDecimal() {217- if st := w.hist.open; st != nil && st.decimal == nil {218- d := w.decimal219- st.decimal = &d220- }221-}222-223172 // push adds a finished step to the undo stack and clears redo, dropping224173 // no-op changes.225174 func (w *Workbook) push(st *step) {
@@ -281,14 +230,19 @@ func (w *Workbook) dropUnchanged(st *step) {
281230 }282231 }283232 284-// dropUnchangedLines removes the column widths and line formats that285-// ended the step as they began.233+// dropUnchangedLines removes the column widths, row heights and line234+// formats that ended the step as they began.286235 func (st *step) dropUnchangedLines() {287236 for k, width := range st.widths {288237 if k.s.widths[k.col] == width {289238 delete(st.widths, k)290239 }291240 }241+ for k, h := range st.heights {242+ if k.s.heights[k.row] == h {243+ delete(st.heights, k)244+ }245+ }292246 for k, l := range st.lines {293247 if k.s.line(k.row, k.n) == l {294248 delete(st.lines, k)
@@ -316,19 +270,20 @@ func sameName(a, b *Name) bool {
316270 }317271 318272 // joinWidths folds the width-only step st into the width-only step on319-// top, so a live preview of a column width and its final value (or its320-// cancellation) are one step.273+// top, so a live preview of a column width or row height and its final274+// value (or its cancellation) are one step.321275 func (h *history) joinWidths(top, st *step) {322276 for k, width := range st.widths {323277 if _, ok := top.widths[k]; !ok {324278 top.widths[k] = width325279 }326280 }327- for k, width := range top.widths {328- if k.s.widths[k.col] == width {329- delete(top.widths, k)281+ for k, h := range st.heights {282+ if _, ok := top.heights[k]; !ok {283+ top.heights[k] = h330284 }331285 }286+ top.dropUnchangedLines()332287 top.id, top.focus = h.lastID, union(top.focus, st.focus)333288 h.bytes -= top.bytes334289 top.bytes = top.size()
@@ -429,6 +384,10 @@ func (w *Workbook) swap(undo bool) (Change, bool) {
429384 inv.widths[k] = k.s.widths[k.col]430385 k.s.setWidth(k.col, width)431386 }387+ for k, h := range st.heights {388+ inv.heights[k] = k.s.heights[k.row]389+ k.s.setHeight(k.row, h)390+ }432391 for k, l := range st.lines {433392 inv.lines[k] = k.s.line(k.row, k.n)434393 k.s.setLine(k.row, k.n, l)
@@ -465,6 +424,10 @@ func (w *Workbook) swap(undo bool) (Change, bool) {
465424 inv.views[s] = &cur466425 s.view = *v467426 s.hidden.valid = false427+ if !slices.Equal(cur.merges, v.merges) {428+ s.version++429+ s.respill(Rect{To: Addr{Col: MaxCols - 1, Row: MaxRows - 1}})430+ }468431 }469432 w.recalcSwapped(changed)470433 if undo {
A internal/sheet/historyrecord.go +59 -0
@@ -0,0 +1,59 @@
1+package sheet2+3+import "slices"4+5+// The before-images a step keeps of what it changes besides cells6+// (record, in history.go), each taken the first time the step changes it.7+8+// recordWidth saves column c's width before its first change in the open9+// step.10+func (s *Sheet) recordWidth(c int) {11+ st := s.wb.hist.open12+ if st == nil {13+ return14+ }15+ k := colKey{s, c}16+ if _, seen := st.widths[k]; !seen {17+ st.widths[k] = s.widths[c]18+ }19+}20+21+// recordName saves the named range with key k before its first change in22+// the open step.23+func (w *Workbook) recordName(k string) {24+ st := w.hist.open25+ if st == nil {26+ return27+ }28+ if _, seen := st.names[k]; !seen {29+ st.names[k] = w.namePtr(k)30+ }31+}32+33+// recordCharts saves the sheet's charts before their first change in the34+// open step. Charts are few, so the step keeps them all.35+func (s *Sheet) recordCharts() {36+ if st := s.wb.hist.open; st != nil {37+ if _, seen := st.charts[s]; !seen {38+ st.charts[s] = slices.Clone(s.charts)39+ }40+ }41+}42+43+// recordSheets saves the sheet list before its first change in the open44+// step, and has the step recalculate everything when it ends.45+func (w *Workbook) recordSheets() {46+ w.structural = true47+ if st := w.hist.open; st != nil && st.sheets == nil {48+ st.sheets = w.sheetList()49+ }50+}51+52+// recordDecimal saves the arithmetic setting before its first change in53+// the open step.54+func (w *Workbook) recordDecimal() {55+ if st := w.hist.open; st != nil && st.decimal == nil {56+ d := w.decimal57+ st.decimal = &d58+ }59+}
M internal/sheet/historysize.go +1 -1
A internal/sheet/layout_test.go +288 -0
@@ -0,0 +1,288 @@
1+package sheet2+3+import (4+ "bytes"5+ "slices"6+ "strings"7+ "testing"8+)9+10+// roundTrip saves s's workbook and reads it back.11+func roundTrip(t *testing.T, s *Sheet) *Sheet {12+ t.Helper()13+ var b bytes.Buffer14+ if err := s.Write(&b); err != nil {15+ t.Fatal(err)16+ }17+ got, err := Read(&b)18+ if err != nil {19+ t.Fatalf("%v\n%s", err, b.String())20+ }21+ return got22+}23+24+func TestBorders(t *testing.T) {25+ s := New()26+ s.SetBorders(rng("B2:C3"), BorderOuter, LineThin)27+ for _, c := range []struct {28+ cell string29+ want Borders30+ }{31+ {"B2", Borders{Top: LineThin, Left: LineThin}},32+ {"C2", Borders{Top: LineThin, Right: LineThin}},33+ {"B3", Borders{Bottom: LineThin, Left: LineThin}},34+ {"C3", Borders{Bottom: LineThin, Right: LineThin}},35+ } {36+ if got := s.CellStyle(at(c.cell)).Borders; got != c.want {37+ t.Errorf("outer: %s = %+v, want %+v", c.cell, got, c.want)38+ }39+ }40+ // Neighbors see the shared edge.41+ if s.EdgeAbove(at("B4")) != LineThin || s.EdgeLeft(at("D2")) != LineThin || s.EdgeAbove(at("B3")) != LineNone {42+ t.Error("shared edges")43+ }44+ // Inner draws between the cells only; a later thick top clears the45+ // neighbor's facing bottom, so the last change shows.46+ s.SetBorders(rng("B2:C3"), BorderInner, LineDouble)47+ if got := s.CellStyle(at("B2")).Borders; got != (Borders{Top: LineThin, Left: LineThin, Bottom: LineDouble, Right: LineDouble}) {48+ t.Errorf("inner B2 = %+v", got)49+ }50+ s.SetBorders(rng("B1"), BorderBottom, LineThin)51+ s.SetBorders(rng("B2"), BorderTop, LineThick)52+ if got := s.CellStyle(at("B1")).Borders.Bottom; got != LineNone {53+ t.Errorf("facing bottom kept: %v", got)54+ }55+ if s.EdgeAbove(at("B2")) != LineThick {56+ t.Error("top not thick")57+ }58+ // None removes every line of the range and the facing ones.59+ s.SetBorders(rng("A1:D4"), BorderNone, LineThin)60+ for a := range s.cells.all() {61+ if b := s.CellStyle(a).Borders; !b.IsZero() {62+ t.Errorf("%s still %+v", a, b)63+ }64+ }65+ if s.cells.len() != 0 {66+ t.Errorf("formatting-only cells left: %d", s.cells.len())67+ }68+ s.Undo()69+ if s.CellStyle(at("C3")).Borders.Right != LineThin {70+ t.Error("undo none")71+ }72+ // Whole columns keep borders on the column.73+ s.SetBorders(rng("E:F"), BorderAll, LineThin)74+ if s.RowFormats()[0] != (Format{}) || s.EdgeLeft(at("F900")) != LineThin || s.EdgeAbove(at("E77")) != LineThin {75+ t.Error("column borders")76+ }77+ if len(s.ColStyles()) != 2 {78+ t.Errorf("column styles %v", s.ColStyles())79+ }80+ got := roundTrip(t, s)81+ if got.CellStyle(at("C2")).Borders != s.CellStyle(at("C2")).Borders || got.EdgeLeft(at("F3")) != LineThin {82+ t.Error("borders lost in the file")83+ }84+}85+86+func TestWrapFile(t *testing.T) {87+ s := New()88+ s.Set(at("A1"), "a long line of text")89+ s.SetStyle(rng("A1"), func(st *Style) { st.Wrap = WrapOn })90+ s.SetStyle(rng("B:B"), func(st *Style) { st.Wrap = WrapClip })91+ var b bytes.Buffer92+ s.Write(&b)93+ if !strings.Contains(b.String(), `"wrap": "wrap"`) && !strings.Contains(b.String(), `"wrap":"wrap"`) {94+ t.Errorf("file: %s", b.String())95+ }96+ got := roundTrip(t, s)97+ if got.CellStyle(at("A1")).Wrap != WrapOn || got.CellStyle(at("B7")).Wrap != WrapClip {98+ t.Error("wrap lost in the file")99+ }100+ if _, err := Read(strings.NewReader(`{"version": 2, "cells": {"A1": {"input": "x", "wrap": "sideways"}}}`)); err == nil {101+ t.Error("unknown wrapping read")102+ }103+}104+105+func TestShapers(t *testing.T) {106+ s := New()107+ if s.Shaped() {108+ t.Error("new sheet shaped")109+ }110+ s.Set(at("C5"), "words that wrap")111+ s.SetStyle(rng("C5"), func(st *Style) { st.Wrap = WrapOn })112+ if !s.Shaped() || !slices.Equal(s.WrappedIn(4), []int{2}) || s.WrappedIn(3) != nil {113+ t.Errorf("wrapped in row 5: %v", s.WrappedIn(4))114+ }115+ s.SetBorders(rng("A7"), BorderBottom, LineThin)116+ if !s.RuleAbove(7) || s.RuleAbove(6) || s.RuleAbove(4) {117+ t.Error("rule above row 8 only")118+ }119+ s.ClearFormatting(rng("A1:Z99"))120+ if s.Shaped() {121+ t.Error("still shaped after clearing")122+ }123+ // A column that wraps wraps the text its cells hold.124+ s.SetStyle(rng("D:D"), func(st *Style) { st.Wrap = WrapOn })125+ s.Set(at("D3"), "x")126+ if !slices.Equal(s.WrappedIn(2), []int{3}) || s.WrappedIn(3) != nil {127+ t.Errorf("column wrap %v", s.WrappedIn(2))128+ }129+ // A merged cell doesn't grow its row.130+ s.Merge(rng("D3:E3"), MergeAll)131+ if s.WrappedIn(2) != nil {132+ t.Error("merged cell wraps")133+ }134+}135+136+func TestRowHeights(t *testing.T) {137+ s := New()138+ s.SetRowHeight(2, 3, 4)139+ if h, ok := s.RowHeight(3); !ok || h != 4 {140+ t.Errorf("height = %d, %v", h, ok)141+ }142+ s.Seal() // the UI seals between actions, so heights join no other step143+ s.InsertRows(0, 2)144+ if _, ok := s.RowHeight(2); ok {145+ t.Error("row 3 kept its height")146+ }147+ if h, _ := s.RowHeight(5); h != 4 {148+ t.Error("heights didn't move")149+ }150+ s.Undo()151+ if h, _ := s.RowHeight(2); h != 4 {152+ t.Error("undo insert")153+ }154+ s.SetRowHeight(2, 2, 0)155+ if _, ok := s.RowHeight(2); ok {156+ t.Error("fit to data")157+ }158+ s.Undo()159+ s.SetRowHeight(9, 9, 99)160+ if h, _ := s.RowHeight(9); h != MaxRowHeight {161+ t.Errorf("capped at %d", h)162+ }163+ got := roundTrip(t, s)164+ if !mapsEqual(got.Heights(), s.Heights()) {165+ t.Errorf("file heights %v, want %v", got.Heights(), s.Heights())166+ }167+ // Whole rows carry their heights when copied and moved.168+ clip := s.Copy(rowRect(2, 3))169+ s.Paste(clip, rowRect(20, 20), false)170+ if h, _ := s.RowHeight(21); h != 4 {171+ t.Errorf("pasted rows' heights %v", s.Heights())172+ }173+ s.Move(rowRect(20, 21), Addr{Row: 30})174+ if _, ok := s.RowHeight(20); ok {175+ t.Error("moved rows left their heights")176+ }177+ if h, _ := s.RowHeight(31); h != 4 {178+ t.Errorf("moved rows' heights %v", s.Heights())179+ }180+}181+182+func mapsEqual(a, b map[int]int) bool {183+ if len(a) != len(b) {184+ return false185+ }186+ for k, v := range a {187+ if b[k] != v {188+ return false189+ }190+ }191+ return true192+}193+194+func TestMerge(t *testing.T) {195+ s := New()196+ s.Set(at("B2"), "kept")197+ s.Set(at("C3"), "lost")198+ if a, loses := s.MergeLoses(rng("B2:C3"), MergeAll); !loses || a != at("C3") {199+ t.Errorf("MergeLoses = %v, %v", a, loses)200+ }201+ if _, loses := s.MergeLoses(rng("B3:C3"), MergeVertically); loses {202+ t.Error("vertical merges of one row lose nothing")203+ }204+ if err := s.Merge(rng("B2:C3"), MergeAll); err != nil {205+ t.Fatal(err)206+ }207+ if m, ok := s.MergeAt(at("C3")); !ok || m != rng("B2:C3") {208+ t.Errorf("MergeAt = %v, %v", m, ok)209+ }210+ if !s.Cell(at("C3")).Blank() || s.Value(at("B2")).Str != "kept" {211+ t.Error("merging keeps only the top-left value")212+ }213+ if s.Grow(rng("A1:B2")) != rng("A1:C3") {214+ t.Errorf("Grow = %v", s.Grow(rng("A1:B2")))215+ }216+ s.Undo()217+ if s.Value(at("C3")).Str != "lost" || len(s.Merges()) != 0 {218+ t.Error("undo merge")219+ }220+ s.Redo()221+ if err := s.Merge(rng("A1"), MergeAll); err != ErrMergeOne {222+ t.Errorf("one cell: %v", err)223+ }224+ if err := s.Merge(rng("A1:B1048576"), MergeHorizontally); err != ErrMergeMany {225+ t.Errorf("a million merges: %v", err)226+ }227+ // Merges follow inserted and deleted lines and are saved.228+ s.InsertCols(0, 1)229+ if got := s.Merges(); len(got) != 1 || got[0] != rng("C2:D3") {230+ t.Errorf("after insert %v", got)231+ }232+ got := roundTrip(t, s)233+ if !slices.Equal(got.Merges(), s.Merges()) {234+ t.Errorf("file merges %v", got.Merges())235+ }236+ s.DeleteCols(3, 1)237+ if got := s.Merges(); len(got) != 1 || got[0] != rng("C2:C3") {238+ t.Errorf("after deleting a column %v", got)239+ }240+ s.DeleteRows(2, 1)241+ if len(s.Merges()) != 0 {242+ t.Errorf("merge of one cell left %v", s.Merges())243+ }244+ s.Undo()245+ s.Undo()246+ // Rows and columns merge one by one.247+ s.Merge(rng("F1:G3"), MergeHorizontally)248+ if len(s.MergesIn(rng("F1:G3"))) != 3 {249+ t.Errorf("horizontal merges %v", s.Merges())250+ }251+ if n := s.Unmerge(rng("G2")); n != 1 || len(s.MergesIn(rng("F1:G3"))) != 2 {252+ t.Errorf("unmerge %d, %v", n, s.Merges())253+ }254+}255+256+func TestMergeCopyMoveSort(t *testing.T) {257+ s := New()258+ s.Set(at("A1"), "b")259+ s.Set(at("A2"), "a")260+ s.Merge(rng("A1:B1"), MergeAll)261+ // A copy carries the merges wholly inside it.262+ s.Paste(s.Copy(rng("A1:B2")), rng("D5"), false)263+ if m, ok := s.MergeAt(at("E5")); !ok || m != rng("D5:E5") {264+ t.Errorf("pasted merge %v, %v", m, ok)265+ }266+ s.Move(rng("D5:E6"), at("D9"))267+ if _, ok := s.MergeAt(at("D5")); ok {268+ t.Error("moved merge left behind")269+ }270+ if m, _ := s.MergeAt(at("E9")); m != rng("D9:E9") {271+ t.Errorf("moved merge %v", s.Merges())272+ }273+ // Sorting a range with merged cells is refused.274+ s.SortRange(rng("A1:B2"), []SortKey{{Col: 0}})275+ if s.Value(at("A1")).Str != "b" {276+ t.Error("sorted over merged cells")277+ }278+ // A merge blocks a spill.279+ s.Set(at("H1"), "=SEQUENCE(3)")280+ s.Merge(rng("H2:I2"), MergeAll)281+ if v := s.Value(at("H1")); v.Kind != Error {282+ t.Errorf("spill over a merge = %v", v)283+ }284+ s.Unmerge(rng("H2"))285+ if v := s.Value(at("H3")); v.Num != 3 {286+ t.Errorf("spill after unmerging = %v", v)287+ }288+}
M internal/sheet/lines.go +19 -5
@@ -243,8 +243,9 @@ func (s *Sheet) lineCells(row bool, n int) []loc {
243243 244244 // formatLines applies fn to the formatting of r, which is whole columns245245 // or rows: to their line formats and to the cells in r that show246-// something else. It reports false, doing nothing, for any other range.247-func (s *Sheet) formatLines(r Rect, fn func(*lineFmt)) bool {246+// something else, with where each is (see lineAddr). It reports false,247+// doing nothing, for any other range.248+func (s *Sheet) formatLines(r Rect, fn func(Addr, *lineFmt)) bool {248249 cols, rows := r.AllRows(), r.AllCols()249250 if !cols && !rows {250251 return false
@@ -275,7 +276,7 @@ func (s *Sheet) formatLines(r Rect, fn func(*lineFmt)) bool {
275276 want := make([]lineFmt, len(targets))276277 for i, a := range targets {277278 want[i] = s.effective(a)278- fn(&want[i])279+ fn(a, &want[i])279280 }280281 s.eachLine(r, fn)281282 for i, a := range targets {
@@ -284,12 +285,25 @@ func (s *Sheet) formatLines(r Rect, fn func(*lineFmt)) bool {
284285 return true285286 }286287 288+// lineAddr is where a line format applies, for functions given the289+// address of what they format: a column's has Row -1, a row's Col -1,290+// and the whole sheet's both.291+func lineAddr(row bool, n int) Addr {292+ switch {293+ case row:294+ return Addr{Col: -1, Row: n}295+ case n == wholeSheet:296+ return Addr{Col: -1, Row: -1}297+ }298+ return Addr{Col: n, Row: -1}299+}300+287301 // eachLine applies fn to the line formats of r's whole columns, or rows;288302 // for the whole sheet, to its own format and to the columns and rows289303 // that have one, so they show the change too.290-func (s *Sheet) eachLine(r Rect, fn func(*lineFmt)) {304+func (s *Sheet) eachLine(r Rect, fn func(Addr, *lineFmt)) {291305 apply := func(row bool, n int, l lineFmt) {292- fn(&l)306+ fn(lineAddr(row, n), &l)293307 s.setLine(row, n, l)294308 }295309 if r.AllRows() && r.AllCols() {
A internal/sheet/merge.go +312 -0
@@ -0,0 +1,312 @@
1+package sheet2+3+import (4+ "errors"5+ "fmt"6+ "slices"7+8+ "github.com/FelineStateMachine/012/internal/formula"9+)10+11+// Merged cells, as Sheets' Format > Merge cells: a merge joins a range12+// into one cell that shows its top-left cell's contents and formatting.13+// The merges are part of the sheet's view state (view.go): a list14+// replaced whole on every change, so an undo step keeps it as it was,15+// that follows inserted and deleted lines as range references do and is16+// saved in the file. Finding the merge holding a cell goes through an17+// index built when the list changes.18+19+// MergeKind is how Format > Merge cells joins a range.20+type MergeKind uint821+22+const (23+ MergeAll MergeKind = iota // the whole range, one cell24+ MergeHorizontally // each row of it25+ MergeVertically // each column of it26+)27+28+// maxMerges caps the merges one change makes: merging a range's rows or29+// columns makes one per line.30+const maxMerges = 1000031+32+var (33+ // ErrMergeOne is why a single cell can't be merged.34+ ErrMergeOne = errors.New("Select more than one cell to merge")35+ // ErrMergeMany is why merging a whole column's rows is refused.36+ ErrMergeMany = fmt.Errorf("That would make more than %d merged cells", maxMerges)37+ // ErrSortMerged is why a range holding merged cells isn't sorted.38+ ErrSortMerged = errors.New("Can't sort a range with merged cells; unmerge them first")39+)40+41+// mergeIndex finds merges by the cells they cover, for the list it was42+// built from (compared by identity: the list is replaced, never changed43+// in place).44+type mergeIndex struct {45+ of []Rect46+ idx rangeIndex47+ at map[Addr]Rect // each merge by its top-left cell48+}49+50+// sameList reports whether a and b are the same slice.51+func sameList(a, b []Rect) bool {52+ return len(a) == len(b) && (len(a) == 0 || &a[0] == &b[0])53+}54+55+func (s *Sheet) merges() *mergeIndex {56+ x := &s.mergeIdx57+ if !sameList(x.of, s.view.merges) || x.at == nil {58+ *x = mergeIndex{of: s.view.merges, at: make(map[Addr]Rect, len(s.view.merges))}59+ for _, r := range s.view.merges {60+ x.at[r.From] = r61+ x.idx.add(r.From, []Rect{r})62+ }63+ }64+ return x65+}66+67+// Merges returns the merged ranges, in the order they were made.68+func (s *Sheet) Merges() []Rect { return slices.Clone(s.view.merges) }69+70+// MergeAt returns the merged range holding a, and false when a isn't71+// merged.72+func (s *Sheet) MergeAt(a Addr) (Rect, bool) {73+ if len(s.view.merges) == 0 {74+ return Rect{}, false75+ }76+ x := s.merges()77+ var out Rect78+ found := false79+ x.idx.readers(a, func(tl Addr) { out, found = x.at[tl], true })80+ return out, found81+}82+83+// MergesIn returns the merges that overlap r.84+func (s *Sheet) MergesIn(r Rect) []Rect {85+ var out []Rect86+ for _, m := range s.view.merges {87+ if _, ok := intersectRect(m, r); ok {88+ out = append(out, m)89+ }90+ }91+ return out92+}93+94+// Grow returns r grown to hold every merge it overlaps, as a selection95+// in Sheets takes in the merged cells it touches.96+func (s *Sheet) Grow(r Rect) Rect {97+ if len(s.view.merges) == 0 {98+ return r99+ }100+ for grown := true; grown; {101+ grown = false102+ for _, m := range s.MergesIn(r) {103+ if u := union(r, m); u != r {104+ r, grown = u, true105+ }106+ }107+ }108+ return r109+}110+111+// mergeAreas are the ranges merging r with k makes.112+func mergeAreas(r Rect, k MergeKind) ([]Rect, error) {113+ var out []Rect114+ switch k {115+ case MergeHorizontally:116+ if r.To.Row-r.From.Row >= maxMerges {117+ return nil, ErrMergeMany118+ }119+ for row := r.From.Row; row <= r.To.Row && r.From.Col < r.To.Col; row++ {120+ out = append(out, Rect{From: Addr{Col: r.From.Col, Row: row}, To: Addr{Col: r.To.Col, Row: row}})121+ }122+ case MergeVertically:123+ if r.To.Col-r.From.Col >= maxMerges {124+ return nil, ErrMergeMany125+ }126+ for c := r.From.Col; c <= r.To.Col && r.From.Row < r.To.Row; c++ {127+ out = append(out, Rect{From: Addr{Col: c, Row: r.From.Row}, To: Addr{Col: c, Row: r.To.Row}})128+ }129+ default:130+ if r.From != r.To {131+ out = []Rect{r}132+ }133+ }134+ if len(out) == 0 {135+ return nil, ErrMergeOne136+ }137+ return out, nil138+}139+140+// MergeLoses returns the first cell merging r with k would clear: one141+// with contents that isn't the top-left of its merge. Sheets warns that142+// only the top-left value is kept.143+func (s *Sheet) MergeLoses(r Rect, k MergeKind) (Addr, bool) {144+ areas, err := mergeAreas(r, k)145+ if err != nil {146+ return Addr{}, false147+ }148+ var lost []Addr149+ for a, c := range s.cells.anyInRange(r) {150+ if !c.Blank() && !c.Spilled() && !isTopLeft(areas, a) {151+ lost = append(lost, a)152+ }153+ }154+ if len(lost) == 0 {155+ return Addr{}, false156+ }157+ sortAddrs(lost)158+ return lost[0], true159+}160+161+// isTopLeft reports whether a is the top-left cell of one of areas, which162+// all lie along r's rows or columns, or a isn't in any.163+func isTopLeft(areas []Rect, a Addr) bool {164+ for _, m := range areas {165+ if m.Contains(a) {166+ return a == m.From167+ }168+ }169+ return true170+}171+172+// Merge joins r into merged cells as k says, as one undo step: each173+// keeps its top-left cell's contents and clears the rest, keeping their174+// formatting and notes. Merges it overlaps are replaced.175+func (s *Sheet) Merge(r Rect, k MergeKind) error {176+ areas, err := mergeAreas(r, k)177+ if err != nil {178+ return err179+ }180+ s.change("merge "+r.String(), r, func() {181+ for a, c := range s.cells.anyInRange(r) {182+ if !c.Blank() && !isTopLeft(areas, a) {183+ s.place(a, c.leftover())184+ }185+ }186+ next := slices.DeleteFunc(slices.Clone(s.view.merges), func(m Rect) bool {187+ _, overlaps := intersectRect(m, r)188+ return overlaps189+ })190+ s.setMerges(append(next, areas...), r)191+ })192+ return nil193+}194+195+// Unmerge splits the merges overlapping r back into cells, as one undo196+// step, and returns how many there were.197+func (s *Sheet) Unmerge(r Rect) int {198+ n := 0199+ next := slices.DeleteFunc(slices.Clone(s.view.merges), func(m Rect) bool {200+ _, overlaps := intersectRect(m, r)201+ if overlaps {202+ n++203+ }204+ return overlaps205+ })206+ if n > 0 {207+ s.change("unmerge "+r.String(), r, func() { s.setMerges(next, r) })208+ }209+ return n210+}211+212+// setMerges replaces the merges, recording them for undo, and has the213+// arrays spilling over the range that changed spill again: a merge214+// blocks a spill.215+func (s *Sheet) setMerges(next []Rect, changed Rect) {216+ if len(next) == 0 {217+ next = nil218+ }219+ s.recordView()220+ s.view.merges = next221+ s.version++222+ s.respill(changed)223+}224+225+// respill marks the anchors spilling, or blocked, over r to recalculate.226+func (s *Sheet) respill(r Rect) {227+ for a, sp := range s.spills {228+ if _, ok := intersectRect(sp.area, r); ok {229+ sp.stale = true230+ s.wb.markDirty(loc{s, a})231+ }232+ }233+}234+235+// LoadMerge adds a merge as a loader does, without recording undo; one236+// cell or one overlapping another is left out.237+func (s *Sheet) LoadMerge(r Rect) {238+ if r.From == r.To || !r.To.Valid() || len(s.MergesIn(r)) > 0 {239+ return240+ }241+ s.view.merges = append(slices.Clone(s.view.merges), r)242+ s.version++243+}244+245+// shiftMerges moves merges with inserted or deleted rows or columns, as246+// a range reference moves; one left a single cell goes.247+func shiftMerges(ms []Rect, rows bool, sp formula.Span) []Rect {248+ if len(ms) == 0 {249+ return ms250+ }251+ _, rng := formula.AxisMaps(rows, sp)252+ var out []Rect253+ for _, m := range ms {254+ if r, ok := rng(m); ok && r.From != r.To {255+ out = append(out, r)256+ }257+ }258+ return out259+}260+261+// mergesWithin is the merges wholly inside r, moved by its top-left corner,262+// for a copy to carry.263+func (s *Sheet) mergesWithin(r Rect) []Rect {264+ var out []Rect265+ for _, m := range s.view.merges {266+ if r.Contains(m.From) && r.Contains(m.To) {267+ out = append(out, Rect{From: subAddr(m.From, r.From), To: subAddr(m.To, r.From)})268+ }269+ }270+ return out271+}272+273+// pasteMerges merges what a copy carried at each tile of p, if anything,274+// replacing the merges the paste lands over.275+func (s *Sheet) pasteMerges(carried []Rect, p pasteLayout) {276+ if len(carried) == 0 {277+ return278+ }279+ next := slices.DeleteFunc(slices.Clone(s.view.merges), func(m Rect) bool {280+ _, overlaps := intersectRect(m, p.dst)281+ return overlaps282+ })283+ for tr := range p.down {284+ for tc := range p.across {285+ at := Addr{Col: p.dst.From.Col + tc*p.tw, Row: p.dst.From.Row + tr*p.th}286+ for _, m := range carried {287+ if r := (Rect{From: addAddr(m.From, at), To: addAddr(m.To, at)}); r.To.Valid() && len(next) < maxMerges {288+ next = append(next, r)289+ }290+ }291+ }292+ }293+ s.setMerges(next, p.dst)294+}295+296+// moveMerges moves the merges wholly inside src on from to dst on s, as297+// cutting and pasting them does, replacing those dst overlaps.298+func (s *Sheet) moveMerges(from *Sheet, src, dst Rect) {299+ carried := from.mergesWithin(src)300+ if len(carried) > 0 {301+ left := slices.DeleteFunc(slices.Clone(from.view.merges), func(m Rect) bool {302+ return src.Contains(m.From) && src.Contains(m.To)303+ })304+ from.setMerges(left, src)305+ }306+ p := pasteLayout{dst: dst, across: 1, down: 1,307+ tw: dst.To.Col - dst.From.Col + 1, th: dst.To.Row - dst.From.Row + 1}308+ s.pasteMerges(carried, p)309+}310+311+func subAddr(a, b Addr) Addr { return Addr{Col: a.Col - b.Col, Row: a.Row - b.Row} }312+func addAddr(a, b Addr) Addr { return Addr{Col: a.Col + b.Col, Row: a.Row + b.Row} }
M internal/sheet/move.go +2 -0
M internal/sheet/ops.go +6 -2
M internal/sheet/pivotlayout.go +1 -0
A internal/sheet/rowlayout.go +266 -0
@@ -0,0 +1,266 @@
1+package sheet2+3+import (4+ "bytes"5+ "maps"6+ "slices"7+ "strconv"8+9+ "github.com/FelineStateMachine/012/internal/formula"10+)11+12+// Row layout: what makes a row of the grid more than one line of text.13+// A row is as tall as its height, in lines, when one is set by hand14+// (Format > Row height, or dragging the row's border), kept per sheet as15+// column widths are; otherwise the grid grows it to fit the text its16+// cells wrap. A horizontal border takes a line of its own above the row17+// it tops. Cells whose own style wraps text or draws borders are indexed18+// by row (shapers), so the grid measures the rows it shows at the cost19+// of those cells, and a sheet without any costs nothing.20+21+// MaxRowHeight caps a row's height in lines.22+const MaxRowHeight = 5023+24+// shapes reports whether a style changes the layout of its row: text25+// that wraps, or borders.26+func (st Style) shapes() bool { return st.Wrap == WrapOn || !st.Borders.IsZero() }27+28+// shaperIndex holds the columns of each row whose cells' own styles29+// shape it, and whether any line format does, as of a version.30+type shaperIndex struct {31+ rows map[int]map[int]struct{}32+ linesAt uint64 // the version the rest was worked out at, plus one33+ lineShape bool // a line format wraps or draws borders34+ colRules bool // a column's format draws a top or bottom edge35+}36+37+// trackShape keeps the index as the cell at a becomes c.38+func (s *Sheet) trackShape(a Addr, c *Cell) {39+ was := s.cells.get(a)40+ had, has := was != nil && was.Style.shapes(), c != nil && c.Style.shapes()41+ switch {42+ case has && !had:43+ if s.shapers.rows == nil {44+ s.shapers.rows = map[int]map[int]struct{}{}45+ }46+ row := s.shapers.rows[a.Row]47+ if row == nil {48+ row = map[int]struct{}{}49+ s.shapers.rows[a.Row] = row50+ }51+ row[a.Col] = struct{}{}52+ case had && !has:53+ row := s.shapers.rows[a.Row]54+ delete(row, a.Col)55+ if len(row) == 0 {56+ delete(s.shapers.rows, a.Row)57+ }58+ }59+}60+61+// linesShape reports whether any line format wraps or draws borders.62+func (s *Sheet) linesShape() bool {63+ if x := &s.shapers; x.linesAt != s.version+1 {64+ x.linesAt, x.lineShape, x.colRules = s.version+1, s.lines.sheet.Style.shapes(), false65+ for _, l := range s.lines.cols {66+ x.lineShape = x.lineShape || l.Style.shapes()67+ x.colRules = x.colRules || l.Style.Borders.horizontal()68+ }69+ for _, l := range s.lines.rows {70+ x.lineShape = x.lineShape || l.Style.shapes()71+ }72+ }73+ return s.shapers.lineShape74+}75+76+// horizontal reports whether b draws its top or bottom edge.77+func (b Borders) horizontal() bool { return b.Top != LineNone || b.Bottom != LineNone }78+79+// Shaped reports whether any row may be other than one line of text80+// under the one above: something wraps or draws borders, a row has a81+// height of its own, or cells are merged. The grid lays out a sheet that82+// isn't shaped a line per row without asking more.83+func (s *Sheet) Shaped() bool {84+ return len(s.shapers.rows) > 0 || len(s.heights) > 0 || len(s.view.merges) > 0 || s.linesShape()85+}86+87+// Version changes whenever anything the grid draws may have: cells,88+// their values and formats, widths, heights and merges.89+func (s *Sheet) Version() uint64 { return s.version }90+91+// shapersIn calls fn with the columns of row whose cells may wrap or have92+// borders: those indexed, and when line formats shape, those of93+// formatted columns and, for a shaping row or sheet format, every cell94+// the row holds.95+func (s *Sheet) shapersIn(row int, fn func(col int)) {96+ for c := range s.shapers.rows[row] {97+ fn(c)98+ }99+ if !s.linesShape() {100+ return101+ }102+ if s.lines.sheet.Style.shapes() || s.lines.rows[row].Style.shapes() {103+ for a := range s.cells.inRange(rowRect(row, row)) {104+ if _, seen := s.shapers.rows[row][a.Col]; !seen {105+ fn(a.Col)106+ }107+ }108+ return109+ }110+ for c, l := range s.lines.cols {111+ if _, seen := s.shapers.rows[row][c]; !seen && l.Style.shapes() {112+ fn(c)113+ }114+ }115+}116+117+// WrappedIn returns the columns of row whose cells wrap text they hold,118+// outside merged cells, in no particular order.119+func (s *Sheet) WrappedIn(row int) []int {120+ var out []int121+ s.shapersIn(row, func(c int) {122+ a := Addr{Col: c, Row: row}123+ if cell := s.cells.get(a); cell.Blank() || s.CellStyle(a).Wrap != WrapOn {124+ return125+ }126+ if _, merged := s.MergeAt(a); !merged {127+ out = append(out, c)128+ }129+ })130+ return out131+}132+133+// RuleAbove reports whether a border lies on the edge above row: the top134+// of one of its cells or the bottom of one in the row before, or a135+// column's, row's or the sheet's format drawing either.136+func (s *Sheet) RuleAbove(row int) bool {137+ if s.linesShape() && (s.shapers.colRules || s.lines.sheet.Style.Borders.horizontal() ||138+ s.lines.rows[row].Style.Borders.Top != LineNone || row > 0 && s.lines.rows[row-1].Style.Borders.Bottom != LineNone) {139+ return true140+ }141+ found := false142+ s.shapersIn(row, func(c int) {143+ found = found || s.CellStyle(Addr{Col: c, Row: row}).Borders.Top != LineNone144+ })145+ if row > 0 && !found {146+ s.shapersIn(row-1, func(c int) {147+ found = found || s.CellStyle(Addr{Col: c, Row: row - 1}).Borders.Bottom != LineNone148+ })149+ }150+ return found151+}152+153+// RowHeight returns the height set for row by hand, in lines, and false154+// when it has none and fits its contents.155+func (s *Sheet) RowHeight(row int) (int, bool) {156+ h, ok := s.heights[row]157+ return h, ok158+}159+160+// SetRowHeight sets rows from..to to h lines, as one undo step; h 0161+// has them fit their contents again, as Sheets' Fit to data.162+func (s *Sheet) SetRowHeight(from, to, h int) {163+ label := "row height"164+ if h <= 0 {165+ label = "fit rows to data"166+ }167+ s.change(label, rowRect(from, to), func() {168+ for r := from; r <= to; r++ {169+ s.setHeight(r, h)170+ }171+ })172+}173+174+// Heights returns the rows that have a height set by hand.175+func (s *Sheet) Heights() map[int]int { return maps.Clone(s.heights) }176+177+// LoadRowHeight sets a row's height as an importer does, without178+// recording undo.179+func (s *Sheet) LoadRowHeight(row, h int) {180+ if row >= 0 && row < MaxRows {181+ s.setHeight(row, h)182+ }183+}184+185+func (s *Sheet) setHeight(row, h int) {186+ h = min(h, MaxRowHeight)187+ if old, ok := s.heights[row]; ok == (h > 0) && old == h {188+ return189+ }190+ s.recordHeight(row)191+ s.version++192+ if h <= 0 {193+ delete(s.heights, row)194+ return195+ }196+ if s.heights == nil {197+ s.heights = map[int]int{}198+ }199+ s.heights[row] = h200+}201+202+// rowKey is a row of a sheet, for undo.203+type rowKey struct {204+ s *Sheet205+ row int206+}207+208+// recordHeight saves row's height before its first change in the open209+// step; 0 stands for none.210+func (s *Sheet) recordHeight(row int) {211+ st := s.wb.hist.open212+ if st == nil {213+ return214+ }215+ k := rowKey{s, row}216+ if _, seen := st.heights[k]; !seen {217+ st.heights[k] = s.heights[row]218+ }219+}220+221+// shiftHeights moves row heights along with inserted or deleted rows.222+func (s *Sheet) shiftHeights(sp formula.Span) {223+ next := map[int]int{}224+ for r, h := range s.heights {225+ if to, ok := sp.Point(r); ok {226+ next[to] = h227+ }228+ }229+ for _, r := range slices.Collect(maps.Keys(s.heights)) {230+ if _, ok := next[r]; !ok {231+ s.setHeight(r, 0)232+ }233+ }234+ for r, h := range next {235+ s.setHeight(r, h)236+ }237+}238+239+// heightsIn is the heights of rows lo..hi, by offset from lo.240+func (s *Sheet) heightsIn(lo, hi int) map[int]int {241+ var out map[int]int242+ for r, h := range s.heights {243+ if r >= lo && r <= hi {244+ if out == nil {245+ out = map[int]int{}246+ }247+ out[r-lo] = h248+ }249+ }250+ return out251+}252+253+// writeHeights writes the "heights" field: rows by number, in lines.254+func (s *Sheet) writeHeights(b *bytes.Buffer, indent string) {255+ if len(s.heights) == 0 {256+ return257+ }258+ b.WriteString(indent + `"heights": {`)259+ for i, r := range slices.Sorted(maps.Keys(s.heights)) {260+ if i > 0 {261+ b.WriteString(", ")262+ }263+ b.WriteString(strconv.Quote(strconv.Itoa(r+1)) + ": " + strconv.Itoa(s.heights[r]))264+ }265+ b.WriteString("},\n")266+}
M internal/sheet/sheet.go +10 -5
@@ -67,9 +67,11 @@ type Sheet struct {
6767 live bool // in the workbook's list; false once deleted6868 tabHidden bool // left out of the tabs; see hidden.go6969 70- cells cellStore // see store.go71- widths map[int]int72- lines lineFormats // column and row formats, see lines.go70+ cells cellStore // see store.go71+ widths map[int]int72+ heights map[int]int // rows' heights set by hand, in lines; see rowlayout.go73+ lines lineFormats // column and row formats, see lines.go74+ shapers shaperIndex // the cells that wrap or draw borders, see rowlayout.go7375 7476 // dependents maps a cell to the formula cells that reference it7577 // directly. Range references are kept on the formula cell itself and
@@ -102,8 +104,9 @@ type Sheet struct {
102104 spills map[Addr]*spill103105 spillAt rangeIndex104106 105- view viewState // frozen panes and the filter, see view.go106- hidden hiddenCache // rows the filter hides, see filter.go107+ view viewState // frozen panes, the filter and merges, see view.go108+ mergeIdx mergeIndex // the merges by the cells they cover, see merge.go109+ hidden hiddenCache // rows the filter hides, see filter.go107110 108111 rules rulesState // conditional formats and data validation, see rules.go109112 looks looksCache // how the rules draw cells, see looks.go
@@ -174,6 +177,7 @@ func (s *Sheet) SetColWidth(c, w int) {
174177 175178 func (s *Sheet) setWidth(c, w int) {176179 s.recordWidth(c)180+ s.version++ // wrapped text may take other lines177181 if w <= 0 || w == DefaultWidth { // keep the map to non-default widths178182 delete(s.widths, c)179183 return
@@ -361,6 +365,7 @@ func (s *Sheet) place(a Addr, c *Cell) {
361365 if c.Spilled() {362366 c = c.leftover()363367 }368+ s.trackShape(a, c)364369 s.spillTouched(a, c)365370 if c == nil {366371 s.unlink(a)
M internal/sheet/sort.go +1 -1
M internal/sheet/spill.go +3 -0
M internal/sheet/style.go +34 -2
M internal/sheet/view.go +23 -3
@@ -11,12 +11,14 @@ import (
1111 )1212 1313 // View state that belongs to the worksheet rather than to a cell: frozen14-// rows and columns, the filter and the protected ranges. It is saved in15-// the file and every change to it is an undo step, as in Sheets.14+// rows and columns, the filter, the protected ranges and the merged15+// cells. It is saved in the file and every change to it is an undo step,16+// as in Sheets.1617 type viewState struct {1718 frozenRows, frozenCols int1819 filter *Filter // never modified in place; replaced whole1920 protected []Protection // see protect.go; replaced whole too21+ merges []Rect // see merge.go; replaced whole too2022 }2123 2224 // Frozen returns how many rows and columns are frozen at the top and left.
@@ -78,7 +80,7 @@ func (s *Sheet) recordView() {
7880 func (v viewState) equal(w viewState) bool {7981 return v.frozenRows == w.frozenRows && v.frozenCols == w.frozenCols &&8082 (v.filter == w.filter || reflect.DeepEqual(v.filter, w.filter)) &&81- slices.Equal(v.protected, w.protected)83+ slices.Equal(v.protected, w.protected) && slices.Equal(v.merges, w.merges)8284 }8385 8486 // shiftView keeps frozen lines, the filter and protected ranges in step
@@ -89,6 +91,7 @@ func (v viewState) equal(w viewState) bool {
8991 func (s *Sheet) shiftView(rows bool, sp formula.Span) {9092 v := s.view9193 v.protected = shiftProtected(v.protected, rows, sp)94+ v.merges = shiftMerges(v.merges, rows, sp)9295 frozen := &v.frozenCols9396 if rows {9497 frozen = &v.frozenRows
@@ -133,6 +136,9 @@ type fileView struct {
133136 Freeze *fileFreeze `json:"freeze,omitempty"`134137 Filter *fileFilter `json:"filter,omitempty"`135138 Protected []fileProtection `json:"protected,omitempty"`139+ // Merges need no version: earlier builds ignore them and show the140+ // cells unmerged.141+ Merges []string `json:"merges,omitempty"`136142 }137143 138144 type fileFreeze struct {
@@ -169,6 +175,13 @@ func (s *Sheet) writeView(b *bytes.Buffer, indent string) error {
169175 if ps := s.view.protected; len(ps) > 0 {170176 keys, parts = append(keys, "protected"), append(parts, encodeProtections(ps))171177 }178+ if ms := s.view.merges; len(ms) > 0 {179+ names := make([]string, len(ms))180+ for i, m := range ms {181+ names[i] = m.String()182+ }183+ keys, parts = append(keys, "merges"), append(parts, names)184+ }172185 for i, p := range parts {173186 raw, err := json.Marshal(p)174187 if err != nil {
@@ -187,6 +200,13 @@ func (s *Sheet) readView(fv fileView) error {
187200 return err188201 }189202 s.view.protected = ps203+ for _, name := range fv.Merges {204+ r, ok := ParseRange(name)205+ if !ok {206+ return fmt.Errorf("invalid merged range %q", name)207+ }208+ s.LoadMerge(r)209+ }190210 if fz := fv.Freeze; fz != nil {191211 s.view.frozenRows, s.view.frozenCols = clampInt(fz.Rows, 0, MaxFrozen), clampInt(fz.Cols, 0, MaxFrozen)192212 }
M internal/sheet/workbook.go +3 -2