| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "strconv" |
| 5 | |
| 6 | "amadan.net/oficina/sheets/internal/sheets/store" |
| 7 | ) |
| 8 | |
| 9 | // wireStyle is one interned Format on the wire. |
| 10 | // |
| 11 | // Sent ONCE per response, in gridResponse.Formats, with each cell |
| 12 | // carrying only the id. That is not a size micro-optimisation: it is |
| 13 | // interning preserved across the wire. A column of 5,000 identically |
| 14 | // styled cells is one Format row in storage (store/models.go), and |
| 15 | // inlining the style per cell would un-intern it at the last step — |
| 16 | // 5,000 copies of the same seven fields, and 5,000 chances for the |
| 17 | // client to decide two of them differ. |
| 18 | // |
| 19 | // Short JSON names because this shape is repeated in every SSE frame, |
| 20 | // and the stream is the one place in this app where bytes are paid for |
| 21 | // per keystroke rather than per page load. |
| 22 | type wireStyle struct { |
| 23 | Bold bool `json:"b,omitempty"` |
| 24 | Italic bool `json:"i,omitempty"` |
| 25 | Align string `json:"a,omitempty"` |
| 26 | NumFmt string `json:"n,omitempty"` |
| 27 | |
| 28 | // FillHex and Ink are the AUTHOR'S literal colours, present only on |
| 29 | // a format that came from an imported file. They are painted as |
| 30 | // authored, in both themes, which is spec §11.1's rule and the |
| 31 | // promise the contrast notice already makes on this page: "shown |
| 32 | // exactly as the file wrote them". Correcting an imported yellow |
| 33 | // header to something theme-appropriate would lie about the |
| 34 | // document, and the app has already decided to warn instead. |
| 35 | FillHex string `json:"fx,omitempty"` |
| 36 | Ink string `json:"k,omitempty"` |
| 37 | |
| 38 | // FillH and FillC are a fill's OKLCH hue and chroma with no |
| 39 | // lightness, which is how a fill THIS app applied is stored |
| 40 | // (store/models.go: "lightness is chosen per-theme, not stored"). |
| 41 | // The client resolves them against --sheet-fill-l, so a fill a |
| 42 | // person picks here follows their light/dark choice instead of |
| 43 | // staying a light-mode swatch on a dark grid. |
| 44 | // |
| 45 | // Pointers, because 0 is a real hue (red) and a real chroma (grey). |
| 46 | FillH *float64 `json:"fh,omitempty"` |
| 47 | FillC *float64 `json:"fc,omitempty"` |
| 48 | |
| 49 | // InkH and InkC are ink's intent, FillH/FillC's twin. See |
| 50 | // store.Format.InkIntentH. |
| 51 | InkH *float64 `json:"kh,omitempty"` |
| 52 | InkC *float64 `json:"kc,omitempty"` |
| 53 | |
| 54 | // LC marks an imported fill+ink pair below WCAG AA's 4.5:1. |
| 55 | // |
| 56 | // contrast.go has computed this since import landed, and sheets.css |
| 57 | // has carried the corner flag that renders it since the same day — |
| 58 | // and nothing ever connected the two, so the rule matched no |
| 59 | // element and the page warned in prose about cells it did not mark. |
| 60 | LC bool `json:"lc,omitempty"` |
| 61 | } |
| 62 | |
| 63 | // styleOf converts a stored Format to its wire shape. |
| 64 | func styleOf(f *store.Format) wireStyle { |
| 65 | if f == nil { |
| 66 | return wireStyle{} |
| 67 | } |
| 68 | return wireStyle{ |
| 69 | Bold: f.Bold, Italic: f.Italic, Align: f.Align, NumFmt: f.NumberFmt, |
| 70 | FillHex: f.FillHex, Ink: f.InkHex, |
| 71 | FillH: f.FillIntentH, FillC: f.FillIntentC, |
| 72 | InkH: f.InkIntentH, InkC: f.InkIntentC, |
| 73 | LC: lowContrast(f), |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | // styleTable collects the distinct formats a response refers to. |
| 78 | // |
| 79 | // Keyed by the interned Format's own id rendered as a string, because |
| 80 | // JSON object keys are strings and a client that reads the id back as a |
| 81 | // number would have to remember to render it the same way again when it |
| 82 | // looks a cell's format up. |
| 83 | type styleTable map[string]wireStyle |
| 84 | |
| 85 | // fmtKey renders a format id for the wire. "" for the unformatted case, |
| 86 | // so a cell with no format omits the field entirely rather than |
| 87 | // carrying a zero that the client has to know means nothing. |
| 88 | func fmtKey(id uint64) string { |
| 89 | if id == 0 { |
| 90 | return "" |
| 91 | } |
| 92 | return strconv.FormatUint(id, 10) |
| 93 | } |
| 94 | |
| 95 | // collector accumulates the styles a response mentions. |
| 96 | // |
| 97 | // It exists so the two response builders — snapshot and fromReport — |
| 98 | // share one definition of "which formats does this batch need", rather |
| 99 | // than each growing its own loop that could diverge on the day one of |
| 100 | // them learns about a new field. |
| 101 | type collector struct { |
| 102 | byCoord map[store.CellCoord]*store.Format |
| 103 | table styleTable |
| 104 | } |
| 105 | |
| 106 | func newCollector(byCoord map[store.CellCoord]*store.Format) *collector { |
| 107 | return &collector{byCoord: byCoord, table: styleTable{}} |
| 108 | } |
| 109 | |
| 110 | // keyFor records the format at a coordinate and returns its wire key. |
| 111 | func (c *collector) keyFor(rowID, colID uint64) string { |
| 112 | f, ok := c.byCoord[store.CellCoord{RowID: rowID, ColID: colID}] |
| 113 | if !ok || f == nil { |
| 114 | return "" |
| 115 | } |
| 116 | key := fmtKey(f.ID) |
| 117 | if key == "" { |
| 118 | return "" |
| 119 | } |
| 120 | if _, seen := c.table[key]; !seen { |
| 121 | c.table[key] = styleOf(f) |
| 122 | } |
| 123 | return key |
| 124 | } |
| 125 | |
| 126 | // numFmtFor is the number format a cell's value should be displayed |
| 127 | // with — the one piece of a Format the SERVER still has to act on, |
| 128 | // because display strings are the server's to own (grid.go's display). |
| 129 | func (c *collector) numFmtFor(rowID, colID uint64) *store.Format { |
| 130 | return c.byCoord[store.CellCoord{RowID: rowID, ColID: colID}] |
| 131 | } |
| 132 | |
| 133 | // done returns the table, or nil when nothing in the batch was |
| 134 | // formatted — so an unformatted sheet's every response omits the field |
| 135 | // rather than carrying an empty object. |
| 136 | func (c *collector) done() styleTable { |
| 137 | if len(c.table) == 0 { |
| 138 | return nil |
| 139 | } |
| 140 | return c.table |
| 141 | } |
| 142 | |
| 143 | // publishReport touches the sheet and fans one write out to every other |
| 144 | // tab watching it. |
| 145 | // |
| 146 | // The two scriptless write paths — the table view's cell form and |
| 147 | // history's restore — did this identically, seven lines each, and the |
| 148 | // day formats joined the response both had to be edited in step. One |
| 149 | // place, so the next field added to a published batch is added once. |
| 150 | func (a *App) publishReport(s SheetSummary, h *store.Handle, rep store.Report) { |
| 151 | a.touchSheet(s.ID) |
| 152 | cells, styles := fromReport(rep, newPosIndex(h), h) |
| 153 | a.publish(s.TabID, gridResponse{ |
| 154 | Version: rep.Version, |
| 155 | Cells: cells, |
| 156 | Formats: styles, |
| 157 | Resync: rep.Capped, |
| 158 | Stamps: rep.Stamps, |
| 159 | }) |
| 160 | } |
| 161 | |