Skip to main content

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, class};
35use makeover_layout::{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    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]
107pub 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]
131pub 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)]
164pub 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
177impl<'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.
195fn 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]
229pub 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)]
249mod 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}