oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
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.
22type 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.
64func 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.
83type 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.
88func 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.
101type collector struct {
102 byCoord map[store.CellCoord]*store.Format
103 table styleTable
104}
105
106func 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.
111func (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).
129func (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.
136func (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.
150func (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