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::{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    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.
106fn 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.
121fn 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]
140pub 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]
157pub 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]
181pub 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)]
214pub 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
238impl<'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#[must_use]
270pub fn part_class(part: RowPart) -> &'static str {
271    match part {
272        RowPart::Primary => "row-primary",
273        RowPart::Secondary => "row-secondary",
274        RowPart::Meta => "row-meta",
275        RowPart::Actions => "row-actions",
276        RowPart::Tokens => "row-tokens",
277        RowPart::Proportion => "row-proportion",
278        _ => "row-part",
279    }
280}
281
282/// The class for a cell part.
283///
284/// [`part_class`]'s table half, added with `makeover-layout` 0.14.0's
285/// [`CellPart`]. The fallback is there for the same reason and costs the same
286/// thing: a member added upstream lands as a bare `cell-part` with no rule of
287/// its own, rather than as a build that stops. Grep this function too when
288/// adopting a new makeover-layout, and public since 0.27.0 for the reason
289/// [`part_class`] is.
290#[must_use]
291pub fn cell_part_class(part: CellPart) -> &'static str {
292    match part {
293        CellPart::Value => "cell-value",
294        CellPart::Tokens => "cell-tokens",
295        CellPart::Actions => "cell-actions",
296        CellPart::Link => "cell-link",
297        _ => "cell-part",
298    }
299}
300
301/// A row's cells, in column order.
302///
303/// Ordered by the columns and not by the cells, so a row cannot silently
304/// disagree with its table about what comes where. A column with no cell gets
305/// an empty container, which keeps the grid aligned; a cell naming no column is
306/// dropped, because there is nowhere to put it.
307///
308/// Emits the cells alone, not the row element. The row carries the app's
309/// identity and hooks — `data-id`, a context-menu binding, a tabindex, its
310/// state classes — and none of that is describable here.
311///
312/// # Not for a webview's scroll path
313///
314/// This has no consumer in either webview app, deliberately, and wiring one in
315/// would be a mistake worth naming. goingson renders rows through a virtual
316/// scroller whose `_render` calls its row builder **synchronously** while
317/// scrolling; the code's own comment says scroll events fire at 60Hz+ and that
318/// this is the hot path. Reaching Rust from there means an IPC round trip and
319/// an `await` in that loop, per visible range, during a drag.
320///
321/// So this is for the hosts where rendering already happens in Rust: an axum
322/// route, and the router when it lands. There the objection does not apply,
323/// because nothing crosses a process boundary to reach it. A webview app should
324/// take [`narrowing_css`] and [`column_class`] and keep building its own rows.
325#[must_use]
326pub fn cells_html(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit) -> String {
327    let cell_class = class("cell", opts);
328    let mut html = String::new();
329
330    for column in columns {
331        let found = cells.iter().find(|cell| cell.column == column.name);
332        let mut classes = format!("{cell_class} {}", column_classes(column, opts));
333        if let Some(part) = found.and_then(|cell| cell.part) {
334            let _ = write!(classes, " {}", class(cell_part_class(part), opts));
335        }
336        let _ = write!(
337            html,
338            "<div class=\"{classes}\">{}</div>",
339            found.map_or("", |cell| cell.content.0)
340        );
341    }
342    html
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    fn columns() -> Vec<Column<'static>> {
350        vec![
351            Column {
352                width: Width::Fill,
353                priority: Priority::Essential,
354                ..Column::new("description")
355            },
356            Column {
357                width: Width::Fixed,
358                priority: Priority::Secondary,
359                ..Column::new("due")
360            },
361            Column {
362                width: Width::Fixed,
363                priority: Priority::Optional,
364                ..Column::new("progress")
365            },
366        ]
367    }
368
369    fn sizing() -> Sizing<'static> {
370        Sizing {
371            lengths: &[
372                ("description", "200px"),
373                ("due", "110px"),
374                ("progress", "100px"),
375            ],
376            fallback: "",
377        }
378    }
379
380    #[test]
381    fn a_fill_column_gets_a_floor_and_the_slack() {
382        let tracks = grid_template_columns(&columns(), &sizing(), Priority::Optional);
383        assert_eq!(tracks, "minmax(200px, 1fr) 110px 100px");
384    }
385
386    #[test]
387    fn a_column_with_no_length_makes_no_claim() {
388        let sizing = Sizing::default();
389        let tracks = grid_template_columns(&columns(), &sizing, Priority::Optional);
390        assert_eq!(tracks, "minmax(auto, 1fr) auto auto");
391    }
392
393    /// The point of the module. Raising the cutoff drops columns by what they
394    /// are worth, and the track list shortens to match, so the two cannot
395    /// disagree the way a hand-written `nth-child` cut and a hand-written
396    /// track list can.
397    #[test]
398    fn raising_the_cutoff_drops_columns_and_their_tracks_together() {
399        let columns = columns();
400
401        let wide = grid_template_columns(&columns, &sizing(), Priority::Optional);
402        assert_eq!(wide.split(' ').count(), 4); // minmax(200px, + 1fr) + 2
403
404        let narrow = grid_template_columns(&columns, &sizing(), Priority::Secondary);
405        assert_eq!(narrow, "minmax(200px, 1fr) 110px");
406
407        let narrowest = grid_template_columns(&columns, &sizing(), Priority::Essential);
408        assert_eq!(narrowest, "minmax(200px, 1fr)");
409    }
410
411    #[test]
412    fn narrowing_hides_a_dropped_column_by_its_own_class_not_its_position() {
413        let css = narrowing_css(
414            &columns(),
415            ".ui-mode-mobile .task-row",
416            &sizing(),
417            Priority::Secondary,
418            &Emit::default(),
419        );
420        assert!(
421            css.contains("grid-template-columns: minmax(200px, 1fr) 110px;"),
422            "{css}"
423        );
424        assert!(
425            css.contains(".ui-mode-mobile .task-row > .col-progress {"),
426            "{css}"
427        );
428        assert!(!css.contains("nth-child"), "{css}");
429        // The kept columns are not mentioned as hidden.
430        assert!(!css.contains(".col-due {\n    display: none"), "{css}");
431    }
432
433    /// A selector list has to distribute, or the earlier parts of it get the
434    /// child combinator appended to the whole and start matching things they
435    /// never named. This hid an entire table header the first time it ran.
436    #[test]
437    fn a_selector_list_distributes_the_hidden_column() {
438        let css = narrowing_css(
439            &columns(),
440            ".task-header-row, .task-row",
441            &sizing(),
442            Priority::Secondary,
443            &Emit::default(),
444        );
445        assert!(
446            css.contains(".task-header-row > .col-progress,\n.task-row > .col-progress {"),
447            "{css}"
448        );
449        // The bare header selector must never appear as a hiding target.
450        assert!(
451            !css.contains(".task-header-row {\n    display: none"),
452            "{css}"
453        );
454        assert!(
455            css.contains(".task-header-row, .task-row {\n    grid-template-columns:"),
456            "{css}"
457        );
458    }
459
460    #[test]
461    fn cells_follow_the_columns_and_carry_their_column_class() {
462        let cells = [
463            Cell {
464                column: "due",
465                part: Some(CellPart::Value),
466                content: Markup("tomorrow"),
467            },
468            Cell::new("description", Markup("<span>Ship it</span>")),
469        ];
470        let html = cells_html(&columns(), &cells, &Emit::default());
471
472        // Column order, not cell order: description was passed second.
473        let description = html.find("Ship it").expect("description cell");
474        let due = html.find("tomorrow").expect("due cell");
475        assert!(description < due, "{html}");
476
477        // Three classes, not one: the column's own name, how wide it asks to
478        // be, and when it drops. The last two are what lets the stylesheet
479        // carry rules a described table cannot generate per table.
480        assert!(
481            html.contains(r#"<div class="cell col-description cell-fill cell-keeps">"#),
482            "{html}"
483        );
484        assert!(
485            html.contains(r#"<div class="cell col-due cell-fixed cell-drops-next cell-value">"#),
486            "{html}"
487        );
488        // progress had no cell, so it is present and empty rather than absent,
489        // or the grid would shift left by one.
490        assert!(
491            html.contains(r#"<div class="cell col-progress cell-fixed cell-drops-first"></div>"#),
492            "{html}"
493        );
494    }
495
496    #[test]
497    fn a_cell_naming_no_column_is_dropped() {
498        let cells = [Cell::new("nonexistent", Markup("nowhere"))];
499        let html = cells_html(&columns(), &cells, &Emit::default());
500        assert!(!html.contains("nowhere"), "{html}");
501    }
502
503    #[test]
504    fn the_class_prefix_reaches_the_cells_and_the_narrowing() {
505        let opts = Emit {
506            class_prefix: "mk-",
507            ..Emit::default()
508        };
509        let cells = [Cell::new("due", Markup("x"))];
510        assert!(
511            cells_html(&columns(), &cells, &opts).contains("mk-cell mk-col-due"),
512            "prefix missing"
513        );
514        assert!(
515            narrowing_css(&columns(), ".t", &sizing(), Priority::Secondary, &opts)
516                .contains(".mk-col-progress"),
517            "prefix missing"
518        );
519    }
520}