Skip to main content

max / makeover-webview

15.3 KB · 421 lines History Blame Raw
1 //! Column layout and row structure for lists and tables.
2 //!
3 //! The other half of phase B. [`form`](crate::form) renders a field; this
4 //! renders the frame a list of rows sits in: which columns exist, how wide they
5 //! are, which ones survive a narrow viewport, and the cell containers a row is
6 //! made of.
7 //!
8 //! # What this does not do
9 //!
10 //! It does not render a cell's contents. That is the crate's own limit, stated
11 //! in `makeover_layout`'s "Where the description stops": generate the boring
12 //! 80% so the bespoke 20% gets the attention. A goingson task row carries
13 //! delegated action hooks with argument substitution, four nested sub-renderers,
14 //! conditional state classes and aria labels built from data. A description
15 //! expressive enough to emit that is a templating language wearing a
16 //! description's name.
17 //!
18 //! So the split is the one [`Markup`] already draws for forms: this owns the
19 //! structure and the app owns what goes in it. What that removes from an app is
20 //! not small — cell order, cell classes, the grid tracks, and above all the
21 //! narrowing rules, which is where addressing columns by position goes wrong.
22 //!
23 //! # Why positions are the bug
24 //!
25 //! goingson hides its mobile columns with `nth-child(n+5)` against a
26 //! seven-column table, plus a separate `nth-child(3)`, plus two class-based
27 //! rules — the same fact said three ways, two of them positional. Insert a
28 //! column anywhere left of the cut and the wrong one disappears, silently,
29 //! because nothing in the stylesheet knows what column five *is*.
30 //! [`Priority`] is the fix: a renderer narrows by raising a cutoff, and never
31 //! by counting.
32
33 use crate::form::Markup;
34 use crate::{Emit, class};
35 use makeover_layout::{Column, Priority, RowPart, Width};
36 use std::fmt::Write as _;
37
38 /// The lengths the description deferred.
39 ///
40 /// [`Width`] says `Content`, `Fixed` or `Fill` and deliberately carries no
41 /// magnitude, because a magnitude is a CSS answer and the description is read
42 /// by renderers that have no pixels. So the numbers arrive here instead, the
43 /// way a field's value arrives in [`Filling`](crate::form::Filling) rather than
44 /// in `Field`.
45 ///
46 /// Looked up by column name, because an app's columns are not all one size:
47 /// goingson's task table has six distinct fixed widths.
48 #[derive(Debug, Clone, Copy, Default)]
49 pub struct Sizing<'a> {
50 /// `(column name, CSS length)`. The length is the track for a
51 /// [`Width::Fixed`] column and the floor for a [`Width::Fill`] one.
52 pub lengths: &'a [(&'a str, &'a str)],
53 /// Used for a column with no entry above. Empty means `auto`.
54 pub fallback: &'a str,
55 }
56
57 impl Sizing<'_> {
58 /// The length for a named column.
59 fn length_for(&self, name: &str) -> &str {
60 self.lengths
61 .iter()
62 .find(|(column, _)| *column == name)
63 .map_or_else(
64 || {
65 if self.fallback.is_empty() {
66 "auto"
67 } else {
68 self.fallback
69 }
70 },
71 |(_, length)| *length,
72 )
73 }
74
75 /// The grid track for one column.
76 fn track(&self, column: &Column<'_>) -> String {
77 match column.width {
78 Width::Content => "max-content".to_owned(),
79 Width::Fixed => self.length_for(column.name).to_owned(),
80 Width::Fill => format!("minmax({}, 1fr)", self.length_for(column.name)),
81 // A width added to the description since this renderer was built.
82 // `auto` is the track that makes no claim, which is the honest
83 // answer to a claim this renderer cannot read.
84 _ => "auto".to_owned(),
85 }
86 }
87 }
88
89 /// The class a cell of this column carries.
90 ///
91 /// Derived from the column's own name, which is what makes the narrowing rules
92 /// addressable. `data-column` would do as well; a class is what both webview
93 /// apps already key their cell styling on.
94 #[must_use]
95 pub fn column_class(column: &Column<'_>, opts: &Emit) -> String {
96 class(&format!("col-{}", column.name), opts)
97 }
98
99 /// The `grid-template-columns` value for the columns kept at `cutoff`.
100 ///
101 /// Emitting only the surviving tracks is what keeps the track list and the
102 /// hiding in agreement. An app that hides a cell with `display: none` but
103 /// leaves its track in place gets a column of empty space, which is the other
104 /// half of goingson's mobile bug: its narrow rule drops to four tracks by hand
105 /// and has to be edited in step with the `nth-child` cut.
106 #[must_use]
107 pub fn grid_template_columns(
108 columns: &[Column<'_>],
109 sizing: &Sizing<'_>,
110 cutoff: Priority,
111 ) -> String {
112 columns
113 .iter()
114 .filter(|column| column.kept_at(cutoff))
115 .map(|column| sizing.track(column))
116 .collect::<Vec<_>>()
117 .join(" ")
118 }
119
120 /// The rules that narrow `selector` to the columns kept at `cutoff`.
121 ///
122 /// Both halves together: the shortened track list, and `display: none` on each
123 /// dropped column *by its own class*. Nothing counts positions, so inserting a
124 /// column changes what is emitted rather than changing which column vanishes.
125 ///
126 /// `selector` may be a selector list. A descendant is appended to each part
127 /// rather than to the whole, because appending to the whole changes what the
128 /// earlier parts match: `.head, .row > .col-x` reads as "`.head`, or a `.col-x`
129 /// inside `.row`", so `.head` itself would be hidden.
130 #[must_use]
131 pub fn narrowing_css(
132 columns: &[Column<'_>],
133 selector: &str,
134 sizing: &Sizing<'_>,
135 cutoff: Priority,
136 opts: &Emit,
137 ) -> String {
138 let parts: Vec<&str> = selector.split(',').map(str::trim).collect();
139
140 let mut css = format!(
141 "{} {{\n grid-template-columns: {};\n}}\n",
142 parts.join(", "),
143 grid_template_columns(columns, sizing, cutoff)
144 );
145
146 for column in columns.iter().filter(|c| !c.kept_at(cutoff)) {
147 let class = column_class(column, opts);
148 let targets: Vec<String> = parts
149 .iter()
150 .map(|part| format!("{part} > .{class}"))
151 .collect();
152 let _ = write!(css, "{} {{\n display: none;\n}}\n", targets.join(",\n"));
153 }
154 css
155 }
156
157 /// One cell of a row.
158 ///
159 /// The contents are [`Markup`] rather than text, and that is the whole shape of
160 /// this module: a cell holds whatever the app builds, and the app says so by
161 /// naming it. Escaping a cell here would be wrong as well as impossible — a
162 /// task row's description cell is five nested spans and a badge.
163 #[derive(Debug, Clone, Copy)]
164 pub struct Cell<'a> {
165 /// Which column this fills, by name.
166 pub column: &'a str,
167 /// What kind of text it is, when it is text.
168 ///
169 /// Carries the row-part class the stylesheet half already emits, so a
170 /// secondary cell says it is secondary in the description's own words
171 /// rather than in the app's.
172 pub part: Option<RowPart>,
173 /// The contents. Trusted app markup.
174 pub content: Markup<'a>,
175 }
176
177 impl<'a> Cell<'a> {
178 /// A cell with no row part.
179 #[must_use]
180 pub const fn new(column: &'a str, content: Markup<'a>) -> Self {
181 Self {
182 column,
183 part: None,
184 content,
185 }
186 }
187 }
188
189 /// The class for a row part.
190 ///
191 /// Exhaustive, unlike the matches on [`Width`] and [`Priority`] above:
192 /// `RowPart` is the one vocabulary in this module that is still a closed enum.
193 /// If it ever gains a member this stops compiling, which is the same lockstep
194 /// break `non_exhaustive` was added elsewhere to end.
195 fn part_class(part: RowPart) -> &'static str {
196 match part {
197 RowPart::Primary => "row-primary",
198 RowPart::Secondary => "row-secondary",
199 RowPart::Meta => "row-meta",
200 RowPart::Actions => "row-actions",
201 }
202 }
203
204 /// A row's cells, in column order.
205 ///
206 /// Ordered by the columns and not by the cells, so a row cannot silently
207 /// disagree with its table about what comes where. A column with no cell gets
208 /// an empty container, which keeps the grid aligned; a cell naming no column is
209 /// dropped, because there is nowhere to put it.
210 ///
211 /// Emits the cells alone, not the row element. The row carries the app's
212 /// identity and hooks — `data-id`, a context-menu binding, a tabindex, its
213 /// state classes — and none of that is describable here.
214 ///
215 /// # Not for a webview's scroll path
216 ///
217 /// This has no consumer in either webview app, deliberately, and wiring one in
218 /// would be a mistake worth naming. goingson renders rows through a virtual
219 /// scroller whose `_render` calls its row builder **synchronously** while
220 /// scrolling; the code's own comment says scroll events fire at 60Hz+ and that
221 /// this is the hot path. Reaching Rust from there means an IPC round trip and
222 /// an `await` in that loop, per visible range, during a drag.
223 ///
224 /// So this is for the hosts where rendering already happens in Rust: an axum
225 /// route, and the router when it lands. There the objection does not apply,
226 /// because nothing crosses a process boundary to reach it. A webview app should
227 /// take [`narrowing_css`] and [`column_class`] and keep building its own rows.
228 #[must_use]
229 pub fn cells_html(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit) -> String {
230 let cell_class = class("cell", opts);
231 let mut html = String::new();
232
233 for column in columns {
234 let found = cells.iter().find(|cell| cell.column == column.name);
235 let mut classes = format!("{cell_class} {}", column_class(column, opts));
236 if let Some(part) = found.and_then(|cell| cell.part) {
237 let _ = write!(classes, " {}", class(part_class(part), opts));
238 }
239 let _ = write!(
240 html,
241 "<div class=\"{classes}\">{}</div>",
242 found.map_or("", |cell| cell.content.0)
243 );
244 }
245 html
246 }
247
248 #[cfg(test)]
249 mod tests {
250 use super::*;
251
252 fn columns() -> Vec<Column<'static>> {
253 vec![
254 Column {
255 name: "description",
256 width: Width::Fill,
257 priority: Priority::Essential,
258 },
259 Column {
260 name: "due",
261 width: Width::Fixed,
262 priority: Priority::Secondary,
263 },
264 Column {
265 name: "progress",
266 width: Width::Fixed,
267 priority: Priority::Optional,
268 },
269 ]
270 }
271
272 fn sizing() -> Sizing<'static> {
273 Sizing {
274 lengths: &[
275 ("description", "200px"),
276 ("due", "110px"),
277 ("progress", "100px"),
278 ],
279 fallback: "",
280 }
281 }
282
283 #[test]
284 fn a_fill_column_gets_a_floor_and_the_slack() {
285 let tracks = grid_template_columns(&columns(), &sizing(), Priority::Optional);
286 assert_eq!(tracks, "minmax(200px, 1fr) 110px 100px");
287 }
288
289 #[test]
290 fn a_column_with_no_length_makes_no_claim() {
291 let sizing = Sizing::default();
292 let tracks = grid_template_columns(&columns(), &sizing, Priority::Optional);
293 assert_eq!(tracks, "minmax(auto, 1fr) auto auto");
294 }
295
296 /// The point of the module. Raising the cutoff drops columns by what they
297 /// are worth, and the track list shortens to match, so the two cannot
298 /// disagree the way a hand-written `nth-child` cut and a hand-written
299 /// track list can.
300 #[test]
301 fn raising_the_cutoff_drops_columns_and_their_tracks_together() {
302 let columns = columns();
303
304 let wide = grid_template_columns(&columns, &sizing(), Priority::Optional);
305 assert_eq!(wide.split(' ').count(), 4); // minmax(200px, + 1fr) + 2
306
307 let narrow = grid_template_columns(&columns, &sizing(), Priority::Secondary);
308 assert_eq!(narrow, "minmax(200px, 1fr) 110px");
309
310 let narrowest = grid_template_columns(&columns, &sizing(), Priority::Essential);
311 assert_eq!(narrowest, "minmax(200px, 1fr)");
312 }
313
314 #[test]
315 fn narrowing_hides_a_dropped_column_by_its_own_class_not_its_position() {
316 let css = narrowing_css(
317 &columns(),
318 ".ui-mode-mobile .task-row",
319 &sizing(),
320 Priority::Secondary,
321 &Emit::default(),
322 );
323 assert!(
324 css.contains("grid-template-columns: minmax(200px, 1fr) 110px;"),
325 "{css}"
326 );
327 assert!(
328 css.contains(".ui-mode-mobile .task-row > .col-progress {"),
329 "{css}"
330 );
331 assert!(!css.contains("nth-child"), "{css}");
332 // The kept columns are not mentioned as hidden.
333 assert!(!css.contains(".col-due {\n display: none"), "{css}");
334 }
335
336 /// A selector list has to distribute, or the earlier parts of it get the
337 /// child combinator appended to the whole and start matching things they
338 /// never named. This hid an entire table header the first time it ran.
339 #[test]
340 fn a_selector_list_distributes_the_hidden_column() {
341 let css = narrowing_css(
342 &columns(),
343 ".task-header-row, .task-row",
344 &sizing(),
345 Priority::Secondary,
346 &Emit::default(),
347 );
348 assert!(
349 css.contains(".task-header-row > .col-progress,\n.task-row > .col-progress {"),
350 "{css}"
351 );
352 // The bare header selector must never appear as a hiding target.
353 assert!(
354 !css.contains(".task-header-row {\n display: none"),
355 "{css}"
356 );
357 assert!(
358 css.contains(".task-header-row, .task-row {\n grid-template-columns:"),
359 "{css}"
360 );
361 }
362
363 #[test]
364 fn cells_follow_the_columns_and_carry_their_column_class() {
365 let cells = [
366 Cell {
367 column: "due",
368 part: Some(RowPart::Meta),
369 content: Markup("tomorrow"),
370 },
371 Cell::new("description", Markup("<span>Ship it</span>")),
372 ];
373 let html = cells_html(&columns(), &cells, &Emit::default());
374
375 // Column order, not cell order: description was passed second.
376 let description = html.find("Ship it").expect("description cell");
377 let due = html.find("tomorrow").expect("due cell");
378 assert!(description < due, "{html}");
379
380 assert!(
381 html.contains(r#"<div class="cell col-description">"#),
382 "{html}"
383 );
384 assert!(
385 html.contains(r#"<div class="cell col-due row-meta">"#),
386 "{html}"
387 );
388 // progress had no cell, so it is present and empty rather than absent,
389 // or the grid would shift left by one.
390 assert!(
391 html.contains(r#"<div class="cell col-progress"></div>"#),
392 "{html}"
393 );
394 }
395
396 #[test]
397 fn a_cell_naming_no_column_is_dropped() {
398 let cells = [Cell::new("nonexistent", Markup("nowhere"))];
399 let html = cells_html(&columns(), &cells, &Emit::default());
400 assert!(!html.contains("nowhere"), "{html}");
401 }
402
403 #[test]
404 fn the_class_prefix_reaches_the_cells_and_the_narrowing() {
405 let opts = Emit {
406 class_prefix: "mk-",
407 ..Emit::default()
408 };
409 let cells = [Cell::new("due", Markup("x"))];
410 assert!(
411 cells_html(&columns(), &cells, &opts).contains("mk-cell mk-col-due"),
412 "prefix missing"
413 );
414 assert!(
415 narrowing_css(&columns(), ".t", &sizing(), Priority::Secondary, &opts)
416 .contains(".mk-col-progress"),
417 "prefix missing"
418 );
419 }
420 }
421