| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 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. |
| 34 | const ( |
| 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. |
| 46 | type 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. |
| 59 | type 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. |
| 77 | func (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 | |
| 161 | func 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. |
| 176 | func (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 | |
| 189 | func (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. |
| 206 | func (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. |
| 228 | func 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 | |