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