Skip to main content

max / makeover-webview

20.8 KB · 549 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::{CellPart, 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 class saying how wide a cell of this column asks to be.
100 ///
101 /// A bounded vocabulary, unlike [`column_class`], which is why the stylesheet
102 /// can carry the rule. [`Width`] is `#[non_exhaustive]`, and a member added
103 /// upstream lands on the fill class: a column that takes the slack is the
104 /// behaviour that makes no claim, matching the `auto` track
105 /// [`Sizing::track`] falls back to for the same reason.
106 fn width_class(width: Width) -> &'static str {
107 match width {
108 Width::Content => "cell-content",
109 Width::Fixed => "cell-fixed",
110 _ => "cell-fill",
111 }
112 }
113
114 /// The class saying when a cell of this column drops.
115 ///
116 /// [`Priority`] said as a class rather than as a cutoff, so the hiding can live
117 /// in the stylesheet instead of being generated per table. That is what a
118 /// [`display: table`](crate::table_rules) frame needs and a grid one cannot use:
119 /// a grid also has to shorten its track list, which only the columns themselves
120 /// can say.
121 fn drop_class(priority: Priority) -> &'static str {
122 match priority {
123 Priority::Optional => "cell-drops-first",
124 Priority::Secondary => "cell-drops-next",
125 // A priority added upstream keeps its column. `Priority` is
126 // `#[non_exhaustive]`, and of the two ways to be wrong about one this
127 // renderer has not learned, showing a column that should have dropped
128 // is the one the user can see and work around.
129 _ => "cell-keeps",
130 }
131 }
132
133 /// Every class a cell of this column carries.
134 ///
135 /// The column's own name, how wide it asks to be, and when it drops. A header
136 /// cell has to carry the same three or the header and the body disagree about
137 /// which column just disappeared, and a renderer emitting its own header row
138 /// should call this rather than assemble the list a second time.
139 #[must_use]
140 pub fn column_classes(column: &Column<'_>, opts: &Emit) -> String {
141 format!(
142 "{} {} {}",
143 column_class(column, opts),
144 class(width_class(column.width), opts),
145 class(drop_class(column.priority), opts)
146 )
147 }
148
149 /// The `grid-template-columns` value for the columns kept at `cutoff`.
150 ///
151 /// Emitting only the surviving tracks is what keeps the track list and the
152 /// hiding in agreement. An app that hides a cell with `display: none` but
153 /// leaves its track in place gets a column of empty space, which is the other
154 /// half of goingson's mobile bug: its narrow rule drops to four tracks by hand
155 /// and has to be edited in step with the `nth-child` cut.
156 #[must_use]
157 pub fn grid_template_columns(
158 columns: &[Column<'_>],
159 sizing: &Sizing<'_>,
160 cutoff: Priority,
161 ) -> String {
162 columns
163 .iter()
164 .filter(|column| column.kept_at(cutoff))
165 .map(|column| sizing.track(column))
166 .collect::<Vec<_>>()
167 .join(" ")
168 }
169
170 /// The rules that narrow `selector` to the columns kept at `cutoff`.
171 ///
172 /// Both halves together: the shortened track list, and `display: none` on each
173 /// dropped column *by its own class*. Nothing counts positions, so inserting a
174 /// column changes what is emitted rather than changing which column vanishes.
175 ///
176 /// `selector` may be a selector list. A descendant is appended to each part
177 /// rather than to the whole, because appending to the whole changes what the
178 /// earlier parts match: `.head, .row > .col-x` reads as "`.head`, or a `.col-x`
179 /// inside `.row`", so `.head` itself would be hidden.
180 #[must_use]
181 pub fn narrowing_css(
182 columns: &[Column<'_>],
183 selector: &str,
184 sizing: &Sizing<'_>,
185 cutoff: Priority,
186 opts: &Emit,
187 ) -> String {
188 let parts: Vec<&str> = selector.split(',').map(str::trim).collect();
189
190 let mut css = format!(
191 "{} {{\n grid-template-columns: {};\n}}\n",
192 parts.join(", "),
193 grid_template_columns(columns, sizing, cutoff)
194 );
195
196 for column in columns.iter().filter(|c| !c.kept_at(cutoff)) {
197 let class = column_class(column, opts);
198 let targets: Vec<String> = parts
199 .iter()
200 .map(|part| format!("{part} > .{class}"))
201 .collect();
202 let _ = write!(css, "{} {{\n display: none;\n}}\n", targets.join(",\n"));
203 }
204 css
205 }
206
207 /// One cell of a row.
208 ///
209 /// The contents are [`Markup`] rather than text, and that is the whole shape of
210 /// this module: a cell holds whatever the app builds, and the app says so by
211 /// naming it. Escaping a cell here would be wrong as well as impossible — a
212 /// task row's description cell is five nested spans and a badge.
213 #[derive(Debug, Clone, Copy)]
214 pub struct Cell<'a> {
215 /// Which column this fills, by name.
216 pub column: &'a str,
217 /// What the cell holds, when the whole cell is one thing.
218 ///
219 /// Carries the cell-part class the stylesheet half emits, so a cell that is
220 /// nothing but controls says so in the description's own words rather than
221 /// in the app's.
222 ///
223 /// This was `Option<RowPart>` until 0.25.0, which was the drift
224 /// `makeover-layout` 0.14.0 named: a table cell borrowing the list row's
225 /// vocabulary, because the table side had none. A row's parts answer a
226 /// different question (which of six emphases this run of text takes) from a
227 /// cell's (whether this is text, tokens, controls or a link).
228 ///
229 /// `None` for a cell mixing parts. A cell holding a value *and* a strip of
230 /// tokens *and* a control is three parts in one container, and each one
231 /// wears its own class inside — this field is for the single-part case,
232 /// where a wrapper span would say nothing the cell has not already said.
233 pub part: Option<CellPart>,
234 /// The contents. Trusted app markup.
235 pub content: Markup<'a>,
236 }
237
238 impl<'a> Cell<'a> {
239 /// A cell with no cell part.
240 #[must_use]
241 pub const fn new(column: &'a str, content: Markup<'a>) -> Self {
242 Self {
243 column,
244 part: None,
245 content,
246 }
247 }
248 }
249
250 /// The class for a row part.
251 ///
252 /// This comment used to say `RowPart` was the one closed enum left here, and
253 /// that gaining a member would stop this compiling — "the same lockstep break
254 /// `non_exhaustive` was added elsewhere to end". makeover-layout 0.9.0 ended
255 /// it: the enum gained [`RowPart::Tokens`] and `#[non_exhaustive]` in the same
256 /// release, so the prediction was paid off rather than waited for.
257 ///
258 /// The fallback is what that costs. A member added upstream lands here as a
259 /// bare `row-part` with no rule of its own, which is a thing rendering plainly
260 /// rather than a build that stops. Grep this function when adopting a new
261 /// makeover-layout.
262 ///
263 /// Public since 0.27.0. A row's parts are emitted by whoever builds the row
264 /// element, and that is not always this crate: `cells_html` emits a table's
265 /// cells, but a list row carries the app's identity and hooks, so a screen
266 /// renderer writes it. quasi-webview wrote this list out a second time to do
267 /// that, which made the obligation in the paragraph above land on a function
268 /// its author would not think to grep.
269 /// Every class [`part_class`] can return, including the fallback.
270 ///
271 /// Beside the match rather than derived from it, because a `match` over a
272 /// `#[non_exhaustive]` enum cannot be enumerated from outside. It carries the
273 /// same obligation the match does and a test below holds the two together, so
274 /// a new arm added without a new entry fails rather than silently narrowing
275 /// what a checker believes this crate can emit.
276 pub const ROW_PART_CLASSES: &[&str] = &[
277 "row-primary",
278 "row-secondary",
279 "row-meta",
280 "row-actions",
281 "row-tokens",
282 "row-proportion",
283 "row-part",
284 ];
285
286 /// Every class [`cell_part_class`] can return, including the fallback.
287 ///
288 /// See [`ROW_PART_CLASSES`] for why it is written out.
289 pub const CELL_PART_CLASSES: &[&str] = &[
290 "cell-value",
291 "cell-tokens",
292 "cell-actions",
293 "cell-link",
294 "cell-part",
295 ];
296
297 #[must_use]
298 pub fn part_class(part: RowPart) -> &'static str {
299 match part {
300 RowPart::Primary => "row-primary",
301 RowPart::Secondary => "row-secondary",
302 RowPart::Meta => "row-meta",
303 RowPart::Actions => "row-actions",
304 RowPart::Tokens => "row-tokens",
305 RowPart::Proportion => "row-proportion",
306 _ => "row-part",
307 }
308 }
309
310 /// The class for a cell part.
311 ///
312 /// [`part_class`]'s table half, added with `makeover-layout` 0.14.0's
313 /// [`CellPart`]. The fallback is there for the same reason and costs the same
314 /// thing: a member added upstream lands as a bare `cell-part` with no rule of
315 /// its own, rather than as a build that stops. Grep this function too when
316 /// adopting a new makeover-layout, and public since 0.27.0 for the reason
317 /// [`part_class`] is.
318 #[must_use]
319 pub fn cell_part_class(part: CellPart) -> &'static str {
320 match part {
321 CellPart::Value => "cell-value",
322 CellPart::Tokens => "cell-tokens",
323 CellPart::Actions => "cell-actions",
324 CellPart::Link => "cell-link",
325 _ => "cell-part",
326 }
327 }
328
329 /// A row's cells, in column order.
330 ///
331 /// Ordered by the columns and not by the cells, so a row cannot silently
332 /// disagree with its table about what comes where. A column with no cell gets
333 /// an empty container, which keeps the grid aligned; a cell naming no column is
334 /// dropped, because there is nowhere to put it.
335 ///
336 /// Emits the cells alone, not the row element. The row carries the app's
337 /// identity and hooks — `data-id`, a context-menu binding, a tabindex, its
338 /// state classes — and none of that is describable here.
339 ///
340 /// # Not for a webview's scroll path
341 ///
342 /// This has no consumer in either webview app, deliberately, and wiring one in
343 /// would be a mistake worth naming. goingson renders rows through a virtual
344 /// scroller whose `_render` calls its row builder **synchronously** while
345 /// scrolling; the code's own comment says scroll events fire at 60Hz+ and that
346 /// this is the hot path. Reaching Rust from there means an IPC round trip and
347 /// an `await` in that loop, per visible range, during a drag.
348 ///
349 /// So this is for the hosts where rendering already happens in Rust: an axum
350 /// route, and the router when it lands. There the objection does not apply,
351 /// because nothing crosses a process boundary to reach it. A webview app should
352 /// take [`narrowing_css`] and [`column_class`] and keep building its own rows.
353 #[must_use]
354 pub fn cells_html(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit) -> String {
355 let cell_class = class("cell", opts);
356 let mut html = String::new();
357
358 for column in columns {
359 let found = cells.iter().find(|cell| cell.column == column.name);
360 let mut classes = format!("{cell_class} {}", column_classes(column, opts));
361 if let Some(part) = found.and_then(|cell| cell.part) {
362 let _ = write!(classes, " {}", class(cell_part_class(part), opts));
363 }
364 let _ = write!(
365 html,
366 "<div class=\"{classes}\">{}</div>",
367 found.map_or("", |cell| cell.content.0)
368 );
369 }
370 html
371 }
372
373 #[cfg(test)]
374 mod tests {
375 use super::*;
376
377 fn columns() -> Vec<Column<'static>> {
378 vec![
379 Column {
380 width: Width::Fill,
381 priority: Priority::Essential,
382 ..Column::new("description")
383 },
384 Column {
385 width: Width::Fixed,
386 priority: Priority::Secondary,
387 ..Column::new("due")
388 },
389 Column {
390 width: Width::Fixed,
391 priority: Priority::Optional,
392 ..Column::new("progress")
393 },
394 ]
395 }
396
397 fn sizing() -> Sizing<'static> {
398 Sizing {
399 lengths: &[
400 ("description", "200px"),
401 ("due", "110px"),
402 ("progress", "100px"),
403 ],
404 fallback: "",
405 }
406 }
407
408 #[test]
409 fn a_fill_column_gets_a_floor_and_the_slack() {
410 let tracks = grid_template_columns(&columns(), &sizing(), Priority::Optional);
411 assert_eq!(tracks, "minmax(200px, 1fr) 110px 100px");
412 }
413
414 #[test]
415 fn a_column_with_no_length_makes_no_claim() {
416 let sizing = Sizing::default();
417 let tracks = grid_template_columns(&columns(), &sizing, Priority::Optional);
418 assert_eq!(tracks, "minmax(auto, 1fr) auto auto");
419 }
420
421 /// The point of the module. Raising the cutoff drops columns by what they
422 /// are worth, and the track list shortens to match, so the two cannot
423 /// disagree the way a hand-written `nth-child` cut and a hand-written
424 /// track list can.
425 #[test]
426 fn raising_the_cutoff_drops_columns_and_their_tracks_together() {
427 let columns = columns();
428
429 let wide = grid_template_columns(&columns, &sizing(), Priority::Optional);
430 assert_eq!(wide.split(' ').count(), 4); // minmax(200px, + 1fr) + 2
431
432 let narrow = grid_template_columns(&columns, &sizing(), Priority::Secondary);
433 assert_eq!(narrow, "minmax(200px, 1fr) 110px");
434
435 let narrowest = grid_template_columns(&columns, &sizing(), Priority::Essential);
436 assert_eq!(narrowest, "minmax(200px, 1fr)");
437 }
438
439 #[test]
440 fn narrowing_hides_a_dropped_column_by_its_own_class_not_its_position() {
441 let css = narrowing_css(
442 &columns(),
443 ".ui-mode-mobile .task-row",
444 &sizing(),
445 Priority::Secondary,
446 &Emit::default(),
447 );
448 assert!(
449 css.contains("grid-template-columns: minmax(200px, 1fr) 110px;"),
450 "{css}"
451 );
452 assert!(
453 css.contains(".ui-mode-mobile .task-row > .col-progress {"),
454 "{css}"
455 );
456 assert!(!css.contains("nth-child"), "{css}");
457 // The kept columns are not mentioned as hidden.
458 assert!(!css.contains(".col-due {\n display: none"), "{css}");
459 }
460
461 /// A selector list has to distribute, or the earlier parts of it get the
462 /// child combinator appended to the whole and start matching things they
463 /// never named. This hid an entire table header the first time it ran.
464 #[test]
465 fn a_selector_list_distributes_the_hidden_column() {
466 let css = narrowing_css(
467 &columns(),
468 ".task-header-row, .task-row",
469 &sizing(),
470 Priority::Secondary,
471 &Emit::default(),
472 );
473 assert!(
474 css.contains(".task-header-row > .col-progress,\n.task-row > .col-progress {"),
475 "{css}"
476 );
477 // The bare header selector must never appear as a hiding target.
478 assert!(
479 !css.contains(".task-header-row {\n display: none"),
480 "{css}"
481 );
482 assert!(
483 css.contains(".task-header-row, .task-row {\n grid-template-columns:"),
484 "{css}"
485 );
486 }
487
488 #[test]
489 fn cells_follow_the_columns_and_carry_their_column_class() {
490 let cells = [
491 Cell {
492 column: "due",
493 part: Some(CellPart::Value),
494 content: Markup("tomorrow"),
495 },
496 Cell::new("description", Markup("<span>Ship it</span>")),
497 ];
498 let html = cells_html(&columns(), &cells, &Emit::default());
499
500 // Column order, not cell order: description was passed second.
501 let description = html.find("Ship it").expect("description cell");
502 let due = html.find("tomorrow").expect("due cell");
503 assert!(description < due, "{html}");
504
505 // Three classes, not one: the column's own name, how wide it asks to
506 // be, and when it drops. The last two are what lets the stylesheet
507 // carry rules a described table cannot generate per table.
508 assert!(
509 html.contains(r#"<div class="cell col-description cell-fill cell-keeps">"#),
510 "{html}"
511 );
512 assert!(
513 html.contains(r#"<div class="cell col-due cell-fixed cell-drops-next cell-value">"#),
514 "{html}"
515 );
516 // progress had no cell, so it is present and empty rather than absent,
517 // or the grid would shift left by one.
518 assert!(
519 html.contains(r#"<div class="cell col-progress cell-fixed cell-drops-first"></div>"#),
520 "{html}"
521 );
522 }
523
524 #[test]
525 fn a_cell_naming_no_column_is_dropped() {
526 let cells = [Cell::new("nonexistent", Markup("nowhere"))];
527 let html = cells_html(&columns(), &cells, &Emit::default());
528 assert!(!html.contains("nowhere"), "{html}");
529 }
530
531 #[test]
532 fn the_class_prefix_reaches_the_cells_and_the_narrowing() {
533 let opts = Emit {
534 class_prefix: "mk-",
535 ..Emit::default()
536 };
537 let cells = [Cell::new("due", Markup("x"))];
538 assert!(
539 cells_html(&columns(), &cells, &opts).contains("mk-cell mk-col-due"),
540 "prefix missing"
541 );
542 assert!(
543 narrowing_css(&columns(), ".t", &sizing(), Priority::Secondary, &opts)
544 .contains(".mk-col-progress"),
545 "prefix missing"
546 );
547 }
548 }
549