Skip to main content

makeover_webview/
list.rs

1//! The classes a list's or a table's columns, rows and cells carry.
2//!
3//! The row and cell markup is drawn by the quasi hosts (`quasi-webview` for
4//! this renderer), which own the column and cell members of the vocabulary.
5//! What stays here is the naming those hosts write into markup and the
6//! generated stylesheet rules: a column's name as a class
7//! ([`push_column_name`]), the kind, part and flow classes, the cell wrapper
8//! ([`CELL_IN`]) and sizer ([`push_sizer`]), the `short-N`, `stacks-N` and
9//! `drop-*-N` ladders ([`SHORT_RUNGS`], [`STACKS_RUNGS`], [`DROP_RUNGS`]), and
10//! the class lists the vocabulary is checked against.
11//!
12//! # Why positions are the bug
13//!
14//! goingson hid its mobile columns with `nth-child(n+5)` against a
15//! seven-column table, plus a separate `nth-child(3)`, plus two class-based
16//! rules, the same fact said three ways, two of them positional. Insert a
17//! column anywhere left of the cut and the wrong one disappears, silently,
18//! because nothing in the stylesheet knows what column five *is*.
19//! [`Priority`](makeover_layout::Priority) is the fix: a renderer narrows by what a column is worth, as a
20//! shrink factor on the column's own class (one of [`CELL_DROP_CLASSES`]), and
21//! never by counting.
22
23use crate::form::Markup;
24use crate::{Emit, push_class};
25use makeover_layout::{CellPart, ColumnKind, Flow, Prominence};
26
27/// A column's name as the identifier half of its class.
28///
29/// # Why this is not escaping
30///
31/// The name is the one app-supplied string this crate puts in a class attribute
32/// rather than in text or an `aria-label`, so it may never go in raw. A column
33/// named `a" onclick="steal()` emitted
34///
35/// ```html
36/// <div class="cell col-a" onclick="steal() cell-fill cell-keeps">
37/// ```
38///
39/// which is a live event handler on every cell of that column. HTML escaping is
40/// the reflex and it is the wrong tool here, because a class is read twice: once
41/// by the HTML parser, which would decode `&quot;` back to a quote, and once by
42/// the CSS selector an app writes against it. An escaped name is safe in the
43/// attribute and unmatchable from the stylesheet, so the two halves would stop
44/// meeting, silently, the way every other defect this module's comments
45/// record did.
46///
47/// Reducing the name to identifier characters answers both. What comes out is a
48/// valid CSS identifier, so the selector matches, and it holds none of the five
49/// characters an attribute value can be ended with, so there is nothing to
50/// escape.
51///
52/// # What it changes for a name that was already fine
53///
54/// Nothing. Alphanumerics, `_` and `-` pass through, and every column name in
55/// the tree is made of those. A name that is *not* was already broken rather
56/// than merely unsafe: `Due date` emitted `col-Due date`, which the HTML parser
57/// reads as the two classes `col-Due` and `date`, and which a selector wrote as
58/// a descendant selector that matched neither. Both now agree on
59/// `col-Due-date`.
60///
61/// Alphanumeric in the Unicode sense, not the ASCII one. CSS identifiers admit
62/// everything from U+00A0 up, so a column named `Größe` keeps its name; folding
63/// it to `Gr--e` would collide with a neighbouring column for nothing.
64pub fn push_column_name(out: &mut String, name: &str) {
65    for ch in name.chars() {
66        // Substituted rather than dropped. Two columns called `a b` and `ab`
67        // are different columns, and dropping would give them one class and one
68        // set of narrowing rules between them.
69        if ch.is_alphanumeric() || ch == '_' || ch == '-' {
70            out.push(ch);
71        } else {
72            out.push('-');
73        }
74    }
75}
76
77/// The class saying what a column holds, or `None` for prose.
78///
79/// wiki `table-model`: the kind carries the look, so the class goes on every
80/// cell and on the heading, and the sheet rules each kind once. Text is the
81/// absence of a kind, as `cell-keeps` would be if a keep needed saying, so it
82/// writes nothing. A kind added upstream reads as text: plain, never missing.
83#[must_use]
84pub fn kind_class(kind: ColumnKind) -> Option<&'static str> {
85    match kind {
86        ColumnKind::Identifier => Some("kind-identifier"),
87        ColumnKind::Date => Some("kind-date"),
88        ColumnKind::Number => Some("kind-number"),
89        ColumnKind::Code => Some("kind-code"),
90        ColumnKind::Status => Some("kind-status"),
91        ColumnKind::Actions => Some("kind-actions"),
92        ColumnKind::Picture => Some("kind-picture"),
93        _ => None,
94    }
95}
96
97/// Every class [`kind_class`] can return.
98pub const KIND_CLASSES: &[&str] = &[
99    "kind-identifier",
100    "kind-date",
101    "kind-number",
102    "kind-code",
103    "kind-status",
104    "kind-actions",
105    "kind-picture",
106];
107
108/// The wrapper every cell and heading holds its contents in.
109///
110/// The column is a flex item that may close to nothing, and padding cannot
111/// shrink, so the padding lives on this and the column clips it. It is also
112/// what a droppable column hides when it is under its floor, and where the
113/// type goes, so a heading and the cells under it are the same box whatever
114/// each holds.
115pub const CELL_IN: &str = "cell-in";
116
117/// What a kept column is as wide as, when its floor cannot say.
118///
119/// A floor is a count of `ch`, and a column of controls is not drawn in `ch`.
120/// Measured on MNW's tables at 1440 and 420: a label's letters run 6 to 8px
121/// against the 9px rung while a button's padding and edge take 26px against the
122/// 18 that `quasi_router::screen::ACT_ROOM` allows, so "Add to cart" held 150px
123/// to draw 118 and "Remove" held 96 to draw 99. The first starved the name
124/// beside it to 32px at 420; the second clipped at every width. No constant
125/// fixes both, because the error is the face's and not the count's.
126///
127/// So the host that knows what a column holds writes a copy of it here, once
128/// per distinct run of controls, drawn in the row's own type and taking no
129/// height and no ink. A kept column holding one is as wide as its widest copy,
130/// which the browser measures in the face it draws, and every cell and the
131/// heading hold the same copies, so the column is one width down the table.
132/// Hidden rather than absent: a hidden box still has a width.
133///
134/// Write it with [`push_sizer`] and never by hand, so the wrapper the sheet
135/// rules and the one a host emits cannot be spelled apart.
136pub const CELL_SIZER: &str = "cell-sizer";
137
138/// A [`CELL_SIZER`] around `copies`, which go inside a cell's or a heading's
139/// [`CELL_IN`] after what it holds.
140///
141/// `copies` is the host's: one block per distinct run, each drawn with the
142/// classes and the element the real controls take, since an element rule an
143/// app writes for `button` reaches a copy only if it is one. Nothing in it may
144/// be a control anyone can reach: `aria-hidden` here and `visibility: hidden`
145/// in the sheet take it out of the accessibility tree and the tab order, and a
146/// copy carries no address. Empty writes nothing.
147///
148/// [`Markup`], because the copies are the host's markup and are written as-is.
149pub fn push_sizer(out: &mut String, copies: Markup<'_>, opts: &Emit) {
150    let Markup(copies) = copies;
151    if copies.is_empty() {
152        return;
153    }
154    out.push_str("<span class=\"");
155    push_class(out, CELL_SIZER, opts);
156    out.push_str("\" aria-hidden=\"true\">");
157    out.push_str(copies);
158    out.push_str("</span>");
159}
160
161/// The class a run of row parts takes when what it holds is badges.
162///
163/// A part's [`Prominence`] says how far forward it stands; what it holds is the
164/// node's to say, and the host reads it there. A run of badges, of controls or
165/// of a meter carries one of these in place of its prominence class: each of
166/// those draws its own colour, so a part-level intent under it would fight it.
167pub const ROW_TOKENS: &str = "row-tokens";
168
169/// The class a run of row parts takes when what it holds is controls.
170/// [`ROW_TOKENS`]' reason.
171pub const ROW_ACTIONS: &str = "row-actions";
172
173/// The class a run of row parts takes when what it holds is a meter.
174/// [`ROW_TOKENS`]' reason.
175pub const ROW_PROPORTION: &str = "row-proportion";
176
177/// Every class [`part_class`] can return, including the fallback, and the
178/// three a run takes for what it holds.
179///
180/// Beside the match rather than derived from it, because a `match` over a
181/// `#[non_exhaustive]` enum cannot be enumerated from outside. It carries the
182/// same obligation the match does and a test below holds the two together, so
183/// a new arm added without a new entry fails rather than silently narrowing
184/// what a checker believes this crate can emit.
185pub const ROW_PART_CLASSES: &[&str] = &[
186    "row-lead",
187    "row-support",
188    "row-quiet",
189    ROW_ACTIONS,
190    ROW_TOKENS,
191    ROW_PROPORTION,
192    "row-part",
193];
194
195/// Every class [`flow_class`] can return.
196///
197/// `Flow::Tight` has no class: one line is what a run already does, so a rule
198/// for it would restate the default on every part in every row. Only the tier
199/// that departs from it is named, which is also why a renderer emitting nothing
200/// for an unknown flow is correct rather than lossy.
201pub const FLOW_CLASSES: &[&str] = &["row-relaxed"];
202
203/// The classes a row inside a hierarchy carries.
204///
205/// Declared here because the generated sheet defines rules for them: the
206/// vocabulary has to say so, or the check that every emitted name has a rule
207/// cannot see them.
208pub const NESTING_CLASSES: &[&str] = &["row-nested", "row-branch", "row-disclose"];
209
210/// The class for a part's flow, if it needs one.
211///
212/// `None` for [`Flow::Tight`] and for any tier added upstream that this crate
213/// has not been taught, which lands as one line: the same trade
214/// [`part_class`]'s fallback makes, and the safe direction, since a part that
215/// grows without bound breaks the rows around it while a part that stays on one
216/// line only looks like the old rendering. Grep this when adopting a new
217/// makeover-layout.
218#[must_use]
219pub fn flow_class(flow: Flow) -> Option<&'static str> {
220    match flow {
221        Flow::Relaxed => Some("row-relaxed"),
222        _ => None,
223    }
224}
225
226/// Every class saying how wide a cell of a column asks to be, including the
227/// fill a column that makes no claim lands on.
228///
229/// See [`ROW_PART_CLASSES`] for why it is written out. Only two of the three
230/// carry a rule, a fill is what a cell does when the sheet says nothing,
231/// which is exactly why the list is here rather than being read off the
232/// generated CSS: `cell-fill` reached every table in the tree and the
233/// vocabulary named it nowhere.
234pub const CELL_WIDTH_CLASSES: &[&str] = &["cell-content", "cell-fixed", "cell-fill"];
235
236/// Every class saying when a cell of a column drops, including the tier that
237/// never does.
238///
239/// The class is a shrink factor: a table short of room takes the shortfall
240/// from the columns worth least, and nothing counts positions or names a
241/// breakpoint.
242///
243/// [`CELL_WIDTH_CLASSES`]' argument, one column property over: `cell-keeps` is
244/// the tier the narrowing never hides, so the sheet writes no rule for it and
245/// a scraped set cannot see it.
246pub const CELL_DROP_CLASSES: &[&str] = &["cell-drops-first", "cell-drops-next", "cell-keeps"];
247
248/// The rungs of the `short-N` ladder, in `rem`: the narrowest and widest table
249/// width it can say, and the step between two.
250///
251/// Two `rem` a step, so a table is marked short at most 32px before its
252/// essentials actually stop fitting, which is well inside the one gap of slack
253/// every floor already carries. A table needing more than the ceiling is wider
254/// than any pane the apps draw, and gets no class.
255pub const SHORT_RUNGS: (u16, u16, u16) = (8, 160, 2);
256
257/// The rungs of the `stacks-N` ladder, in `rem`: the width under which a
258/// table's rows stop being a grid and draw as stacked records.
259///
260/// The [`SHORT_RUNGS`] ladder, kept a name of its own because a table can carry
261/// a rung of each. One that folds its acts into a row menu is short below the
262/// width its essentials need with the acts in them, and stacks below the
263/// narrower width they need with the acts folded away; the host works out both
264/// from the same floors.
265pub const STACKS_RUNGS: (u16, u16, u16) = SHORT_RUNGS;
266
267/// How many `rem` of `tiles-N` rungs share one width query: a table carrying
268/// any rung up to the next multiple of this tiles below that multiple.
269///
270/// The tiles ladder takes whatever rung the host's floors land on, but every
271/// rung's tiled half is several rules, so a query per rung would carry the
272/// whole ladder in every sheet.
273pub const TILES_STEP: u16 = 16;
274const _: () = assert!(
275    STACKS_RUNGS.1.is_multiple_of(TILES_STEP),
276    "the widest tiles rung closes a group"
277);
278
279/// The rungs of the `drop-optional-N` and `drop-secondary-N` ladders, in
280/// `rem`: the width under which a table's optional, then its secondary,
281/// columns close outright.
282///
283/// The [`SHORT_RUNGS`] ladder again. A host puts the optional rung at every
284/// column's floor added up and the secondary rung at the floors of the
285/// essential and secondary columns, which is the budget the terminal and egui
286/// raise their cutoff by.
287pub const DROP_RUNGS: (u16, u16, u16) = SHORT_RUNGS;
288
289/// Every class [`cell_part_class`] can return, including the fallback.
290///
291/// See [`ROW_PART_CLASSES`] for why it is written out.
292pub const CELL_PART_CLASSES: &[&str] = &[
293    "cell-value",
294    "cell-tokens",
295    "cell-actions",
296    "cell-link",
297    "cell-part",
298];
299
300/// The class for a row part, by how far forward it stands.
301///
302/// [`Prominence`] is `#[non_exhaustive]`, so a member added upstream does not stop
303/// this compiling.
304///
305/// The fallback is what that costs. A member added upstream lands here as a
306/// bare `row-part`, which the sheet lets shrink like its siblings and otherwise
307/// leaves plain: a thing rendering plainly rather than a build that stops. Grep
308/// this function when adopting a new makeover-layout.
309///
310/// Public, because a row's parts are emitted by whoever builds the row element
311/// and that is not always the same host: a table's cells are one emitter, but a
312/// list row carries the app's identity and hooks, so a screen renderer writes
313/// it. A renderer that reimplements this list rather than calling it takes on
314/// the obligation above without knowing it.
315#[must_use]
316pub fn part_class(part: Prominence) -> &'static str {
317    match part {
318        Prominence::Lead => "row-lead",
319        Prominence::Support => "row-support",
320        Prominence::Quiet => "row-quiet",
321        _ => "row-part",
322    }
323}
324
325/// The class for a cell part.
326///
327/// [`part_class`]'s table half, for [`CellPart`]. The fallback is there for the
328/// same reason and costs the same thing: a member added upstream lands as a
329/// bare `cell-part` with no rule of its own, rather than as a build that stops.
330/// Grep this function too when adopting a new makeover-layout. Public for the
331/// reason [`part_class`] is.
332#[must_use]
333pub fn cell_part_class(part: CellPart) -> &'static str {
334    match part {
335        CellPart::Value => "cell-value",
336        CellPart::Tokens => "cell-tokens",
337        CellPart::Actions => "cell-actions",
338        CellPart::Link => "cell-link",
339        _ => "cell-part",
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    /// A sizer is one hidden wrapper around the host's copies, and no copies
348    /// writes nothing.
349    #[test]
350    fn a_sizer_wraps_its_copies_and_an_empty_one_writes_nothing() {
351        let opts = Emit::default();
352        let mut out = String::new();
353        push_sizer(&mut out, Markup(""), &opts);
354        assert_eq!(out, "");
355
356        push_sizer(&mut out, Markup("<button>Remove</button>"), &opts);
357        assert_eq!(
358            out,
359            "<span class=\"cell-sizer\" aria-hidden=\"true\"><button>Remove</button></span>"
360        );
361    }
362
363    #[test]
364    fn a_column_name_cannot_break_out_of_the_class_attribute() {
365        // A name written raw would emit
366        // `class="cell col-a" onclick="steal() cell-fill ...">`, a live
367        // handler on every cell of the column.
368        let mut out = String::new();
369        push_column_name(&mut out, "a\" onclick=\"steal()");
370        assert_eq!(out, "a--onclick--steal--");
371        assert!(!out.contains('"'), "{out}");
372    }
373
374    #[test]
375    fn the_class_and_the_selector_that_names_it_agree_on_the_name() {
376        // The reason the fix is a filter and not an escape: one class, not the
377        // two `col-Due date` parses as, and no entity a selector cannot match.
378        let mut out = String::new();
379        push_column_name(&mut out, "Due date");
380        assert_eq!(out, "Due-date");
381    }
382
383    #[test]
384    fn a_name_already_made_of_identifier_characters_is_untouched() {
385        for name in ["description", "due", "progress", "Name", "col_2", "a-b"] {
386            let mut out = String::new();
387            push_column_name(&mut out, name);
388            assert_eq!(out, name);
389        }
390    }
391
392    #[test]
393    fn a_name_outside_ascii_keeps_itself() {
394        // CSS identifiers admit everything from U+00A0 up, so folding these to
395        // dashes would collide two columns for nothing.
396        let mut out = String::new();
397        push_column_name(&mut out, "Größe");
398        assert_eq!(out, "Größe");
399    }
400}