feat(format): the styles we already store, on the wire and writable
Format rows have existed since XLSX import: bold, italic, fill, ink, align, number format, interned by digest. Nothing has ever painted one, so an imported file with a bold header and a green total arrived here as plain text — and the contrast notice on that page warned about colours the grid was not showing. Three changes, all server-side: - Formats travel on the wire INTERNED. Each cell carries a key; the response carries the table. A column of 5,000 identically styled cells is one Format row in storage, and inlining the style per cell would un-intern it at the last step. - store.Handle.Formats reads a whole tab's formats in two queries. Handle.Format is two round trips per cell, which was fine while only the contrast check read them and is not fine now that every painted cell asks. - POST .../format applies a patch to a rectangle. Every patch field is a pointer, so a fill button cannot silently un-bold the selection, and the client sends a toggle's target state rather than "toggle" — over a mixed selection those differ, and per-cell flipping would invert a checkerboard into its negative. Colour is picked from a server-owned palette by NAME, never a hex. A fill this app applies is stored as OKLCH hue and chroma with no lightness, so it resolves against the reader's light/dark choice; a hex would bake one in. Ink gains the same pair of columns (migration 0008, additive) for the same reason. An imported fill keeps its literal and is still painted exactly as authored — spec §11.1, and what the contrast notice already promises. Formatting writes no event, so it is not undoable. Named here rather than discovered: the fix is a format event kind, which changes the undo model.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0182f7QfojuuqdS4Xd2fYFaL
14 files changed,
+1030
−28
internal/sheets/app.go+1 −0internal/sheets/formatpatch.go+195 −0internal/sheets/grid.go+44 −12internal/sheets/gridstyle.go+144 −0internal/sheets/handlers_format.go+173 −0internal/sheets/handlers_format_test.go+222 −0internal/sheets/handlers_grid.go+5 −3internal/sheets/handlers_history.go+1 −6internal/sheets/handlers_table.go+1 −6internal/sheets/migrations/0008_alter_formats_ink_intent.sql+2 −0internal/sheets/palette.go+119 −0internal/sheets/store/handle_formats.go+102 −0internal/sheets/store/handle_grid.go+8 −0internal/sheets/store/models.go+13 −1
diff --git a/internal/sheets/app.go b/internal/sheets/app.go| index cbce2d0..9a0f59e 100644 |
| --- a/internal/sheets/app.go |
| +++ b/internal/sheets/app.go |
| @@ -224,6 +224,7 @@ func (a *App) Router() http.Handler { |
| // ask for it in. |
| r.Get("/api/sheets/{sheetID}/tabs/{tabID}/cells", a.handleCellsGet) |
| r.Post("/api/sheets/{sheetID}/tabs/{tabID}/cells", a.handleCellPost) |
| + r.Post("/api/sheets/{sheetID}/tabs/{tabID}/format", a.handleFormatPost) |
| r.Get("/api/sheets/{sheetID}/tabs/{tabID}/stream", a.handleStream) |
| // The job status page, its polled fragment and its event |
diff --git a/internal/sheets/formatpatch.go b/internal/sheets/formatpatch.go| new file mode 100644 |
| index 0000000..2e46bfc |
| --- /dev/null |
| +++ b/internal/sheets/formatpatch.go |
| @@ -0,0 +1,195 @@ |
| +package sheets |
| + |
| +import ( |
| + "errors" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/store" |
| +) |
| + |
| +// maxNumFmtBytes caps a number-format pattern. |
| +// |
| +// A pattern, not a document. format's tokenizer walks it per painted |
| +// cell, so a long one is paid for on every render of every cell using |
| +// it — and no pattern anybody means to write is close to this. |
| +const maxNumFmtBytes = 128 |
| + |
| +// formatPatch is a change to the formatting of a rectangle of cells. |
| +// |
| +// **Every field is a pointer, and that is the whole design.** A toolbar |
| +// toggle sends `{"bold": false}` to un-bold and sends no `bold` key at |
| +// all to leave boldness alone, and those are different requests. With |
| +// value fields they would decode identically, and pressing "fill: |
| +// yellow" would silently un-bold the selection — the class of bug where |
| +// the control you did not touch is the one that changed. |
| +// |
| +// The client decides a toggle's TARGET STATE and sends it explicitly, |
| +// rather than sending "toggle" and letting the server work it out over |
| +// a range. Over a mixed selection those differ: Google and Excel both |
| +// make everything bold when anything is not, and a per-cell flip would |
| +// instead invert a checkerboard into its negative. |
| +type formatPatch struct { |
| + R1 int `json:"r1"` |
| + C1 int `json:"c1"` |
| + R2 int `json:"r2"` |
| + C2 int `json:"c2"` |
| + |
| + Bold *bool `json:"bold,omitempty"` |
| + Italic *bool `json:"italic,omitempty"` |
| + Align *string `json:"align,omitempty"` |
| + NumFmt *string `json:"numfmt,omitempty"` |
| + // Fill and Ink carry a palette NAME, or "none" to clear. Never a |
| + // hex — see palette.go for why the server owns the colours. |
| + Fill *string `json:"fill,omitempty"` |
| + Ink *string `json:"ink,omitempty"` |
| + |
| + Client string `json:"client"` |
| +} |
| + |
| +var ( |
| + errBadAlign = errors.New("sheets: unknown alignment") |
| + errBadSwatch = errors.New("sheets: unknown colour") |
| + errBadNumFmt = errors.New("sheets: number format too long") |
| + errEmptyPatch = errors.New("sheets: patch changes nothing") |
| +) |
| + |
| +// alignments are the values Align accepts. Empty clears it back to the |
| +// engine's own choice, which right-aligns numbers and left-aligns text |
| +// — the behaviour a spreadsheet has when nobody has said otherwise, and |
| +// the reason "no alignment" is a real state rather than a synonym for |
| +// left. |
| +var alignments = map[string]bool{"left": true, "center": true, "right": true} |
| + |
| +// touchesAnything reports whether the patch would change a format at |
| +// all. A patch that names no field is rejected rather than applied, |
| +// because applying it would re-intern every cell in the range to a |
| +// format identical to the one it already has and publish a repaint for |
| +// no reason. |
| +func (p formatPatch) touchesAnything() bool { |
| + return p.Bold != nil || p.Italic != nil || p.Align != nil || |
| + p.NumFmt != nil || p.Fill != nil || p.Ink != nil |
| +} |
| + |
| +// validate checks the patch's values before any cell is touched. |
| +// |
| +// Up front and over the whole patch, not per cell: a range write that |
| +// styles four hundred cells and then rejects the four hundred and first |
| +// has already half-applied something the caller asked for atomically. |
| +func (p formatPatch) validate() error { |
| + if !p.touchesAnything() { |
| + return errEmptyPatch |
| + } |
| + if p.Align != nil && *p.Align != "" && !alignments[*p.Align] { |
| + return errBadAlign |
| + } |
| + if p.NumFmt != nil && len(*p.NumFmt) > maxNumFmtBytes { |
| + return errBadNumFmt |
| + } |
| + if p.Fill != nil && *p.Fill != noColour { |
| + if _, ok := lookupSwatch(fillSwatches, *p.Fill); !ok { |
| + return errBadSwatch |
| + } |
| + } |
| + if p.Ink != nil && *p.Ink != noColour { |
| + if _, ok := lookupSwatch(inkSwatches, *p.Ink); !ok { |
| + return errBadSwatch |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// applyTo merges the patch into one cell's existing format. |
| +// |
| +// f is taken by value and returned by value, with ID and Digest zeroed: |
| +// the result is CONTENT to be interned, not a row to be updated. |
| +// Updating the row in place would edit the format shared by every other |
| +// cell that happens to look the same, which is exactly what interning |
| +// makes possible and exactly what it must never be used to do — bolding |
| +// one cell would bold a thousand. |
| +func (p formatPatch) applyTo(f store.Format) store.Format { |
| + f.ID, f.Digest = 0, "" |
| + |
| + if p.Bold != nil { |
| + f.Bold = *p.Bold |
| + } |
| + if p.Italic != nil { |
| + f.Italic = *p.Italic |
| + } |
| + if p.Align != nil { |
| + f.Align = *p.Align |
| + } |
| + if p.NumFmt != nil { |
| + f.NumberFmt = *p.NumFmt |
| + } |
| + if p.Fill != nil { |
| + applyFill(&f, *p.Fill) |
| + } |
| + if p.Ink != nil { |
| + applyInk(&f, *p.Ink) |
| + } |
| + return f |
| +} |
| + |
| +// applyFill sets or clears a fill. |
| +// |
| +// **Setting one clears FillHex.** FillHex is the AUTHOR'S literal, |
| +// retained from import so a re-export is lossless (store/models.go). |
| +// Once somebody recolours the cell here it is no longer the author's |
| +// colour, and leaving the hex behind would make export write the old |
| +// one back — the file would come out the colour it went in, whatever |
| +// the screen showed. |
| +func applyFill(f *store.Format, name string) { |
| + f.FillHex = "" |
| + if name == noColour { |
| + f.FillIntentH, f.FillIntentC = nil, nil |
| + return |
| + } |
| + s, ok := lookupSwatch(fillSwatches, name) |
| + if !ok { |
| + // validate has already refused this; the guard is here so a |
| + // future caller that forgets to validate clears the fill rather |
| + // than writing a zero hue, which is red. |
| + f.FillIntentH, f.FillIntentC = nil, nil |
| + return |
| + } |
| + h, c := s.H, s.C |
| + f.FillIntentH, f.FillIntentC = &h, &c |
| +} |
| + |
| +// applyInk is applyFill's twin for text colour — same rule, same reason. |
| +func applyInk(f *store.Format, name string) { |
| + f.InkHex = "" |
| + if name == noColour { |
| + f.InkIntentH, f.InkIntentC = nil, nil |
| + return |
| + } |
| + s, ok := lookupSwatch(inkSwatches, name) |
| + if !ok { |
| + f.InkIntentH, f.InkIntentC = nil, nil |
| + return |
| + } |
| + h, c := s.H, s.C |
| + f.InkIntentH, f.InkIntentC = &h, &c |
| +} |
| + |
| +// normalize orders and bounds the rectangle. |
| +// |
| +// A selection dragged up and to the left arrives with r2 < r1, which is |
| +// not an error — it is how a person selects. Ordering here means every |
| +// caller below can assume r1 <= r2 rather than each remembering to. |
| +func (p formatPatch) normalize(maxRows, maxCols int) (r1, c1, r2, c2 int, ok bool) { |
| + r1, r2 = p.R1, p.R2 |
| + c1, c2 = p.C1, p.C2 |
| + if r1 > r2 { |
| + r1, r2 = r2, r1 |
| + } |
| + if c1 > c2 { |
| + c1, c2 = c2, c1 |
| + } |
| + if r1 < 0 || c1 < 0 { |
| + return 0, 0, 0, 0, false |
| + } |
| + if r2 >= maxRows || c2 >= maxCols { |
| + return 0, 0, 0, 0, false |
| + } |
| + return r1, c1, r2, c2, true |
| +} |
diff --git a/internal/sheets/grid.go b/internal/sheets/grid.go| index 082a445..4f21475 100644 |
| --- a/internal/sheets/grid.go |
| +++ b/internal/sheets/grid.go |
| @@ -26,6 +26,10 @@ type gridCell struct { |
| Raw string `json:"raw"` |
| Display string `json:"display"` |
| Derived bool `json:"derived,omitempty"` |
| + // F is the id of this cell's interned format, into the response's |
| + // own Formats table. Empty for an unformatted cell — the common |
| + // case, and the reason this is a key rather than an inline object. |
| + F string `json:"f,omitempty"` |
| } |
| // gridResponse is what both GET and POST answer, and what the SSE |
| @@ -50,6 +54,15 @@ type gridResponse struct { |
| Cells []gridCell `json:"cells"` |
| Resync bool `json:"resync,omitempty"` |
| Client string `json:"client,omitempty"` |
| + // Formats is every distinct style the cells above refer to, keyed |
| + // by the id each cell's F names. Absent when nothing in the batch |
| + // is formatted. |
| + // |
| + // A batch carries only the formats IT mentions, not the tab's whole |
| + // table, so a client must merge rather than replace — see grid.js. |
| + // Replacing would drop every style outside the edited cell the |
| + // first time somebody typed into an imported sheet. |
| + Formats styleTable `json:"formats,omitempty"` |
| } |
| // display turns a computed value into the string the grid paints. |
| @@ -99,29 +112,38 @@ func newPosIndex(h *store.Handle) posIndex { |
| // Sparse: only cells that exist are sent. A blank cell is absent from |
| // the response rather than present and empty, which is the difference |
| // between a 100×26 window costing two kilobytes and costing sixty. |
| -func snapshot(h *store.Handle, idx posIndex, rows, cols int) ([]gridCell, error) { |
| +func snapshot(h *store.Handle, idx posIndex, rows, cols int) ([]gridCell, styleTable, error) { |
| coords, err := h.Cells() |
| if err != nil { |
| - return nil, err |
| + return nil, nil, err |
| } |
| + // One batched read for the whole window, not one query per cell: |
| + // see store.Handle.Formats for why Handle.Format is the wrong shape |
| + // here now that every cell's style is painted. |
| + formats, err := h.Formats() |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + col := newCollector(formats) |
| out := make([]gridCell, 0, len(coords)) |
| for _, c := range coords { |
| r, ok := idx.rowPos[c.RowID] |
| if !ok || r >= rows { |
| continue |
| } |
| - col, ok := idx.colPos[c.ColID] |
| - if !ok || col >= cols { |
| + colPos, ok := idx.colPos[c.ColID] |
| + if !ok || colPos >= cols { |
| continue |
| } |
| raw, _ := h.RawInput(c.RowID, c.ColID) |
| out = append(out, gridCell{ |
| - R: r, C: col, |
| + R: r, C: colPos, |
| Raw: raw, |
| - Display: display(h.Value(c.RowID, c.ColID), h.Format(c.RowID, c.ColID)), |
| + Display: display(h.Value(c.RowID, c.ColID), col.numFmtFor(c.RowID, c.ColID)), |
| + F: col.keyFor(c.RowID, c.ColID), |
| }) |
| } |
| - return out, nil |
| + return out, col.done(), nil |
| } |
| // fromReport turns a write's report into wire cells. |
| @@ -130,25 +152,35 @@ func snapshot(h *store.Handle, idx posIndex, rows, cols int) ([]gridCell, error) |
| // dropped rather than sent with a wrong coordinate: a cell painted in |
| // the wrong place is worse than a cell not painted, because nothing |
| // later corrects it. |
| -func fromReport(rep store.Report, idx posIndex, h *store.Handle) []gridCell { |
| +func fromReport(rep store.Report, idx posIndex, h *store.Handle) ([]gridCell, styleTable) { |
| + formats, err := h.Formats() |
| + if err != nil { |
| + // A format read that fails costs styling on this batch, not the |
| + // edit itself. The values are what the person typed; painting |
| + // them unstyled and letting the next snapshot correct it beats |
| + // failing a write that already landed in storage. |
| + formats = nil |
| + } |
| + col := newCollector(formats) |
| out := make([]gridCell, 0, len(rep.Cells)) |
| for _, c := range rep.Cells { |
| r, ok := idx.rowPos[c.RowID] |
| if !ok { |
| continue |
| } |
| - col, ok := idx.colPos[c.ColID] |
| + colPos, ok := idx.colPos[c.ColID] |
| if !ok { |
| continue |
| } |
| out = append(out, gridCell{ |
| - R: r, C: col, |
| + R: r, C: colPos, |
| Raw: c.Raw, |
| - Display: display(c.Value, h.Format(c.RowID, c.ColID)), |
| + Display: display(c.Value, col.numFmtFor(c.RowID, c.ColID)), |
| Derived: c.Derived, |
| + F: col.keyFor(c.RowID, c.ColID), |
| }) |
| } |
| - return out |
| + return out, col.done() |
| } |
| // clamp bounds a requested window. |
diff --git a/internal/sheets/gridstyle.go b/internal/sheets/gridstyle.go| new file mode 100644 |
| index 0000000..8404119 |
| --- /dev/null |
| +++ b/internal/sheets/gridstyle.go |
| @@ -0,0 +1,144 @@ |
| +package sheets |
| + |
| +import ( |
| + "strconv" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/store" |
| +) |
| + |
| +// wireStyle is one interned Format on the wire. |
| +// |
| +// Sent ONCE per response, in gridResponse.Formats, with each cell |
| +// carrying only the id. That is not a size micro-optimisation: it is |
| +// interning preserved across the wire. A column of 5,000 identically |
| +// styled cells is one Format row in storage (store/models.go), and |
| +// inlining the style per cell would un-intern it at the last step — |
| +// 5,000 copies of the same seven fields, and 5,000 chances for the |
| +// client to decide two of them differ. |
| +// |
| +// Short JSON names because this shape is repeated in every SSE frame, |
| +// and the stream is the one place in this app where bytes are paid for |
| +// per keystroke rather than per page load. |
| +type wireStyle struct { |
| + Bold bool `json:"b,omitempty"` |
| + Italic bool `json:"i,omitempty"` |
| + Align string `json:"a,omitempty"` |
| + NumFmt string `json:"n,omitempty"` |
| + |
| + // FillHex and Ink are the AUTHOR'S literal colours, present only on |
| + // a format that came from an imported file. They are painted as |
| + // authored, in both themes, which is spec §11.1's rule and the |
| + // promise the contrast notice already makes on this page: "shown |
| + // exactly as the file wrote them". Correcting an imported yellow |
| + // header to something theme-appropriate would lie about the |
| + // document, and the app has already decided to warn instead. |
| + FillHex string `json:"fx,omitempty"` |
| + Ink string `json:"k,omitempty"` |
| + |
| + // FillH and FillC are a fill's OKLCH hue and chroma with no |
| + // lightness, which is how a fill THIS app applied is stored |
| + // (store/models.go: "lightness is chosen per-theme, not stored"). |
| + // The client resolves them against --sheet-fill-l, so a fill a |
| + // person picks here follows their light/dark choice instead of |
| + // staying a light-mode swatch on a dark grid. |
| + // |
| + // Pointers, because 0 is a real hue (red) and a real chroma (grey). |
| + FillH *float64 `json:"fh,omitempty"` |
| + FillC *float64 `json:"fc,omitempty"` |
| +} |
| + |
| +// styleOf converts a stored Format to its wire shape. |
| +func styleOf(f *store.Format) wireStyle { |
| + if f == nil { |
| + return wireStyle{} |
| + } |
| + return wireStyle{ |
| + Bold: f.Bold, Italic: f.Italic, Align: f.Align, NumFmt: f.NumberFmt, |
| + FillHex: f.FillHex, Ink: f.InkHex, |
| + FillH: f.FillIntentH, FillC: f.FillIntentC, |
| + } |
| +} |
| + |
| +// styleTable collects the distinct formats a response refers to. |
| +// |
| +// Keyed by the interned Format's own id rendered as a string, because |
| +// JSON object keys are strings and a client that reads the id back as a |
| +// number would have to remember to render it the same way again when it |
| +// looks a cell's format up. |
| +type styleTable map[string]wireStyle |
| + |
| +// fmtKey renders a format id for the wire. "" for the unformatted case, |
| +// so a cell with no format omits the field entirely rather than |
| +// carrying a zero that the client has to know means nothing. |
| +func fmtKey(id uint64) string { |
| + if id == 0 { |
| + return "" |
| + } |
| + return strconv.FormatUint(id, 10) |
| +} |
| + |
| +// collector accumulates the styles a response mentions. |
| +// |
| +// It exists so the two response builders — snapshot and fromReport — |
| +// share one definition of "which formats does this batch need", rather |
| +// than each growing its own loop that could diverge on the day one of |
| +// them learns about a new field. |
| +type collector struct { |
| + byCoord map[store.CellCoord]*store.Format |
| + table styleTable |
| +} |
| + |
| +func newCollector(byCoord map[store.CellCoord]*store.Format) *collector { |
| + return &collector{byCoord: byCoord, table: styleTable{}} |
| +} |
| + |
| +// keyFor records the format at a coordinate and returns its wire key. |
| +func (c *collector) keyFor(rowID, colID uint64) string { |
| + f, ok := c.byCoord[store.CellCoord{RowID: rowID, ColID: colID}] |
| + if !ok || f == nil { |
| + return "" |
| + } |
| + key := fmtKey(f.ID) |
| + if key == "" { |
| + return "" |
| + } |
| + if _, seen := c.table[key]; !seen { |
| + c.table[key] = styleOf(f) |
| + } |
| + return key |
| +} |
| + |
| +// numFmtFor is the number format a cell's value should be displayed |
| +// with — the one piece of a Format the SERVER still has to act on, |
| +// because display strings are the server's to own (grid.go's display). |
| +func (c *collector) numFmtFor(rowID, colID uint64) *store.Format { |
| + return c.byCoord[store.CellCoord{RowID: rowID, ColID: colID}] |
| +} |
| + |
| +// done returns the table, or nil when nothing in the batch was |
| +// formatted — so an unformatted sheet's every response omits the field |
| +// rather than carrying an empty object. |
| +func (c *collector) done() styleTable { |
| + if len(c.table) == 0 { |
| + return nil |
| + } |
| + return c.table |
| +} |
| + |
| +// publishReport touches the sheet and fans one write out to every other |
| +// tab watching it. |
| +// |
| +// The two scriptless write paths — the table view's cell form and |
| +// history's restore — did this identically, seven lines each, and the |
| +// day formats joined the response both had to be edited in step. One |
| +// place, so the next field added to a published batch is added once. |
| +func (a *App) publishReport(s SheetSummary, h *store.Handle, rep store.Report) { |
| + a.touchSheet(s.ID) |
| + cells, styles := fromReport(rep, newPosIndex(h), h) |
| + a.publish(s.TabID, gridResponse{ |
| + Version: rep.Version, |
| + Cells: cells, |
| + Formats: styles, |
| + Resync: rep.Capped, |
| + }) |
| +} |
diff --git a/internal/sheets/handlers_format.go b/internal/sheets/handlers_format.go| new file mode 100644 |
| index 0000000..fa74ad8 |
| --- /dev/null |
| +++ b/internal/sheets/handlers_format.go |
| @@ -0,0 +1,173 @@ |
| +package sheets |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/store" |
| +) |
| + |
| +// maxFormatCells bounds one format write. |
| +// |
| +// Formatting a range is a per-cell write — a Format row interned once, |
| +// then one Cell row updated per coordinate — so "select the column and |
| +// make it bold" over a hundred-thousand-cell selection is a hundred |
| +// thousand updates inside one request. Capped rather than streamed |
| +// because the cap is generous against any selection a person makes by |
| +// hand, and a bounded refusal beats a request that appears to hang. |
| +const maxFormatCells = 4000 |
| + |
| +// handleFormatPost applies a format patch to a rectangle of cells. |
| +// |
| +// It is a sibling of handleCellPost and deliberately shaped like it: |
| +// same auth, same tab resolution, same publish-then-answer, same |
| +// response type. A format change IS a change to the grid, and a client |
| +// that had to merge two different response shapes would have two paints |
| +// that could disagree. |
| +// |
| +// **Formatting is not in the event log, so it is not undoable.** Undo |
| +// is cell-level and replays input events (spec §7); a format carries no |
| +// event, so Ctrl-Z after bolding undoes the last VALUE edit instead, |
| +// which is surprising in the way this app tries not to be. It is a |
| +// deliberate, named limitation of this slice rather than an oversight — |
| +// the fix is a format event kind, which changes the undo model and is |
| +// not a thing to do in passing. Until then the UI does not offer undo |
| +// for formatting. |
| +func (a *App) handleFormatPost(w http.ResponseWriter, r *http.Request) { |
| + actor, ok := a.actor(r) |
| + if !ok { |
| + http.Error(w, "Sign in first.", http.StatusForbidden) |
| + return |
| + } |
| + h, ok := a.openTab(w, r, SheetEditor) |
| + if !ok { |
| + return |
| + } |
| + defer h.Release() |
| + |
| + var p formatPatch |
| + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&p); err != nil { |
| + // Not echoed: a number-format pattern is authored content, and |
| + // an error string is a log line waiting to happen (AGENTS.md). |
| + http.Error(w, "Malformed request.", http.StatusBadRequest) |
| + return |
| + } |
| + if err := p.validate(); err != nil { |
| + http.Error(w, "That formatting could not be applied.", http.StatusBadRequest) |
| + return |
| + } |
| + r1, c1, r2, c2, ok := p.normalize(maxWindowRows*10, maxWindowCols*10) |
| + if !ok { |
| + http.Error(w, "Range out of bounds.", http.StatusBadRequest) |
| + return |
| + } |
| + if (r2-r1+1)*(c2-c1+1) > maxFormatCells { |
| + http.Error(w, "That range is too large to format in one go.", http.StatusRequestEntityTooLarge) |
| + return |
| + } |
| + |
| + coords, err := a.applyFormat(h, p, r1, c1, r2, c2) |
| + if err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + _ = actor // formatting records no event, so there is no actor to write |
| + |
| + resp, err := formatResponse(h, coords, p.Client) |
| + if err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + a.touchSheet(sheetIDFrom(r)) |
| + a.publish(tabIDFrom(r), resp) |
| + writeJSON(w, http.StatusOK, resp) |
| +} |
| + |
| +// applyFormat writes the patch across the rectangle and returns the |
| +// coordinates it touched. |
| +// |
| +// Every cell in the range is visited, including blank ones: a coloured |
| +// header nobody has typed into yet is a real thing to want, and |
| +// store.Handle.SetFormat creates the Cell row to carry it. |
| +func (a *App) applyFormat(h *store.Handle, p formatPatch, r1, c1, r2, c2 int) ([]store.CellCoord, error) { |
| + existing, err := h.Formats() |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + out := make([]store.CellCoord, 0, (r2-r1+1)*(c2-c1+1)) |
| + for row := r1; row <= r2; row++ { |
| + rowID, err := h.EnsureRow(row) |
| + if err != nil { |
| + return nil, err |
| + } |
| + for col := c1; col <= c2; col++ { |
| + colID, err := h.EnsureCol(col) |
| + if err != nil { |
| + return nil, err |
| + } |
| + coord := store.CellCoord{RowID: rowID, ColID: colID} |
| + |
| + // The cell's CURRENT format is the base, so a patch that |
| + // only sets bold keeps the fill somebody applied earlier. |
| + // Merging against the zero value instead would make every |
| + // toolbar button a reset button for the other eight. |
| + base := store.Format{} |
| + if f, ok := existing[coord]; ok && f != nil { |
| + base = *f |
| + } |
| + if err := h.SetFormat(rowID, colID, p.applyTo(base)); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, coord) |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// formatResponse re-reads the touched cells and builds the paint. |
| +// |
| +// Re-read rather than predicted, for one reason worth stating: a number |
| +// format changes the DISPLAY string, and the server owns every string |
| +// on screen (grid.go's display). A response that echoed the patch and |
| +// left the client to re-render would put a number-format implementation |
| +// in JavaScript, which is the one thing this design will not do. |
| +func formatResponse(h *store.Handle, coords []store.CellCoord, client string) (gridResponse, error) { |
| + idx := newPosIndex(h) |
| + formats, err := h.Formats() |
| + if err != nil { |
| + return gridResponse{}, err |
| + } |
| + col := newCollector(formats) |
| + |
| + cells := make([]gridCell, 0, len(coords)) |
| + for _, c := range coords { |
| + rowPos, ok := idx.rowPos[c.RowID] |
| + if !ok { |
| + continue |
| + } |
| + colPos, ok := idx.colPos[c.ColID] |
| + if !ok { |
| + continue |
| + } |
| + raw, _ := h.RawInput(c.RowID, c.ColID) |
| + cells = append(cells, gridCell{ |
| + R: rowPos, C: colPos, |
| + Raw: raw, |
| + Display: display(h.Value(c.RowID, c.ColID), col.numFmtFor(c.RowID, c.ColID)), |
| + F: col.keyFor(c.RowID, c.ColID), |
| + }) |
| + } |
| + return gridResponse{ |
| + // The tab's version is unchanged: a format writes no input |
| + // event, so nothing has advanced it. Sending the current |
| + // version rather than zero matters — applyBatch discards a |
| + // batch whose version TRAILS what it has painted, and zero |
| + // would be discarded by every client that had ever seen an |
| + // edit. |
| + Version: h.Version(), |
| + Cells: cells, |
| + Formats: col.done(), |
| + Client: client, |
| + }, nil |
| +} |
diff --git a/internal/sheets/handlers_format_test.go b/internal/sheets/handlers_format_test.go| new file mode 100644 |
| index 0000000..d9f0806 |
| --- /dev/null |
| +++ b/internal/sheets/handlers_format_test.go |
| @@ -0,0 +1,222 @@ |
| +package sheets |
| + |
| +import ( |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/store" |
| +) |
| + |
| +// formatPath is the format endpoint for a sheet's first tab. |
| +func formatPath(s SheetSummary) string { |
| + return fmt.Sprintf("/api/sheets/%d/tabs/%d/format", s.ID, s.TabID) |
| +} |
| + |
| +func postFormat(t *testing.T, app *App, cookies []*http.Cookie, s SheetSummary, body string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + req := httptest.NewRequest(http.MethodPost, formatPath(s), strings.NewReader(body)) |
| + req.Header.Set("Content-Type", "application/json") |
| + return do(t, app, as(req, cookies)) |
| +} |
| + |
| +// styleFor resolves a cell's style through the response's own format |
| +// table — the indirection the wire uses, exercised the way a client |
| +// does, so a test cannot pass against a shape no client could read. |
| +func styleFor(t *testing.T, g gridResponse, r, c int) wireStyle { |
| + t.Helper() |
| + for _, cell := range g.Cells { |
| + if cell.R != r || cell.C != c { |
| + continue |
| + } |
| + if cell.F == "" { |
| + t.Fatalf("cell (%d,%d) carries no format key", r, c) |
| + } |
| + st, ok := g.Formats[cell.F] |
| + if !ok { |
| + t.Fatalf("cell (%d,%d) names format %q, which the response does not carry — "+ |
| + "a client would paint nothing and never know why", r, c, cell.F) |
| + } |
| + return st |
| + } |
| + t.Fatalf("no cell at (%d,%d) in %+v", r, c, g.Cells) |
| + return wireStyle{} |
| +} |
| + |
| +func TestFormatAppliesAcrossARange(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + |
| + rec := postFormat(t, app, cookies, s, `{"r1":0,"c1":0,"r2":1,"c2":2,"bold":true}`) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("POST format = %d: %s", rec.Code, rec.Body.String()) |
| + } |
| + g := decodeGrid(t, rec) |
| + if len(g.Cells) != 6 { |
| + t.Fatalf("formatted %d cells, want 6 (2 rows x 3 cols)", len(g.Cells)) |
| + } |
| + // Every cell in the range must be bold, INCLUDING the blank ones: a |
| + // coloured or bold header nobody has typed into yet is the ordinary |
| + // case, and it needs a Cell row to carry the format. |
| + for r := 0; r <= 1; r++ { |
| + for c := 0; c <= 2; c++ { |
| + if !styleFor(t, g, r, c).Bold { |
| + t.Errorf("cell (%d,%d) is not bold", r, c) |
| + } |
| + } |
| + } |
| + // Interning survives the wire: six cells, one style. |
| + if len(g.Formats) != 1 { |
| + t.Errorf("response carries %d distinct formats, want 1 — six identically "+ |
| + "styled cells are one interned format, and inlining would un-intern them", len(g.Formats)) |
| + } |
| +} |
| + |
| +func TestFormatMergesRatherThanReplaces(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + |
| + if rec := postFormat(t, app, cookies, s, `{"r1":0,"c1":0,"r2":0,"c2":0,"bold":true}`); rec.Code != http.StatusOK { |
| + t.Fatalf("first patch = %d: %s", rec.Code, rec.Body.String()) |
| + } |
| + g := decodeGrid(t, postFormat(t, app, cookies, s, `{"r1":0,"c1":0,"r2":0,"c2":0,"fill":"yellow"}`)) |
| + |
| + st := styleFor(t, g, 0, 0) |
| + // The second patch never mentioned bold, so bold must survive it. |
| + // This is the whole reason every patch field is a pointer: pressing |
| + // a fill button must not un-bold the selection. |
| + if !st.Bold { |
| + t.Error("applying a fill cleared bold — a patch changes only the fields it names") |
| + } |
| + if st.FillH == nil || st.FillC == nil { |
| + t.Fatalf("fill intent missing: %+v", st) |
| + } |
| +} |
| + |
| +func TestFormatFillIsIntentNotHex(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + |
| + g := decodeGrid(t, postFormat(t, app, cookies, s, `{"r1":0,"c1":0,"r2":0,"c2":0,"fill":"green"}`)) |
| + st := styleFor(t, g, 0, 0) |
| + // A fill this app applies carries no lightness, so it can resolve |
| + // against the reader's theme. A hex would bake one in. |
| + if st.FillHex != "" { |
| + t.Errorf("fill hex = %q, want empty — a fill we apply is stored as intent, not a literal", st.FillHex) |
| + } |
| + if st.FillH == nil { |
| + t.Fatal("no fill hue") |
| + } |
| + if *st.FillH != 145 { |
| + t.Errorf("fill hue = %v, want green's 145", *st.FillH) |
| + } |
| +} |
| + |
| +func TestFormatClearingAFillClearsTheImportedHexToo(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + |
| + // Stand in for an import: a format carrying the author's literal. |
| + h, err := store.Open(app.db, app.sealer, s.TabID) |
| + if err != nil { |
| + t.Fatalf("open tab: %v", err) |
| + } |
| + rowID, err := h.EnsureRow(0) |
| + if err != nil { |
| + t.Fatalf("ensure row: %v", err) |
| + } |
| + colID, err := h.EnsureCol(0) |
| + if err != nil { |
| + t.Fatalf("ensure col: %v", err) |
| + } |
| + if err := h.SetFormat(rowID, colID, store.Format{FillHex: "FFFFEB9C", InkHex: "FF9C6500"}); err != nil { |
| + t.Fatalf("seed imported format: %v", err) |
| + } |
| + h.Release() |
| + |
| + g := decodeGrid(t, postFormat(t, app, cookies, s, `{"r1":0,"c1":0,"r2":0,"c2":0,"fill":"none","ink":"none"}`)) |
| + st := styleFor(t, g, 0, 0) |
| + // Clearing must clear the retained literal as well, or export |
| + // writes the old colour back and the file comes out the colour it |
| + // went in whatever the screen showed. |
| + if st.FillHex != "" || st.Ink != "" { |
| + t.Errorf("clearing left literals behind: fill %q ink %q", st.FillHex, st.Ink) |
| + } |
| + if st.FillH != nil || st.FillC != nil { |
| + t.Errorf("clearing left an intent behind: %+v", st) |
| + } |
| +} |
| + |
| +func TestFormatNumberFormatChangesTheServerRenderedDisplay(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, cells := newSheet(t, app, cookies, "Budget") |
| + |
| + if rec := postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"0.5"}`); rec.Code != http.StatusOK { |
| + t.Fatalf("write cell = %d: %s", rec.Code, rec.Body.String()) |
| + } |
| + g := decodeGrid(t, postFormat(t, app, cookies, s, `{"r1":0,"c1":0,"r2":0,"c2":0,"numfmt":"0%"}`)) |
| + for _, cell := range g.Cells { |
| + if cell.R == 0 && cell.C == 0 { |
| + // The response re-renders rather than echoing the patch: |
| + // the server owns every painted string, so a number format |
| + // must not need a JavaScript implementation to take effect. |
| + if cell.Display != "50%" { |
| + t.Errorf("display = %q, want %q", cell.Display, "50%") |
| + } |
| + return |
| + } |
| + } |
| + t.Fatal("the formatted cell is not in the response") |
| +} |
| + |
| +func TestFormatRefusesWhatItCannotApply(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + |
| + for name, body := range map[string]string{ |
| + "unknown colour": `{"r1":0,"c1":0,"r2":0,"c2":0,"fill":"chartreuse"}`, |
| + "unknown alignment": `{"r1":0,"c1":0,"r2":0,"c2":0,"align":"justify"}`, |
| + "empty patch": `{"r1":0,"c1":0,"r2":0,"c2":0}`, |
| + "negative range": `{"r1":-1,"c1":0,"r2":0,"c2":0,"bold":true}`, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + rec := postFormat(t, app, cookies, s, body) |
| + if rec.Code != http.StatusBadRequest { |
| + t.Errorf("= %d, want 400: %s", rec.Code, rec.Body.String()) |
| + } |
| + // A refusal must not quote what it refused: a number format |
| + // and a colour name are authored content (AGENTS.md). |
| + if strings.Contains(rec.Body.String(), "chartreuse") || |
| + strings.Contains(rec.Body.String(), "justify") { |
| + t.Errorf("the refusal echoed the input: %s", rec.Body.String()) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestFormatRefusesAnOversizedRange(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + |
| + rec := postFormat(t, app, cookies, s, `{"r1":0,"c1":0,"r2":999,"c2":99,"bold":true}`) |
| + if rec.Code != http.StatusRequestEntityTooLarge { |
| + t.Errorf("= %d, want 413 — a hundred thousand per-cell writes in one request "+ |
| + "is a request that appears to hang", rec.Code) |
| + } |
| +} |
| + |
| +func TestFormatIsRefusedForAViewer(t *testing.T) { |
| + app, owner := signIn(t) |
| + s, _ := newSheet(t, app, owner, "Budget") |
| + guestOn(t, app, s.ID, "ada@elsewhere.example", SheetViewer) |
| + |
| + viewer := signInAs(t, app, "ada@elsewhere.example") |
| + rec := postFormat(t, app, viewer, s, `{"r1":0,"c1":0,"r2":0,"c2":0,"bold":true}`) |
| + // 404, never 403: anything else lets somebody enumerate the team's |
| + // sheets by status code. |
| + if rec.Code != http.StatusNotFound { |
| + t.Errorf("= %d, want 404", rec.Code) |
| + } |
| +} |
diff --git a/internal/sheets/handlers_grid.go b/internal/sheets/handlers_grid.go| index 712d5f4..5096f6b 100644 |
| --- a/internal/sheets/handlers_grid.go |
| +++ b/internal/sheets/handlers_grid.go |
| @@ -41,13 +41,13 @@ func (a *App) handleCellsGet(w http.ResponseWriter, r *http.Request) { |
| rows := clamp(intParam(r, "rows"), defaultWindowRows, maxWindowRows) |
| cols := clamp(intParam(r, "cols"), defaultWindowCols, maxWindowCols) |
| - cells, err := snapshot(h, newPosIndex(h), rows, cols) |
| + cells, styles, err := snapshot(h, newPosIndex(h), rows, cols) |
| if err != nil { |
| a.fail(w, r, err) |
| return |
| } |
| writeJSON(w, http.StatusOK, gridResponse{ |
| - Version: h.Version(), Rows: rows, Cols: cols, Cells: cells, |
| + Version: h.Version(), Rows: rows, Cols: cols, Cells: cells, Formats: styles, |
| }) |
| } |
| @@ -104,9 +104,11 @@ func (a *App) handleCellPost(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| + cells, styles := fromReport(rep, newPosIndex(h), h) |
| resp := gridResponse{ |
| Version: rep.Version, |
| - Cells: fromReport(rep, newPosIndex(h), h), |
| + Cells: cells, |
| + Formats: styles, |
| Resync: rep.Capped, |
| Client: in.Client, |
| } |
diff --git a/internal/sheets/handlers_history.go b/internal/sheets/handlers_history.go| index c3948d9..fb6bb24 100644 |
| --- a/internal/sheets/handlers_history.go |
| +++ b/internal/sheets/handlers_history.go |
| @@ -213,12 +213,7 @@ func (a *App) applyRaw(s SheetSummary, rowID, colID uint64, raw string, by Membe |
| if err != nil { |
| return err |
| } |
| - a.touchSheet(s.ID) |
| - a.publish(s.TabID, gridResponse{ |
| - Version: rep.Version, |
| - Cells: fromReport(rep, newPosIndex(h), h), |
| - Resync: rep.Capped, |
| - }) |
| + a.publishReport(s, h, rep) |
| return nil |
| } |
diff --git a/internal/sheets/handlers_table.go b/internal/sheets/handlers_table.go| index cc2a848..023672c 100644 |
| --- a/internal/sheets/handlers_table.go |
| +++ b/internal/sheets/handlers_table.go |
| @@ -195,12 +195,7 @@ func (a *App) handleTableWrite(w http.ResponseWriter, r *http.Request) { |
| http.Error(w, "That cell could not be written.", http.StatusBadRequest) |
| return |
| } |
| - a.touchSheet(s.ID) |
| - a.publish(s.TabID, gridResponse{ |
| - Version: rep.Version, |
| - Cells: fromReport(rep, newPosIndex(h), h), |
| - Resync: rep.Capped, |
| - }) |
| + a.publishReport(s, h, rep) |
| // 303 back to the page you were on, with no ?edit — a POST that |
| // renders resubmits on reload, and the reload would rewrite the |
diff --git a/internal/sheets/migrations/0008_alter_formats_ink_intent.sql b/internal/sheets/migrations/0008_alter_formats_ink_intent.sql| new file mode 100644 |
| index 0000000..9125682 |
| --- /dev/null |
| +++ b/internal/sheets/migrations/0008_alter_formats_ink_intent.sql |
| @@ -0,0 +1,2 @@ |
| +ALTER TABLE `formats` ADD `ink_intent_h` real; |
| +ALTER TABLE `formats` ADD `ink_intent_c` real; |
diff --git a/internal/sheets/palette.go b/internal/sheets/palette.go| new file mode 100644 |
| index 0000000..488982a |
| --- /dev/null |
| +++ b/internal/sheets/palette.go |
| @@ -0,0 +1,119 @@ |
| +package sheets |
| + |
| +// The fill and text-colour palette. |
| +// |
| +// **The server owns it, and the client picks by name.** A colour picker |
| +// that posts a hex would put an arbitrary sRGB literal into storage, and |
| +// this app's whole colour design is that a fill WE apply is stored as |
| +// hue and chroma with no lightness, so it can resolve against the |
| +// reader's theme (store/models.go). A hex has a lightness baked in; a |
| +// swatch name does not. Naming them here also means the two places that |
| +// need the list — the toolbar's buttons and the write endpoint's |
| +// validation — read the same one, rather than a Go map and a JavaScript |
| +// array that agree until somebody edits one. |
| +// |
| +// Hues are spread around the OKLCH circle rather than taken from a |
| +// brand palette: these are a DOCUMENT's colours, chosen by whoever is |
| +// writing the sheet, and they should not shift when the app's own |
| +// accent does. |
| + |
| +// swatch is one palette entry: an OKLCH hue in degrees and a chroma. |
| +// Lightness is deliberately absent — that is the whole point. See |
| +// store.Format's FillIntentH/FillIntentC. |
| +type swatch struct { |
| + // Name is the wire value and the stable id. Lowercase, because it |
| + // travels in JSON and a case difference would be a silent miss. |
| + Name string |
| + // Label is what a person reads on the button's accessible name. |
| + Label string |
| + H float64 |
| + C float64 |
| +} |
| + |
| +// fillChroma and inkChroma differ on purpose. |
| +// |
| +// A fill sits behind text and must not fight it, so it is soft. Ink IS |
| +// the text, so it can be saturated — at the same chroma as a fill it |
| +// reads as a mistake rather than a colour. One hue list, two chromas, |
| +// which is why these are constants rather than two hand-written tables |
| +// that could drift apart on the day somebody adds a hue. |
| +const ( |
| + fillChroma = 0.085 |
| + inkChroma = 0.135 |
| +) |
| + |
| +// hues is the ordered hue wheel both palettes are built from. |
| +// |
| +// Ordered, and the order is what the toolbar renders left to right: a |
| +// palette in spectrum order is one a person can aim at, where a palette |
| +// in alphabetical order is one they have to read. |
| +var hues = []struct { |
| + Name, Label string |
| + H float64 |
| +}{ |
| + {"red", "Red", 27}, |
| + {"orange", "Orange", 62}, |
| + {"yellow", "Yellow", 95}, |
| + {"green", "Green", 145}, |
| + {"teal", "Teal", 190}, |
| + {"blue", "Blue", 250}, |
| + {"violet", "Violet", 295}, |
| + {"magenta", "Magenta", 340}, |
| + // Grey is a hue with no chroma. It is in the list because "make |
| + // this row grey" is one of the two or three things anybody actually |
| + // wants from a fill palette, and leaving it out would push people |
| + // to a coloured swatch they did not mean. |
| + {"grey", "Grey", 250}, |
| +} |
| + |
| +// fillSwatches and inkSwatches are the two palettes, derived once. |
| +var ( |
| + fillSwatches = buildSwatches(fillChroma) |
| + inkSwatches = buildSwatches(inkChroma) |
| +) |
| + |
| +func buildSwatches(chroma float64) []swatch { |
| + out := make([]swatch, 0, len(hues)) |
| + for _, h := range hues { |
| + c := chroma |
| + if h.Name == "grey" { |
| + c = 0 |
| + } |
| + out = append(out, swatch{Name: h.Name, Label: h.Label, H: h.H, C: c}) |
| + } |
| + return out |
| +} |
| + |
| +// noColour is the wire value that CLEARS a fill or an ink. |
| +// |
| +// A distinct name rather than an empty string, because an empty string |
| +// is what a field absent from a JSON patch decodes to, and "the caller |
| +// did not mention fill" and "the caller asked for no fill" must not be |
| +// the same value. The patch struct uses pointers for the first |
| +// distinction; this constant carries the second. |
| +const noColour = "none" |
| + |
| +// lookupSwatch resolves a name in a palette. |
| +// |
| +// ok is false for a name the palette does not have, which the write |
| +// endpoint turns into a 400 rather than silently applying nothing: a |
| +// colour button that appears to work and does nothing is the worst |
| +// available failure, because the person has no reason to look again. |
| +func lookupSwatch(list []swatch, name string) (swatch, bool) { |
| + for _, s := range list { |
| + if s.Name == name { |
| + return s, true |
| + } |
| + } |
| + return swatch{}, false |
| +} |
| + |
| +// FillSwatches and InkSwatches expose the palettes to the templates. |
| +// |
| +// Methods on App rather than package-level template funcs, because the |
| +// template already receives the app's page data and adding a func map |
| +// entry for a constant list is a second mechanism for no gain. |
| +func (a *App) FillSwatches() []swatch { return fillSwatches } |
| + |
| +// InkSwatches is FillSwatches' twin — see it. |
| +func (a *App) InkSwatches() []swatch { return inkSwatches } |
diff --git a/internal/sheets/store/handle_formats.go b/internal/sheets/store/handle_formats.go| new file mode 100644 |
| index 0000000..58d4a8d |
| --- /dev/null |
| +++ b/internal/sheets/store/handle_formats.go |
| @@ -0,0 +1,102 @@ |
| +package store |
| + |
| +import "fmt" |
| + |
| +// Formats reads every formatted cell in the tab in two queries. |
| +// |
| +// Handle.Format answers for one cell with two round trips, which was |
| +// right while formatting was a thing only the XLSX importer wrote and |
| +// only the contrast check read. It stops being right the moment the |
| +// grid paints formats: snapshot walks a whole window, so a hundred |
| +// formatted cells became two hundred queries per page load — and the |
| +// cost is invisible in every test, because the tests all use tabs with |
| +// a handful of cells. |
| +// |
| +// Two queries and not one: the join would return the interned Format's |
| +// columns once per cell using it, which is exactly the duplication |
| +// interning exists to avoid. A column of 5,000 identically styled cells |
| +// is one Format row here, and this reads it once. |
| +// |
| +// The returned pointers are shared — several coordinates map to the |
| +// same *Format, because that is what interning means. Callers read |
| +// them; nothing mutates one in place. |
| +func (h *Handle) Formats() (map[CellCoord]*Format, error) { |
| + var cells []Cell |
| + if err := h.db.Select("row_id", "col_id", "format_id"). |
| + Where("tab_id = ? AND format_id IS NOT NULL", h.tabID). |
| + Find(&cells).Error; err != nil { |
| + return nil, fmt.Errorf("store: list formatted cells: %w", err) |
| + } |
| + if len(cells) == 0 { |
| + return map[CellCoord]*Format{}, nil |
| + } |
| + |
| + ids := make([]uint64, 0, len(cells)) |
| + seen := make(map[uint64]bool, len(cells)) |
| + for _, c := range cells { |
| + if c.FormatID == nil || seen[*c.FormatID] { |
| + continue |
| + } |
| + seen[*c.FormatID] = true |
| + ids = append(ids, *c.FormatID) |
| + } |
| + |
| + var rows []Format |
| + if err := h.db.Where("id IN ?", ids).Find(&rows).Error; err != nil { |
| + return nil, fmt.Errorf("store: read formats: %w", err) |
| + } |
| + byID := make(map[uint64]*Format, len(rows)) |
| + for i := range rows { |
| + byID[rows[i].ID] = &rows[i] |
| + } |
| + |
| + out := make(map[CellCoord]*Format, len(cells)) |
| + for _, c := range cells { |
| + // A cell pointing at a Format row that is not there is dropped |
| + // rather than defaulted. It cannot happen through any path this |
| + // package offers — formats are interned before they are |
| + // assigned and never deleted — and if it ever does, an unstyled |
| + // cell is a better answer than a styled one we invented. |
| + if c.FormatID == nil { |
| + continue |
| + } |
| + if f, ok := byID[*c.FormatID]; ok { |
| + out[CellCoord{RowID: c.RowID, ColID: c.ColID}] = f |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// FormatByID returns one interned Format, or nil. |
| +// |
| +// It exists for the write path, which knows the id it just interned and |
| +// wants the row back to echo to the client without re-deriving it. |
| +func (h *Handle) FormatByID(id uint64) *Format { |
| + if id == 0 { |
| + return nil |
| + } |
| + var f Format |
| + if err := h.db.First(&f, id).Error; err != nil { |
| + return nil |
| + } |
| + return &f |
| +} |
| + |
| +// FormatIDAt returns the Format id assigned to a cell, or 0. |
| +// |
| +// The id rather than the row, because the caller that needs this — a |
| +// format toggle merging into what a cell already has — needs to know |
| +// whether two cells share a format, and comparing ids answers that |
| +// without reading either row. |
| +func (h *Handle) FormatIDAt(rowID, colID uint64) uint64 { |
| + var cell Cell |
| + if err := h.db.Select("format_id"). |
| + Where("tab_id = ? AND row_id = ? AND col_id = ?", h.tabID, rowID, colID). |
| + First(&cell).Error; err != nil { |
| + return 0 |
| + } |
| + if cell.FormatID == nil { |
| + return 0 |
| + } |
| + return *cell.FormatID |
| +} |
diff --git a/internal/sheets/store/handle_grid.go b/internal/sheets/store/handle_grid.go| index 1b90513..d087dfe 100644 |
| --- a/internal/sheets/store/handle_grid.go |
| +++ b/internal/sheets/store/handle_grid.go |
| @@ -231,6 +231,14 @@ func formatDigest(f Format) string { |
| // the second cell silently adopts the first's colour. |
| sb.WriteString(f.InkHex) |
| sb.WriteByte('|') |
| + if f.InkIntentH != nil { |
| + sb.WriteString(strconv.FormatFloat(*f.InkIntentH, 'g', -1, 64)) |
| + } |
| + sb.WriteByte('|') |
| + if f.InkIntentC != nil { |
| + sb.WriteString(strconv.FormatFloat(*f.InkIntentC, 'g', -1, 64)) |
| + } |
| + sb.WriteByte('|') |
| sb.WriteString(f.Align) |
| sum := sha256.Sum256([]byte(sb.String())) |
| return hex.EncodeToString(sum[:]) |
diff --git a/internal/sheets/store/models.go b/internal/sheets/store/models.go| index b70b456..0faed88 100644 |
| --- a/internal/sheets/store/models.go |
| +++ b/internal/sheets/store/models.go |
| @@ -127,7 +127,19 @@ type Format struct { |
| // and the theme's own ink is used, which is a pairing this app |
| // controls and cannot get wrong. |
| InkHex string |
| - Align string |
| + // InkIntentH/InkIntentC are ink's hue and chroma with no lightness, |
| + // the same shape as FillIntentH/C and for the same reason: a colour |
| + // THIS app applied should follow the reader's light/dark choice, |
| + // where a colour an imported file authored must not. So a text |
| + // colour picked in the toolbar is stored as intent and resolved |
| + // per-theme, and InkHex stays what it has always been — the |
| + // author's literal, retained for lossless re-export. |
| + // |
| + // Nil means "no ink of ours"; the theme's own text colour is used, |
| + // which is a pairing this app controls and cannot get wrong. |
| + InkIntentH *float64 |
| + InkIntentC *float64 |
| + Align string |
| } |
| // Event is the append-only log: live-sync fan-out, version history, undo |