| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | |
| 6 | "amadan.net/oficina/sheets/internal/sheets/store" |
| 7 | ) |
| 8 | |
| 9 | // formatPatch is a change to the formatting of a rectangle of cells. |
| 10 | // |
| 11 | // **Every field is a pointer, and that is the whole design.** A toolbar |
| 12 | // toggle sends `{"bold": false}` to un-bold and sends no `bold` key at |
| 13 | // all to leave boldness alone, and those are different requests. With |
| 14 | // value fields they would decode identically, and pressing "fill: |
| 15 | // yellow" would silently un-bold the selection — the class of bug where |
| 16 | // the control you did not touch is the one that changed. |
| 17 | // |
| 18 | // The client decides a toggle's TARGET STATE and sends it explicitly, |
| 19 | // rather than sending "toggle" and letting the server work it out over |
| 20 | // a range. Over a mixed selection those differ: Google and Excel both |
| 21 | // make everything bold when anything is not, and a per-cell flip would |
| 22 | // instead invert a checkerboard into its negative. |
| 23 | type formatPatch struct { |
| 24 | R1 int `json:"r1"` |
| 25 | C1 int `json:"c1"` |
| 26 | R2 int `json:"r2"` |
| 27 | C2 int `json:"c2"` |
| 28 | |
| 29 | Bold *bool `json:"bold,omitempty"` |
| 30 | Italic *bool `json:"italic,omitempty"` |
| 31 | Align *string `json:"align,omitempty"` |
| 32 | NumFmt *string `json:"numfmt,omitempty"` |
| 33 | // Fill and Ink carry a palette NAME, or "none" to clear. Never a |
| 34 | // hex — see palette.go for why the server owns the colours. |
| 35 | Fill *string `json:"fill,omitempty"` |
| 36 | Ink *string `json:"ink,omitempty"` |
| 37 | |
| 38 | Client string `json:"client"` |
| 39 | } |
| 40 | |
| 41 | var ( |
| 42 | errBadAlign = errors.New("sheets: unknown alignment") |
| 43 | errBadSwatch = errors.New("sheets: unknown colour") |
| 44 | errBadNumFmt = errors.New("sheets: unknown number format") |
| 45 | errEmptyPatch = errors.New("sheets: patch changes nothing") |
| 46 | ) |
| 47 | |
| 48 | // alignments are the values Align accepts. Empty clears it back to the |
| 49 | // engine's own choice, which right-aligns numbers and left-aligns text |
| 50 | // — the behaviour a spreadsheet has when nobody has said otherwise, and |
| 51 | // the reason "no alignment" is a real state rather than a synonym for |
| 52 | // left. |
| 53 | var alignments = map[string]bool{"left": true, "center": true, "right": true} |
| 54 | |
| 55 | // touchesAnything reports whether the patch would change a format at |
| 56 | // all. A patch that names no field is rejected rather than applied, |
| 57 | // because applying it would re-intern every cell in the range to a |
| 58 | // format identical to the one it already has and publish a repaint for |
| 59 | // no reason. |
| 60 | func (p formatPatch) touchesAnything() bool { |
| 61 | return p.Bold != nil || p.Italic != nil || p.Align != nil || |
| 62 | p.NumFmt != nil || p.Fill != nil || p.Ink != nil |
| 63 | } |
| 64 | |
| 65 | // validate checks the patch's values before any cell is touched. |
| 66 | // |
| 67 | // Up front and over the whole patch, not per cell: a range write that |
| 68 | // styles four hundred cells and then rejects the four hundred and first |
| 69 | // has already half-applied something the caller asked for atomically. |
| 70 | func (p formatPatch) validate() error { |
| 71 | if !p.touchesAnything() { |
| 72 | return errEmptyPatch |
| 73 | } |
| 74 | if p.Align != nil && *p.Align != "" && !alignments[*p.Align] { |
| 75 | return errBadAlign |
| 76 | } |
| 77 | // Against the offered list, not a length. See palette.go's |
| 78 | // numberFormat for why a byte cap was the wrong check. |
| 79 | if p.NumFmt != nil && !knownNumberFormat(*p.NumFmt) { |
| 80 | return errBadNumFmt |
| 81 | } |
| 82 | if p.Fill != nil && *p.Fill != noColour { |
| 83 | if _, ok := lookupSwatch(fillSwatches, *p.Fill); !ok { |
| 84 | return errBadSwatch |
| 85 | } |
| 86 | } |
| 87 | if p.Ink != nil && *p.Ink != noColour { |
| 88 | if _, ok := lookupSwatch(inkSwatches, *p.Ink); !ok { |
| 89 | return errBadSwatch |
| 90 | } |
| 91 | } |
| 92 | return nil |
| 93 | } |
| 94 | |
| 95 | // applyTo merges the patch into one cell's existing format. |
| 96 | // |
| 97 | // f is taken by value and returned by value, with ID and Digest zeroed: |
| 98 | // the result is CONTENT to be interned, not a row to be updated. |
| 99 | // Updating the row in place would edit the format shared by every other |
| 100 | // cell that happens to look the same, which is exactly what interning |
| 101 | // makes possible and exactly what it must never be used to do — bolding |
| 102 | // one cell would bold a thousand. |
| 103 | func (p formatPatch) applyTo(f store.Format) store.Format { |
| 104 | f.ID, f.Digest = 0, "" |
| 105 | |
| 106 | if p.Bold != nil { |
| 107 | f.Bold = *p.Bold |
| 108 | } |
| 109 | if p.Italic != nil { |
| 110 | f.Italic = *p.Italic |
| 111 | } |
| 112 | if p.Align != nil { |
| 113 | f.Align = *p.Align |
| 114 | } |
| 115 | if p.NumFmt != nil { |
| 116 | f.NumberFmt = *p.NumFmt |
| 117 | } |
| 118 | if p.Fill != nil { |
| 119 | applyFill(&f, *p.Fill) |
| 120 | } |
| 121 | if p.Ink != nil { |
| 122 | applyInk(&f, *p.Ink) |
| 123 | } |
| 124 | return f |
| 125 | } |
| 126 | |
| 127 | // applyFill sets or clears a fill. |
| 128 | // |
| 129 | // **Setting one clears FillHex.** FillHex is the AUTHOR'S literal, |
| 130 | // retained from import so a re-export is lossless (store/models.go). |
| 131 | // Once somebody recolours the cell here it is no longer the author's |
| 132 | // colour, and leaving the hex behind would make export write the old |
| 133 | // one back — the file would come out the colour it went in, whatever |
| 134 | // the screen showed. |
| 135 | func applyFill(f *store.Format, name string) { |
| 136 | f.FillHex = "" |
| 137 | if name == noColour { |
| 138 | f.FillIntentH, f.FillIntentC = nil, nil |
| 139 | return |
| 140 | } |
| 141 | s, ok := lookupSwatch(fillSwatches, name) |
| 142 | if !ok { |
| 143 | // validate has already refused this; the guard is here so a |
| 144 | // future caller that forgets to validate clears the fill rather |
| 145 | // than writing a zero hue, which is red. |
| 146 | f.FillIntentH, f.FillIntentC = nil, nil |
| 147 | return |
| 148 | } |
| 149 | h, c := s.H, s.C |
| 150 | f.FillIntentH, f.FillIntentC = &h, &c |
| 151 | } |
| 152 | |
| 153 | // applyInk is applyFill's twin for text colour — same rule, same reason. |
| 154 | func applyInk(f *store.Format, name string) { |
| 155 | f.InkHex = "" |
| 156 | if name == noColour { |
| 157 | f.InkIntentH, f.InkIntentC = nil, nil |
| 158 | return |
| 159 | } |
| 160 | s, ok := lookupSwatch(inkSwatches, name) |
| 161 | if !ok { |
| 162 | f.InkIntentH, f.InkIntentC = nil, nil |
| 163 | return |
| 164 | } |
| 165 | h, c := s.H, s.C |
| 166 | f.InkIntentH, f.InkIntentC = &h, &c |
| 167 | } |
| 168 | |
| 169 | // normalize orders and bounds the rectangle. |
| 170 | // |
| 171 | // A selection dragged up and to the left arrives with r2 < r1, which is |
| 172 | // not an error — it is how a person selects. Ordering here means every |
| 173 | // caller below can assume r1 <= r2 rather than each remembering to. |
| 174 | func (p formatPatch) normalize(maxRows, maxCols int) (r1, c1, r2, c2 int, ok bool) { |
| 175 | r1, r2 = p.R1, p.R2 |
| 176 | c1, c2 = p.C1, p.C2 |
| 177 | if r1 > r2 { |
| 178 | r1, r2 = r2, r1 |
| 179 | } |
| 180 | if c1 > c2 { |
| 181 | c1, c2 = c2, c1 |
| 182 | } |
| 183 | if r1 < 0 || c1 < 0 { |
| 184 | return 0, 0, 0, 0, false |
| 185 | } |
| 186 | if r2 >= maxRows || c2 >= maxCols { |
| 187 | return 0, 0, 0, 0, false |
| 188 | } |
| 189 | return r1, c1, r2, c2, true |
| 190 | } |
| 191 | |