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 "sort"
5
6 "amadan.net/oficina/sheets/internal/sheets/store"
7)
8
9// The list's live preview thumbnails.
10//
11// **The preview is built from the SHAPE of a sheet, never its
12// contents.** It reads which cells exist and what formats they carry —
13// two columns of the cells table and the interned formats they point at
14// — and it never opens a sealed value. That is not a shortcut, it is
15// the design: a preview that cannot read a cell cannot leak one, so the
16// one rule needs no argument here and no exemption. It also means
17// building one costs four indexed reads — rows, columns, cells, and the
18// interned formats those cells name — rather than opening a Handle and
19// recomputing a formula graph, which is what makes a list of twenty
20// sheets affordable at all.
21//
22// What it therefore shows is real and honest: where the data is, which
23// rows are formatted, what colour they are. What it cannot show is the
24// text, which at this size would be a grey smudge in any case — Docs
25// reached the same conclusion about its own line boxes.
26//
27// Shared with the Docs app by agreement: same 26x34 slot, same place in
28// the row, so the two file lists read as one product rather than as two
29// apps that both happen to have thumbnails.
30
31// The sampled window. Enough of the top-left to recognise a sheet by,
32// and no more: past about this the bars are under a pixel and the
33// preview stops being a picture of anything.
34const (
35 previewRows = 12
36 previewCols = 8
37)
38
39// previewCell is one mark in a thumbnail.
40//
41// Fill is a CSS colour to paint behind the bar, already resolved to
42// something a browser can read: an author's literal hex from an
43// imported file, or an oklch() that resolves the app's own hue and
44// chroma against the theme's lightness custom property. Empty for an
45// unfilled cell.
46type previewCell struct {
47 R, C int
48 Fill string
49 Bold bool
50}
51
52// preview is what a list row draws.
53//
54// Empty reports a sheet with nothing in the sampled window, which
55// renders as the empty bordered box rather than as a fabricated
56// picture. Docs does the same for an empty document, deliberately: a
57// preview that invents content is worse than one that admits there is
58// none.
59type preview struct {
60 Cells []previewCell
61 // Rows and Cols are the extent actually DRAWN, not the window
62 // sampled. A sheet four columns wide fills the thumbnail rather
63 // than drawing four specks in the corner of a box sized for eight —
64 // at 26 by 34 pixels a quarter-filled picture reads as a smudge,
65 // not as a small sheet. The sampled window still caps it, so a
66 // large sheet and a very large one look alike, which is honest:
67 // past that the bars are under a pixel either way.
68 Rows, Cols int
69 Empty bool
70}
71
72// buildPreview reads a tab's shape.
73//
74// Four indexed reads and no Handle: the row and column records give
75// position, the cells table gives presence and a format id, and the
76// formats table gives the rest. Nothing here can reach a sealed column.
77func (a *App) buildPreview(tabID uint64) preview {
78 if a.db == nil || tabID == 0 {
79 return preview{Empty: true}
80 }
81
82 // Positions first: the cells table keys on stable row and column
83 // ids, and a preview is a picture of a grid, so the ids have to
84 // become coordinates before anything can be drawn.
85 rowPos := a.rowPositions(tabID)
86 colPos := a.colPositions(tabID)
87 if len(rowPos) == 0 || len(colPos) == 0 {
88 return preview{Empty: true}
89 }
90
91 type cellRow struct {
92 RowID, ColID uint64
93 FormatID *uint64
94 }
95 var cells []cellRow
96 if err := a.db.Model(&store.Cell{}).
97 Select("row_id", "col_id", "format_id").
98 Where("tab_id = ?", tabID).
99 Find(&cells).Error; err != nil {
100 return preview{Empty: true}
101 }
102
103 seen := map[uint64]bool{}
104 ids := make([]uint64, 0, len(cells))
105 for _, c := range cells {
106 if c.FormatID == nil || seen[*c.FormatID] {
107 continue
108 }
109 seen[*c.FormatID] = true
110 ids = append(ids, *c.FormatID)
111 }
112 formats := a.previewFormats(ids)
113
114 out := make([]previewCell, 0, len(cells))
115 for _, c := range cells {
116 r, ok := rowPos[c.RowID]
117 if !ok || r >= previewRows {
118 continue
119 }
120 col, ok := colPos[c.ColID]
121 if !ok || col >= previewCols {
122 continue
123 }
124 pc := previewCell{R: r, C: col}
125 if c.FormatID != nil {
126 if f, ok := formats[*c.FormatID]; ok {
127 pc.Bold = f.Bold
128 pc.Fill = previewFill(f)
129 }
130 }
131 out = append(out, pc)
132 }
133 if len(out) == 0 {
134 return preview{Empty: true}
135 }
136 // Sorted so the markup is stable: two renders of an unchanged sheet
137 // must produce identical bytes, or the cache below is comparing
138 // timestamps to decide something that changes anyway.
139 sort.Slice(out, func(i, j int) bool {
140 if out[i].R != out[j].R {
141 return out[i].R < out[j].R
142 }
143 return out[i].C < out[j].C
144 })
145
146 maxR, maxC := 0, 0
147 for _, c := range out {
148 if c.R > maxR {
149 maxR = c.R
150 }
151 if c.C > maxC {
152 maxC = c.C
153 }
154 }
155 // Floors, so a single cell does not become one enormous bar filling
156 // the whole thumbnail — which would make a nearly empty sheet look
157 // like the fullest one in the list.
158 return preview{Cells: out, Rows: atLeast(maxR+1, 4), Cols: atLeast(maxC+1, 3)}
159}
160
161func atLeast(v, floor int) int {
162 if v < floor {
163 return floor
164 }
165 return v
166}
167
168// rowPositions and colPositions read a tab's stable ids as grid
169// coordinates.
170//
171// Two functions rather than one generic, because Row and Col are
172// structurally identical and semantically not: making them one
173// parameter of the same call is the kind of tidiness that later makes
174// somebody pass a row where a column belongs and get a picture that is
175// silently transposed.
176func (a *App) rowPositions(tabID uint64) map[uint64]int {
177 var rows []store.Row
178 if err := a.db.Select("id", "position").Where("tab_id = ?", tabID).
179 Find(&rows).Error; err != nil {
180 return nil
181 }
182 out := make(map[uint64]int, len(rows))
183 for _, r := range rows {
184 out[r.ID] = r.Position
185 }
186 return out
187}
188
189func (a *App) colPositions(tabID uint64) map[uint64]int {
190 var cols []store.Col
191 if err := a.db.Select("id", "position").Where("tab_id = ?", tabID).
192 Find(&cols).Error; err != nil {
193 return nil
194 }
195 out := make(map[uint64]int, len(cols))
196 for _, c := range cols {
197 out[c.ID] = c.Position
198 }
199 return out
200}
201
202// previewFormats reads exactly the interned formats a preview refers
203// to. Interned, so a column of identically styled cells is one row here
204// — reading them all would be reading the whole table for a picture
205// that uses two of its columns.
206func (a *App) previewFormats(ids []uint64) map[uint64]store.Format {
207 out := map[uint64]store.Format{}
208 if len(ids) == 0 {
209 return out
210 }
211 var rows []store.Format
212 if err := a.db.Select("id", "bold", "fill_hex", "fill_intent_h", "fill_intent_c").
213 Where("id IN ?", ids).Find(&rows).Error; err != nil {
214 return out
215 }
216 for _, f := range rows {
217 out[f.ID] = f
218 }
219 return out
220}
221
222// previewFill resolves a format's fill to a CSS colour, or "".
223//
224// The same two-case rule the grid paints by: an imported file's literal
225// is drawn as the file authored it, and a colour this app applied is
226// hue and chroma resolved against the theme's own lightness — so a
227// thumbnail follows light and dark the way the sheet it previews does.
228func previewFill(f store.Format) string {
229 if hex := safeHex(f.FillHex); hex != "" {
230 return hex
231 }
232 if f.FillIntentH == nil || f.FillIntentC == nil {
233 return ""
234 }
235 return "oklch(var(--sheet-fill-l) " +
236 trimFloat(*f.FillIntentC) + " " + trimFloat(*f.FillIntentH) + ")"
237}
238