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, each column's floor, and above all the
21//! narrowing, 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 what a column is worth, here as
31//! a shrink factor on the column's own class, and never by counting.
32
33use crate::form::Markup;
34use crate::{Emit, push_class};
35use makeover_layout::{CellPart, Column, ColumnKind, Flow, Priority, RowPart, Width};
36use std::fmt::Write as _;
37
38/// The class a cell of this column carries.
39///
40/// Derived from the column's own name, which is what makes the narrowing rules
41/// addressable. `data-column` would do as well; a class is what both webview
42/// apps already key their cell styling on.
43///
44/// The name is reduced to identifier characters first. See [`push_column_name`].
45#[must_use]
46pub fn column_class(column: &Column<'_>, opts: &Emit) -> String {
47    let mut out = String::new();
48    push_column_class(&mut out, column, opts);
49    out
50}
51
52/// The class a cell of this column carries, written into a buffer the caller
53/// already has.
54///
55/// [`column_class`]'s streaming form. It is the one that runs per cell per row,
56/// where the allocating form pays twice: once for `col-<name>` and once for the
57/// prefix in front of it.
58pub fn push_column_class(out: &mut String, column: &Column<'_>, opts: &Emit) {
59    out.push_str(opts.class_prefix);
60    out.push_str("col-");
61    push_column_name(out, column.name);
62}
63
64/// A column's name as the identifier half of its class.
65///
66/// # Why this is not escaping
67///
68/// The name is the one app-supplied string this crate puts in a class attribute
69/// rather than in text or an `aria-label`, so it may never go in raw. A column
70/// named `a" onclick="steal()` emitted
71///
72/// ```html
73/// <div class="cell col-a" onclick="steal() cell-fill cell-keeps">
74/// ```
75///
76/// which is a live event handler on every cell of that column. HTML escaping is
77/// the reflex and it is the wrong tool here, because a class is read twice: once
78/// by the HTML parser, which would decode `&quot;` back to a quote, and once by
79/// the CSS selector an app writes against it. An escaped name is safe in the
80/// attribute and unmatchable from the stylesheet, so the two halves would stop
81/// meeting -- silently, the way every other defect this module's comments
82/// record did.
83///
84/// Reducing the name to identifier characters answers both. What comes out is a
85/// valid CSS identifier, so the selector matches, and it holds none of the five
86/// characters an attribute value can be ended with, so there is nothing to
87/// escape.
88///
89/// # What it changes for a name that was already fine
90///
91/// Nothing. Alphanumerics, `_` and `-` pass through, and every column name in
92/// the tree is made of those. A name that is *not* was already broken rather
93/// than merely unsafe: `Due date` emitted `col-Due date`, which the HTML parser
94/// reads as the two classes `col-Due` and `date`, and which a selector wrote as
95/// a descendant selector that matched neither. Both now agree on
96/// `col-Due-date`.
97///
98/// Alphanumeric in the Unicode sense, not the ASCII one. CSS identifiers admit
99/// everything from U+00A0 up, so a column named `Größe` keeps its name; folding
100/// it to `Gr--e` would collide with a neighbouring column for nothing.
101pub fn push_column_name(out: &mut String, name: &str) {
102    for ch in name.chars() {
103        // Substituted rather than dropped. Two columns called `a b` and `ab`
104        // are different columns, and dropping would give them one class and one
105        // set of narrowing rules between them.
106        if ch.is_alphanumeric() || ch == '_' || ch == '-' {
107            out.push(ch);
108        } else {
109            out.push('-');
110        }
111    }
112}
113
114/// The class saying how wide a cell of this column asks to be.
115///
116/// A bounded vocabulary, unlike [`column_class`], which is why the stylesheet
117/// can carry the rule. [`Width`] is `#[non_exhaustive]`, and a member added
118/// upstream lands on the fill class: a column that takes the slack is the
119/// behaviour that makes no claim.
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 narrowing can
131/// live in the stylesheet instead of being generated per table. The class is a
132/// shrink factor: a table short of room takes the shortfall from the columns
133/// worth least, and nothing counts positions or names a breakpoint.
134fn drop_class(priority: Priority) -> &'static str {
135    match priority {
136        Priority::Optional => "cell-drops-first",
137        Priority::Secondary => "cell-drops-next",
138        // A priority added upstream keeps its column. `Priority` is
139        // `#[non_exhaustive]`, and of the two ways to be wrong about one this
140        // renderer has not learned, showing a column that should have dropped
141        // is the one the user can see and work around.
142        _ => "cell-keeps",
143    }
144}
145
146/// The class saying what a column holds, or `None` for prose.
147///
148/// wiki `table-model`: the kind carries the look, so the class goes on every
149/// cell and on the heading, and the sheet rules each kind once. Text is the
150/// absence of a kind, as `cell-keeps` would be if a keep needed saying, so it
151/// writes nothing. A kind added upstream reads as text: plain, never missing.
152#[must_use]
153pub fn kind_class(kind: ColumnKind) -> Option<&'static str> {
154    match kind {
155        ColumnKind::Identifier => Some("kind-identifier"),
156        ColumnKind::Date => Some("kind-date"),
157        ColumnKind::Number => Some("kind-number"),
158        ColumnKind::Code => Some("kind-code"),
159        ColumnKind::Status => Some("kind-status"),
160        ColumnKind::Actions => Some("kind-actions"),
161        _ => None,
162    }
163}
164
165/// Every class [`kind_class`] can return.
166pub const KIND_CLASSES: &[&str] = &[
167    "kind-identifier",
168    "kind-date",
169    "kind-number",
170    "kind-code",
171    "kind-status",
172    "kind-actions",
173];
174
175/// Every class a cell of this column carries.
176///
177/// The column's own name, how wide it asks to be, when it drops, what it holds
178/// and its floor as a rung of the `min-N` ladder. A header cell has to carry the
179/// same classes or the header and the body disagree about which column just
180/// closed and how wide the rest are, and a renderer emitting its own header row
181/// should call this rather than assemble the list a second time.
182#[must_use]
183pub fn column_classes(column: &Column<'_>, opts: &Emit) -> String {
184    let mut out = String::new();
185    push_column_classes(&mut out, column, opts);
186    out
187}
188
189/// Every class a cell of this column carries, written into a buffer the caller
190/// already has.
191///
192/// [`column_classes`]'s streaming form, and four allocations fewer per cell: the
193/// three names and the string joining them.
194pub fn push_column_classes(out: &mut String, column: &Column<'_>, opts: &Emit) {
195    push_column_class(out, column, opts);
196    out.push(' ');
197    push_class(out, width_class(column.width), opts);
198    out.push(' ');
199    push_class(out, drop_class(column.priority), opts);
200    if let Some(kind) = kind_class(column.kind) {
201        out.push(' ');
202        push_class(out, kind, opts);
203    }
204    out.push(' ');
205    push_class(out, "min-", opts);
206    let _ = write!(out, "{}", column.floor());
207}
208
209/// The wrapper every cell and heading holds its contents in.
210///
211/// The column is a flex item that may close to nothing, and padding cannot
212/// shrink, so the padding lives on this and the column clips it. It is also
213/// what a droppable column hides when it is under its floor, and where the
214/// type goes, so a heading and the cells under it are the same box whatever
215/// each holds.
216pub const CELL_IN: &str = "cell-in";
217
218/// One cell of a row.
219///
220/// The contents are [`Markup`] rather than text, and that is the whole shape of
221/// this module: a cell holds whatever the app builds, and the app says so by
222/// naming it. Escaping a cell here would be wrong as well as impossible — a
223/// task row's description cell is five nested spans and a badge.
224#[derive(Debug, Clone, Copy)]
225pub struct Cell<'a> {
226    /// Which column this fills, by name.
227    pub column: &'a str,
228    /// What the cell holds, when the whole cell is one thing.
229    ///
230    /// Carries the cell-part class the stylesheet half emits, so a cell that is
231    /// nothing but controls says so in the description's own words rather than
232    /// in the app's.
233    ///
234    /// `Option<CellPart>` and never `Option<RowPart>`: a table cell borrowing
235    /// the list row's vocabulary is drift. A row's parts answer a different
236    /// question (which of six emphases this run of text takes) from a
237    /// cell's (whether this is text, tokens, controls or a link).
238    ///
239    /// `None` for a cell mixing parts. A cell holding a value *and* a strip of
240    /// tokens *and* a control is three parts in one container, and each one
241    /// wears its own class inside — this field is for the single-part case,
242    /// where a wrapper span would say nothing the cell has not already said.
243    pub part: Option<CellPart>,
244    /// The contents. Trusted app markup.
245    pub content: Markup<'a>,
246}
247
248impl<'a> Cell<'a> {
249    /// A cell with no cell part.
250    #[must_use]
251    pub const fn new(column: &'a str, content: Markup<'a>) -> Self {
252        Self {
253            column,
254            part: None,
255            content,
256        }
257    }
258}
259
260/// The class for a row part.
261///
262/// [`RowPart`] is `#[non_exhaustive]`, so a member added upstream does not stop
263/// this compiling.
264///
265/// The fallback is what that costs. A member added upstream lands here as a
266/// bare `row-part` with no rule of its own, which is a thing rendering plainly
267/// rather than a build that stops. Grep this function when adopting a new
268/// makeover-layout.
269///
270/// Public, because a row's parts are emitted by whoever builds the row element
271/// and that is not always this crate: `cells_html` emits a table's cells, but a
272/// list row carries the app's identity and hooks, so a screen renderer writes
273/// it. A renderer that reimplements this list rather than calling it takes on
274/// the obligation above without knowing it.
275/// Every class [`part_class`] can return, including the fallback.
276///
277/// Beside the match rather than derived from it, because a `match` over a
278/// `#[non_exhaustive]` enum cannot be enumerated from outside. It carries the
279/// same obligation the match does and a test below holds the two together, so
280/// a new arm added without a new entry fails rather than silently narrowing
281/// what a checker believes this crate can emit.
282pub const ROW_PART_CLASSES: &[&str] = &[
283    "row-primary",
284    "row-secondary",
285    "row-meta",
286    "row-actions",
287    "row-tokens",
288    "row-proportion",
289    "row-part",
290];
291
292/// Every class [`flow_class`] can return.
293///
294/// `Flow::Tight` has no class: one line is what a run already does, so a rule
295/// for it would restate the default on every part in every row. Only the tier
296/// that departs from it is named, which is also why a renderer emitting nothing
297/// for an unknown flow is correct rather than lossy.
298pub const FLOW_CLASSES: &[&str] = &["row-relaxed"];
299
300/// The classes a row inside a hierarchy carries.
301///
302/// Declared here because the generated sheet defines rules for them: the
303/// vocabulary has to say so, or the check that every emitted name has a rule
304/// cannot see them.
305pub const NESTING_CLASSES: &[&str] = &["row-nested", "row-branch", "row-disclose"];
306
307/// The class for a part's flow, if it needs one.
308///
309/// `None` for [`Flow::Tight`] and for any tier added upstream that this crate
310/// has not been taught, which lands as one line: the same trade
311/// [`part_class`]'s fallback makes, and the safe direction, since a part that
312/// grows without bound breaks the rows around it while a part that stays on one
313/// line only looks like the old rendering. Grep this when adopting a new
314/// makeover-layout.
315#[must_use]
316pub fn flow_class(flow: Flow) -> Option<&'static str> {
317    match flow {
318        Flow::Relaxed => Some("row-relaxed"),
319        _ => None,
320    }
321}
322
323/// Every class [`width_class`](fn@width_class) can return, including the
324/// fallback.
325///
326/// See [`ROW_PART_CLASSES`] for why it is written out. Only two of the three
327/// carry a rule -- a fill is what a cell does when the sheet says nothing --
328/// which is exactly why the list is here rather than being read off the
329/// generated CSS: `cell-fill` reached every table in the tree and the
330/// vocabulary named it nowhere.
331pub const CELL_WIDTH_CLASSES: &[&str] = &["cell-content", "cell-fixed", "cell-fill"];
332
333/// Every class [`drop_class`](fn@drop_class) can return, including the
334/// fallback.
335///
336/// [`CELL_WIDTH_CLASSES`]' argument, one column property over: `cell-keeps` is
337/// the tier the narrowing never hides, so the sheet writes no rule for it and
338/// a scraped set cannot see it.
339pub const CELL_DROP_CLASSES: &[&str] = &["cell-drops-first", "cell-drops-next", "cell-keeps"];
340
341/// Every class [`cell_part_class`] can return, including the fallback.
342///
343/// See [`ROW_PART_CLASSES`] for why it is written out.
344pub const CELL_PART_CLASSES: &[&str] = &[
345    "cell-value",
346    "cell-tokens",
347    "cell-actions",
348    "cell-link",
349    "cell-part",
350];
351
352#[must_use]
353pub fn part_class(part: RowPart) -> &'static str {
354    match part {
355        RowPart::Primary => "row-primary",
356        RowPart::Secondary => "row-secondary",
357        RowPart::Meta => "row-meta",
358        RowPart::Actions => "row-actions",
359        RowPart::Tokens => "row-tokens",
360        RowPart::Proportion => "row-proportion",
361        _ => "row-part",
362    }
363}
364
365/// The class for a cell part.
366///
367/// [`part_class`]'s table half, for [`CellPart`]. The fallback is there for the
368/// same reason and costs the same thing: a member added upstream lands as a
369/// bare `cell-part` with no rule of its own, rather than as a build that stops.
370/// Grep this function too when adopting a new makeover-layout. Public for the
371/// reason [`part_class`] is.
372#[must_use]
373pub fn cell_part_class(part: CellPart) -> &'static str {
374    match part {
375        CellPart::Value => "cell-value",
376        CellPart::Tokens => "cell-tokens",
377        CellPart::Actions => "cell-actions",
378        CellPart::Link => "cell-link",
379        _ => "cell-part",
380    }
381}
382
383/// A row's cells, in column order.
384///
385/// Ordered by the columns and not by the cells, so a row cannot silently
386/// disagree with its table about what comes where. A column with no cell gets
387/// an empty container, which keeps the grid aligned; a cell naming no column is
388/// dropped, because there is nowhere to put it.
389///
390/// Emits the cells alone, not the row element. The row carries the app's
391/// identity and hooks — `data-id`, a context-menu binding, a tabindex, its
392/// state classes — and none of that is describable here.
393///
394/// # Not for a webview's scroll path
395///
396/// This has no consumer in either webview app, deliberately, and wiring one in
397/// would be a mistake worth naming. goingson renders rows through a virtual
398/// scroller whose `_render` calls its row builder **synchronously** while
399/// scrolling; the code's own comment says scroll events fire at 60Hz+ and that
400/// this is the hot path. Reaching Rust from there means an IPC round trip and
401/// an `await` in that loop, per visible range, during a drag.
402///
403/// So this is for the hosts where rendering already happens in Rust: an axum
404/// route, and the router when it lands. There the objection does not apply,
405/// because nothing crosses a process boundary to reach it. A webview app should
406/// take [`column_classes`] and [`CELL_IN`] and keep building its own rows.
407#[must_use]
408pub fn cells_html(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit) -> String {
409    let mut html = String::new();
410    cells_html_into(columns, cells, opts, &mut html);
411    html
412}
413
414/// A row's cells, written into a buffer the caller already has.
415///
416/// [`cells_html`]'s streaming form, byte-identical to it, and the one a host
417/// rendering a table should call: a row is emitted once per row per render, so
418/// this is where a `String` per cell class is paid for most often.
419pub fn cells_html_into(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit, out: &mut String) {
420    emit_cells(columns, cells, opts, out, None);
421}
422
423/// Where one column's markup went, and which of it the cell wrote.
424///
425/// Two ranges because two different things vary, and a caller compiling this
426/// into a template needs to say which it means.
427///
428/// `block` is the whole `<div>`: the column emits one per column whether or not
429/// a cell answers it, so a caller replacing a cell with a different one replaces
430/// the block, class and all.
431///
432/// `content` is what the cell itself contributed. A cell that is absent leaves
433/// the block standing and empty, so a caller compiling "this cell is there or
434/// is not" must cover the content alone -- covering the block would delete a
435/// `<div>` the row still draws, and the row would lose a column.
436#[derive(Clone, Debug, PartialEq, Eq)]
437pub struct Placed {
438    /// The whole column block, opening tag to closing tag.
439    pub block: core::ops::Range<usize>,
440    /// What the cell wrote inside it, empty range and all.
441    pub content: core::ops::Range<usize>,
442}
443
444/// A row's cells, saying where each column's block landed.
445///
446/// Byte-identical to [`cells_html_into`], and it appends one entry to `placed`
447/// per column, in column order: the offsets in `out` between which that
448/// column's whole `<div>` was written.
449///
450/// # Who this is for
451///
452/// A caller compiling a described screen into a template, which has to know
453/// which bytes of a row a particular cell produced. Finding that out by
454/// searching the output for the content is the thing it exists to avoid: a
455/// cell's markup may appear twice in a row, and a cell that renders to nothing
456/// cannot be searched for at all. So the writer says where it wrote, which is
457/// the only source that cannot be wrong.
458///
459/// Nothing about the markup changes, and a caller not compiling anything should
460/// call [`cells_html_into`] and pay nothing for this.
461pub fn cells_html_placed(
462    columns: &[Column<'_>],
463    cells: &[Cell<'_>],
464    opts: &Emit,
465    out: &mut String,
466    placed: &mut Vec<Placed>,
467) {
468    emit_cells(columns, cells, opts, out, Some(placed));
469}
470
471fn emit_cells(
472    columns: &[Column<'_>],
473    cells: &[Cell<'_>],
474    opts: &Emit,
475    out: &mut String,
476    mut placed: Option<&mut Vec<Placed>>,
477) {
478    for column in columns {
479        let at = out.len();
480        let found = cells.iter().find(|cell| cell.column == column.name);
481        out.push_str("<div class=\"");
482        push_class(out, "cell", opts);
483        out.push(' ');
484        push_column_classes(out, column, opts);
485        if let Some(part) = found.and_then(|cell| cell.part) {
486            out.push(' ');
487            push_class(out, cell_part_class(part), opts);
488        }
489        out.push_str("\"><span class=\"");
490        push_class(out, CELL_IN, opts);
491        out.push_str("\">");
492        let content = out.len();
493        out.push_str(found.map_or("", |cell| cell.content.0));
494        let wrote = content..out.len();
495        out.push_str("</span></div>");
496        if let Some(placed) = placed.as_deref_mut() {
497            placed.push(Placed {
498                block: at..out.len(),
499                content: wrote,
500            });
501        }
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    /// The placed form writes the same bytes, and says where each one went.
510    ///
511    /// Both halves matter. If the two ever disagreed, a compiled screen would
512    /// be built against markup nobody serves; if a range were off by a byte, it
513    /// would cut a tag in half.
514    #[test]
515    fn saying_where_a_cell_landed_does_not_change_what_is_written() {
516        let columns = [
517            Column {
518                name: "Name",
519                ..Column::new("Name")
520            },
521            Column {
522                name: "Price",
523                ..Column::new("Price")
524            },
525        ];
526        let cells = [
527            Cell {
528                column: "Name",
529                part: Some(CellPart::Value),
530                content: Markup("Kick"),
531            },
532            Cell {
533                column: "Price",
534                part: None,
535                content: Markup("<b>Free</b>"),
536            },
537        ];
538        let opts = Emit::default();
539
540        let mut plain = String::new();
541        cells_html_into(&columns, &cells, &opts, &mut plain);
542
543        let mut said = String::from("before:");
544        let mut placed = Vec::new();
545        cells_html_placed(&columns, &cells, &opts, &mut said, &mut placed);
546
547        assert_eq!(said.strip_prefix("before:").unwrap(), plain);
548        assert_eq!(placed.len(), columns.len());
549        assert_eq!(placed[0].block.start, "before:".len());
550        assert_eq!(placed[1].block.end, said.len());
551        for one in &placed {
552            let block = &said[one.block.clone()];
553            assert!(block.starts_with("<div class=\""), "{block}");
554            assert!(block.ends_with("</div>"), "{block}");
555        }
556
557        // The content is what the cell wrote and nothing the column wrote, so
558        // it is the half a caller covers when the cell may be absent: the
559        // block stands either way.
560        assert_eq!(&said[placed[0].content.clone()], "Kick");
561        assert_eq!(&said[placed[1].content.clone()], "<b>Free</b>");
562        for one in &placed {
563            assert!(one.block.start < one.content.start);
564            assert!(one.content.end < one.block.end);
565        }
566
567        // A column no cell answers still draws its block, and the content it
568        // reports is the empty range inside it. That is the case the whole
569        // split exists for.
570        let mut none = String::new();
571        let mut empty = Vec::new();
572        cells_html_placed(&columns, &[], &opts, &mut none, &mut empty);
573        assert_eq!(empty.len(), columns.len());
574        for one in &empty {
575            assert!(one.content.is_empty(), "{one:?}");
576            assert!(!none[one.block.clone()].is_empty());
577        }
578    }
579
580    #[test]
581    fn every_width_and_drop_class_is_one_the_vocabulary_wrote_down() {
582        // The obligation ROW_PART_CLASSES carries. Both matches have a wildcard
583        // arm, so a member added upstream lands on a class that is already in
584        // the list; what this catches is a new arm returning a new name, which
585        // would otherwise narrow what a checker believes this crate emits
586        // without narrowing what it writes.
587        for width in [Width::Content, Width::Fixed, Width::Fill] {
588            assert!(
589                CELL_WIDTH_CLASSES.contains(&width_class(width)),
590                "{width:?} is missing from CELL_WIDTH_CLASSES"
591            );
592        }
593        for priority in [Priority::Optional, Priority::Secondary, Priority::Essential] {
594            assert!(
595                CELL_DROP_CLASSES.contains(&drop_class(priority)),
596                "{priority:?} is missing from CELL_DROP_CLASSES"
597            );
598        }
599        let names = crate::vocabulary::names(&Emit::default());
600        for name in CELL_WIDTH_CLASSES.iter().chain(CELL_DROP_CLASSES) {
601            assert!(names.contains(*name), "{name} is not in the vocabulary");
602        }
603    }
604
605    #[test]
606    fn a_column_name_cannot_break_out_of_the_class_attribute() {
607        // Until 0.41.0 the name went in raw, so this emitted
608        // `class="cell col-a" onclick="steal() cell-fill ...">` -- a live
609        // handler on every cell of the column. The name is the one
610        // app-supplied string this crate puts in a class rather than in text.
611        let name = "a\" onclick=\"steal()";
612        let columns = vec![Column::new(name)];
613        let cells = vec![Cell {
614            column: name,
615            part: None,
616            content: Markup("x"),
617        }];
618        let html = cells_html(&columns, &cells, &Emit::default());
619
620        assert!(!html.contains("onclick=\"steal()"), "{html}");
621        assert!(html.contains("col-a--onclick--steal--"), "{html}");
622        // Four quotes in the whole cell, all this crate's: the ones opening and
623        // closing the cell's class attribute and the wrapper's. A fifth would
624        // be the name ending one of them.
625        assert_eq!(html.matches('"').count(), 4, "{html}");
626    }
627
628    #[test]
629    fn the_class_and_the_selector_that_names_it_agree_on_the_name() {
630        // The reason the fix is a filter and not an escape. A class is read by
631        // the HTML parser and again by a CSS selector an app writes against
632        // `column_class`; an escaped name would be safe in the attribute and
633        // unmatchable from the stylesheet.
634        let columns = vec![Column {
635            priority: Priority::Optional,
636            kind: ColumnKind::Text,
637            ..Column::new("Due date")
638        }];
639        let cells = vec![Cell {
640            column: "Due date",
641            part: None,
642            content: Markup("x"),
643        }];
644        let opts = Emit::default();
645
646        let html = cells_html(&columns, &cells, &opts);
647
648        // One class, not the two `col-Due date` parsed as.
649        assert!(html.contains("class=\"cell col-Due-date "), "{html}");
650        assert_eq!(column_class(&columns[0], &opts), "col-Due-date");
651    }
652
653    #[test]
654    fn a_name_already_made_of_identifier_characters_is_untouched() {
655        // Every column name in the tree is one of these, which is what makes
656        // 0.41.0 a fix rather than a rename.
657        for name in ["description", "due", "progress", "Name", "col_2", "a-b"] {
658            let mut out = String::new();
659            push_column_name(&mut out, name);
660            assert_eq!(out, name);
661        }
662    }
663
664    #[test]
665    fn a_name_outside_ascii_keeps_itself() {
666        // CSS identifiers admit everything from U+00A0 up, so folding these to
667        // dashes would collide two columns for nothing.
668        let mut out = String::new();
669        push_column_name(&mut out, "Größe");
670        assert_eq!(out, "Größe");
671    }
672
673    fn columns() -> Vec<Column<'static>> {
674        vec![
675            Column {
676                width: Width::Fill,
677                priority: Priority::Essential,
678                kind: ColumnKind::Text,
679                ..Column::new("description")
680            },
681            Column {
682                width: Width::Fixed,
683                priority: Priority::Secondary,
684                kind: ColumnKind::Text,
685                ..Column::new("due")
686            },
687            Column {
688                width: Width::Fixed,
689                priority: Priority::Optional,
690                kind: ColumnKind::Text,
691                ..Column::new("progress")
692            },
693        ]
694    }
695
696    /// The floor rides on the column's classes as a rung the stylesheet has a
697    /// rule for, so a described table narrows at its declared width with no
698    /// CSS travelling beside it.
699    #[test]
700    fn every_column_names_its_floor_as_a_rung_of_the_ladder() {
701        let opts = Emit::default();
702        let declared = Column::new("Buyer").min(15);
703        assert!(column_classes(&declared, &opts).ends_with(" min-16"));
704        let derived = Column::new("description");
705        assert!(column_classes(&derived, &opts).ends_with(" min-16"));
706        // Every floor a column can reach has a rule, prefix and all.
707        let sheet = crate::stylesheet(&opts);
708        for n in (2..=makeover_layout::MIN_CEILING).step_by(2) {
709            assert!(sheet.contains(&format!(".min-{n} {{")), "no rung for {n}");
710        }
711    }
712
713    #[test]
714    fn cells_follow_the_columns_and_carry_their_column_class() {
715        let cells = [
716            Cell {
717                column: "due",
718                part: Some(CellPart::Value),
719                content: Markup("tomorrow"),
720            },
721            Cell::new("description", Markup("<span>Ship it</span>")),
722        ];
723        let html = cells_html(&columns(), &cells, &Emit::default());
724
725        // Column order, not cell order: description was passed second.
726        let description = html.find("Ship it").expect("description cell");
727        let due = html.find("tomorrow").expect("due cell");
728        assert!(description < due, "{html}");
729
730        // Three classes, not one: the column's own name, how wide it asks to
731        // be, and when it drops. The last two are what lets the stylesheet
732        // carry rules a described table cannot generate per table.
733        assert!(
734            html.contains(
735                r#"<div class="cell col-description cell-fill cell-keeps min-16"><span class="cell-in">"#
736            ),
737            "{html}"
738        );
739        assert!(
740            html.contains(
741                r#"<div class="cell col-due cell-fixed cell-drops-next min-8 cell-value"><span class="cell-in">tomorrow</span></div>"#
742            ),
743            "{html}"
744        );
745        // progress had no cell, so it is present and empty rather than absent,
746        // or every column after it would line up under the wrong heading.
747        assert!(
748            html.contains(
749                r#"<div class="cell col-progress cell-fixed cell-drops-first min-10"><span class="cell-in"></span></div>"#
750            ),
751            "{html}"
752        );
753    }
754
755    /// A row is emitted once per row per render, so the streaming form is the
756    /// one a host should call and the two have to agree byte for byte.
757    #[test]
758    fn streamed_cells_are_the_cells_the_other_form_returns() {
759        let opts = Emit {
760            class_prefix: "mk-",
761            ..Emit::default()
762        };
763        let cells = [
764            Cell {
765                column: "due",
766                part: Some(CellPart::Value),
767                content: Markup("tomorrow"),
768            },
769            Cell::new("description", Markup("<span>Ship it</span>")),
770        ];
771        for cells in [&cells[..], &[]] {
772            let mut streamed = String::new();
773            cells_html_into(&columns(), cells, &opts, &mut streamed);
774            assert_eq!(streamed, cells_html(&columns(), cells, &opts));
775        }
776        for column in &columns() {
777            let mut streamed = String::new();
778            push_column_classes(&mut streamed, column, &opts);
779            assert_eq!(streamed, column_classes(column, &opts));
780        }
781    }
782
783    #[test]
784    fn a_cell_naming_no_column_is_dropped() {
785        let cells = [Cell::new("nonexistent", Markup("nowhere"))];
786        let html = cells_html(&columns(), &cells, &Emit::default());
787        assert!(!html.contains("nowhere"), "{html}");
788    }
789
790    #[test]
791    fn the_class_prefix_reaches_the_cells_the_rung_and_the_wrapper() {
792        let opts = Emit {
793            class_prefix: "mk-",
794            ..Emit::default()
795        };
796        let cells = [Cell::new("due", Markup("x"))];
797        let html = cells_html(&columns(), &cells, &opts);
798        assert!(
799            html.contains("mk-cell mk-col-due"),
800            "prefix missing: {html}"
801        );
802        assert!(html.contains(" mk-min-8"), "prefix missing: {html}");
803        assert!(
804            html.contains("class=\"mk-cell-in\""),
805            "prefix missing: {html}"
806        );
807    }
808}