//! Column layout and row structure for lists and tables. //! //! The other half of phase B. [`form`](crate::form) renders a field; this //! renders the frame a list of rows sits in: which columns exist, how wide they //! are, which ones survive a narrow viewport, and the cell containers a row is //! made of. //! //! # What this does not do //! //! It does not render a cell's contents. That is the crate's own limit, stated //! in `makeover_layout`'s "Where the description stops": generate the boring //! 80% so the bespoke 20% gets the attention. A goingson task row carries //! delegated action hooks with argument substitution, four nested sub-renderers, //! conditional state classes and aria labels built from data. A description //! expressive enough to emit that is a templating language wearing a //! description's name. //! //! So the split is the one [`Markup`] already draws for forms: this owns the //! structure and the app owns what goes in it. What that removes from an app is //! not small — cell order, cell classes, the grid tracks, and above all the //! narrowing rules, which is where addressing columns by position goes wrong. //! //! # Why positions are the bug //! //! goingson hides its mobile columns with `nth-child(n+5)` against a //! seven-column table, plus a separate `nth-child(3)`, plus two class-based //! rules — the same fact said three ways, two of them positional. Insert a //! column anywhere left of the cut and the wrong one disappears, silently, //! because nothing in the stylesheet knows what column five *is*. //! [`Priority`] is the fix: a renderer narrows by raising a cutoff, and never //! by counting. use crate::form::Markup; use crate::{Emit, push_class}; use makeover_layout::{CellPart, Column, Flow, Priority, RowPart, Width}; use std::fmt::Write as _; /// The lengths the description deferred. /// /// [`Width`] says `Content`, `Fixed` or `Fill` and deliberately carries no /// magnitude, because a magnitude is a CSS answer and the description is read /// by renderers that have no pixels. So the numbers arrive here instead, the /// way a field's value arrives in [`Filling`](crate::form::Filling) rather than /// in `Field`. /// /// Looked up by column name, because an app's columns are not all one size: /// goingson's task table has six distinct fixed widths. #[derive(Debug, Clone, Copy, Default)] pub struct Sizing<'a> { /// `(column name, CSS length)`. The length is the track for a /// [`Width::Fixed`] column and the floor for a [`Width::Fill`] one. pub lengths: &'a [(&'a str, &'a str)], /// Used for a column with no entry above. Empty means `auto`. pub fallback: &'a str, } impl Sizing<'_> { /// The length for a named column. fn length_for(&self, name: &str) -> &str { self.lengths .iter() .find(|(column, _)| *column == name) .map_or_else( || { if self.fallback.is_empty() { "auto" } else { self.fallback } }, |(_, length)| *length, ) } /// The grid track for one column. fn track(&self, column: &Column<'_>) -> String { match column.width { Width::Content => "max-content".to_owned(), Width::Fixed => self.length_for(column.name).to_owned(), Width::Fill => format!("minmax({}, 1fr)", self.length_for(column.name)), // A width added to the description since this renderer was built. // `auto` is the track that makes no claim, which is the honest // answer to a claim this renderer cannot read. _ => "auto".to_owned(), } } } /// The class a cell of this column carries. /// /// Derived from the column's own name, which is what makes the narrowing rules /// addressable. `data-column` would do as well; a class is what both webview /// apps already key their cell styling on. /// /// The name is reduced to identifier characters first. See [`push_column_name`]. #[must_use] pub fn column_class(column: &Column<'_>, opts: &Emit) -> String { let mut out = String::new(); push_column_class(&mut out, column, opts); out } /// The class a cell of this column carries, written into a buffer the caller /// already has. /// /// [`column_class`]'s streaming form. It is the one that runs per cell per row, /// and it used to allocate twice to get there: once for `col-` and once /// for the prefix in front of it. pub fn push_column_class(out: &mut String, column: &Column<'_>, opts: &Emit) { out.push_str(opts.class_prefix); out.push_str("col-"); push_column_name(out, column.name); } /// A column's name as the identifier half of its class. /// /// # Why this is not escaping /// /// The name is the one app-supplied string this crate puts in a class attribute /// rather than in text or an `aria-label`, and until 0.41.0 it went in raw. A /// column named `a" onclick="steal()` emitted /// /// ```html ///
/// ``` /// /// which is a live event handler on every cell of that column. HTML escaping is /// the reflex and it is the wrong tool here, because a class is read twice: once /// by the HTML parser, which would decode `"` back to a quote, and once by /// a CSS selector, which [`narrowing_css`] writes from this same function. An /// escaped name is safe in the attribute and unmatchable from the stylesheet, /// so the two halves of the narrowing would stop meeting -- silently, the way /// every other defect this module's comments record did. /// /// Reducing the name to identifier characters answers both. What comes out is a /// valid CSS identifier, so the selector matches, and it holds none of the five /// characters an attribute value can be ended with, so there is nothing to /// escape. /// /// # What it changes for a name that was already fine /// /// Nothing. Alphanumerics, `_` and `-` pass through, and every column name in /// the tree is made of those. A name that is *not* was already broken rather /// than merely unsafe: `Due date` emitted `col-Due date`, which the HTML parser /// reads as the two classes `col-Due` and `date`, and which `narrowing_css` /// wrote as a descendant selector that matched neither. Both now agree on /// `col-Due-date`. /// /// Alphanumeric in the Unicode sense, not the ASCII one. CSS identifiers admit /// everything from U+00A0 up, so a column named `Größe` keeps its name; folding /// it to `Gr--e` would collide with a neighbouring column for nothing. pub fn push_column_name(out: &mut String, name: &str) { for ch in name.chars() { // Substituted rather than dropped. Two columns called `a b` and `ab` // are different columns, and dropping would give them one class and one // set of narrowing rules between them. if ch.is_alphanumeric() || ch == '_' || ch == '-' { out.push(ch); } else { out.push('-'); } } } /// The class saying how wide a cell of this column asks to be. /// /// A bounded vocabulary, unlike [`column_class`], which is why the stylesheet /// can carry the rule. [`Width`] is `#[non_exhaustive]`, and a member added /// upstream lands on the fill class: a column that takes the slack is the /// behaviour that makes no claim, matching the `auto` track /// [`Sizing::track`] falls back to for the same reason. fn width_class(width: Width) -> &'static str { match width { Width::Content => "cell-content", Width::Fixed => "cell-fixed", _ => "cell-fill", } } /// The class saying when a cell of this column drops. /// /// [`Priority`] said as a class rather than as a cutoff, so the hiding can live /// in the stylesheet instead of being generated per table. That is what a /// [`display: table`](crate::table_rules) frame needs and a grid one cannot use: /// a grid also has to shorten its track list, which only the columns themselves /// can say. fn drop_class(priority: Priority) -> &'static str { match priority { Priority::Optional => "cell-drops-first", Priority::Secondary => "cell-drops-next", // A priority added upstream keeps its column. `Priority` is // `#[non_exhaustive]`, and of the two ways to be wrong about one this // renderer has not learned, showing a column that should have dropped // is the one the user can see and work around. _ => "cell-keeps", } } /// Every class a cell of this column carries. /// /// The column's own name, how wide it asks to be, and when it drops. A header /// cell has to carry the same three or the header and the body disagree about /// which column just disappeared, and a renderer emitting its own header row /// should call this rather than assemble the list a second time. #[must_use] pub fn column_classes(column: &Column<'_>, opts: &Emit) -> String { let mut out = String::new(); push_column_classes(&mut out, column, opts); out } /// Every class a cell of this column carries, written into a buffer the caller /// already has. /// /// [`column_classes`]'s streaming form, and four allocations fewer per cell: the /// three names and the string joining them. pub fn push_column_classes(out: &mut String, column: &Column<'_>, opts: &Emit) { push_column_class(out, column, opts); out.push(' '); push_class(out, width_class(column.width), opts); out.push(' '); push_class(out, drop_class(column.priority), opts); } /// The `grid-template-columns` value for the columns kept at `cutoff`. /// /// Emitting only the surviving tracks is what keeps the track list and the /// hiding in agreement. An app that hides a cell with `display: none` but /// leaves its track in place gets a column of empty space, which is the other /// half of goingson's mobile bug: its narrow rule drops to four tracks by hand /// and has to be edited in step with the `nth-child` cut. #[must_use] pub fn grid_template_columns( columns: &[Column<'_>], sizing: &Sizing<'_>, cutoff: Priority, ) -> String { columns .iter() .filter(|column| column.kept_at(cutoff)) .map(|column| sizing.track(column)) .collect::>() .join(" ") } /// The rules that narrow `selector` to the columns kept at `cutoff`. /// /// Both halves together: the shortened track list, and `display: none` on each /// dropped column *by its own class*. Nothing counts positions, so inserting a /// column changes what is emitted rather than changing which column vanishes. /// /// `selector` may be a selector list. A descendant is appended to each part /// rather than to the whole, because appending to the whole changes what the /// earlier parts match: `.head, .row > .col-x` reads as "`.head`, or a `.col-x` /// inside `.row`", so `.head` itself would be hidden. #[must_use] pub fn narrowing_css( columns: &[Column<'_>], selector: &str, sizing: &Sizing<'_>, cutoff: Priority, opts: &Emit, ) -> String { let parts: Vec<&str> = selector.split(',').map(str::trim).collect(); let mut css = format!( "{} {{\n grid-template-columns: {};\n}}\n", parts.join(", "), grid_template_columns(columns, sizing, cutoff) ); for column in columns.iter().filter(|c| !c.kept_at(cutoff)) { let class = column_class(column, opts); let targets: Vec = parts .iter() .map(|part| format!("{part} > .{class}")) .collect(); let _ = write!(css, "{} {{\n display: none;\n}}\n", targets.join(",\n")); } css } /// One cell of a row. /// /// The contents are [`Markup`] rather than text, and that is the whole shape of /// this module: a cell holds whatever the app builds, and the app says so by /// naming it. Escaping a cell here would be wrong as well as impossible — a /// task row's description cell is five nested spans and a badge. #[derive(Debug, Clone, Copy)] pub struct Cell<'a> { /// Which column this fills, by name. pub column: &'a str, /// What the cell holds, when the whole cell is one thing. /// /// Carries the cell-part class the stylesheet half emits, so a cell that is /// nothing but controls says so in the description's own words rather than /// in the app's. /// /// This was `Option` until 0.25.0, which was the drift /// `makeover-layout` 0.14.0 named: a table cell borrowing the list row's /// vocabulary, because the table side had none. A row's parts answer a /// different question (which of six emphases this run of text takes) from a /// cell's (whether this is text, tokens, controls or a link). /// /// `None` for a cell mixing parts. A cell holding a value *and* a strip of /// tokens *and* a control is three parts in one container, and each one /// wears its own class inside — this field is for the single-part case, /// where a wrapper span would say nothing the cell has not already said. pub part: Option, /// The contents. Trusted app markup. pub content: Markup<'a>, } impl<'a> Cell<'a> { /// A cell with no cell part. #[must_use] pub const fn new(column: &'a str, content: Markup<'a>) -> Self { Self { column, part: None, content, } } } /// The class for a row part. /// /// This comment used to say `RowPart` was the one closed enum left here, and /// that gaining a member would stop this compiling — "the same lockstep break /// `non_exhaustive` was added elsewhere to end". makeover-layout 0.9.0 ended /// it: the enum gained [`RowPart::Tokens`] and `#[non_exhaustive]` in the same /// release, so the prediction was paid off rather than waited for. /// /// The fallback is what that costs. A member added upstream lands here as a /// bare `row-part` with no rule of its own, which is a thing rendering plainly /// rather than a build that stops. Grep this function when adopting a new /// makeover-layout. /// /// Public since 0.27.0. A row's parts are emitted by whoever builds the row /// element, and that is not always this crate: `cells_html` emits a table's /// cells, but a list row carries the app's identity and hooks, so a screen /// renderer writes it. quasi-webview wrote this list out a second time to do /// that, which made the obligation in the paragraph above land on a function /// its author would not think to grep. /// Every class [`part_class`] can return, including the fallback. /// /// Beside the match rather than derived from it, because a `match` over a /// `#[non_exhaustive]` enum cannot be enumerated from outside. It carries the /// same obligation the match does and a test below holds the two together, so /// a new arm added without a new entry fails rather than silently narrowing /// what a checker believes this crate can emit. pub const ROW_PART_CLASSES: &[&str] = &[ "row-primary", "row-secondary", "row-meta", "row-actions", "row-tokens", "row-proportion", "row-part", ]; /// Every class [`flow_class`] can return. /// /// `Flow::Tight` has no class: one line is what a run already does, so a rule /// for it would restate the default on every part in every row. Only the tier /// that departs from it is named, which is also why a renderer emitting nothing /// for an unknown flow is correct rather than lossy. pub const FLOW_CLASSES: &[&str] = &["row-relaxed"]; /// The class for a part's flow, if it needs one. /// /// `None` for [`Flow::Tight`] and for any tier added upstream that this crate /// has not been taught, which lands as one line: the same trade /// [`part_class`]'s fallback makes, and the safe direction, since a part that /// grows without bound breaks the rows around it while a part that stays on one /// line only looks like the old rendering. Grep this when adopting a new /// makeover-layout. #[must_use] pub fn flow_class(flow: Flow) -> Option<&'static str> { match flow { Flow::Relaxed => Some("row-relaxed"), _ => None, } } /// Every class [`width_class`](fn@width_class) can return, including the /// fallback. /// /// See [`ROW_PART_CLASSES`] for why it is written out. Only two of the three /// carry a rule -- a fill is what a cell does when the sheet says nothing -- /// which is exactly why the list is here rather than being read off the /// generated CSS: `cell-fill` reached every table in the tree and the /// vocabulary named it nowhere. pub const CELL_WIDTH_CLASSES: &[&str] = &["cell-content", "cell-fixed", "cell-fill"]; /// Every class [`drop_class`](fn@drop_class) can return, including the /// fallback. /// /// [`CELL_WIDTH_CLASSES`]' argument, one column property over: `cell-keeps` is /// the tier the narrowing never hides, so the sheet writes no rule for it and /// a scraped set cannot see it. pub const CELL_DROP_CLASSES: &[&str] = &["cell-drops-first", "cell-drops-next", "cell-keeps"]; /// Every class [`cell_part_class`] can return, including the fallback. /// /// See [`ROW_PART_CLASSES`] for why it is written out. pub const CELL_PART_CLASSES: &[&str] = &[ "cell-value", "cell-tokens", "cell-actions", "cell-link", "cell-part", ]; #[must_use] pub fn part_class(part: RowPart) -> &'static str { match part { RowPart::Primary => "row-primary", RowPart::Secondary => "row-secondary", RowPart::Meta => "row-meta", RowPart::Actions => "row-actions", RowPart::Tokens => "row-tokens", RowPart::Proportion => "row-proportion", _ => "row-part", } } /// The class for a cell part. /// /// [`part_class`]'s table half, added with `makeover-layout` 0.14.0's /// [`CellPart`]. The fallback is there for the same reason and costs the same /// thing: a member added upstream lands as a bare `cell-part` with no rule of /// its own, rather than as a build that stops. Grep this function too when /// adopting a new makeover-layout, and public since 0.27.0 for the reason /// [`part_class`] is. #[must_use] pub fn cell_part_class(part: CellPart) -> &'static str { match part { CellPart::Value => "cell-value", CellPart::Tokens => "cell-tokens", CellPart::Actions => "cell-actions", CellPart::Link => "cell-link", _ => "cell-part", } } /// A row's cells, in column order. /// /// Ordered by the columns and not by the cells, so a row cannot silently /// disagree with its table about what comes where. A column with no cell gets /// an empty container, which keeps the grid aligned; a cell naming no column is /// dropped, because there is nowhere to put it. /// /// Emits the cells alone, not the row element. The row carries the app's /// identity and hooks — `data-id`, a context-menu binding, a tabindex, its /// state classes — and none of that is describable here. /// /// # Not for a webview's scroll path /// /// This has no consumer in either webview app, deliberately, and wiring one in /// would be a mistake worth naming. goingson renders rows through a virtual /// scroller whose `_render` calls its row builder **synchronously** while /// scrolling; the code's own comment says scroll events fire at 60Hz+ and that /// this is the hot path. Reaching Rust from there means an IPC round trip and /// an `await` in that loop, per visible range, during a drag. /// /// So this is for the hosts where rendering already happens in Rust: an axum /// route, and the router when it lands. There the objection does not apply, /// because nothing crosses a process boundary to reach it. A webview app should /// take [`narrowing_css`] and [`column_class`] and keep building its own rows. #[must_use] pub fn cells_html(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit) -> String { let mut html = String::new(); cells_html_into(columns, cells, opts, &mut html); html } /// A row's cells, written into a buffer the caller already has. /// /// [`cells_html`]'s streaming form, byte-identical to it, and the one a host /// rendering a table should call: a row is emitted once per row per render, so /// this is where a `String` per cell class is paid for most often. pub fn cells_html_into(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit, out: &mut String) { for column in columns { let found = cells.iter().find(|cell| cell.column == column.name); out.push_str("
"); out.push_str(found.map_or("", |cell| cell.content.0)); out.push_str("
"); } } #[cfg(test)] mod tests { use super::*; #[test] fn every_width_and_drop_class_is_one_the_vocabulary_wrote_down() { // The obligation ROW_PART_CLASSES carries. Both matches have a wildcard // arm, so a member added upstream lands on a class that is already in // the list; what this catches is a new arm returning a new name, which // would otherwise narrow what a checker believes this crate emits // without narrowing what it writes. for width in [Width::Content, Width::Fixed, Width::Fill] { assert!( CELL_WIDTH_CLASSES.contains(&width_class(width)), "{width:?} is missing from CELL_WIDTH_CLASSES" ); } for priority in [Priority::Optional, Priority::Secondary, Priority::Essential] { assert!( CELL_DROP_CLASSES.contains(&drop_class(priority)), "{priority:?} is missing from CELL_DROP_CLASSES" ); } let names = crate::vocabulary::names(&Emit::default()); for name in CELL_WIDTH_CLASSES.iter().chain(CELL_DROP_CLASSES) { assert!(names.contains(*name), "{name} is not in the vocabulary"); } } #[test] fn a_column_name_cannot_break_out_of_the_class_attribute() { // Until 0.41.0 the name went in raw, so this emitted // `class="cell col-a" onclick="steal() cell-fill ...">` -- a live // handler on every cell of the column. The name is the one // app-supplied string this crate puts in a class rather than in text. let name = "a\" onclick=\"steal()"; let columns = vec![Column::new(name)]; let cells = vec![Cell { column: name, part: None, content: Markup("x"), }]; let html = cells_html(&columns, &cells, &Emit::default()); assert!(!html.contains("onclick=\"steal()"), "{html}"); assert!(html.contains("col-a--onclick--steal--"), "{html}"); // Two quotes in the whole cell, both this crate's: the ones opening and // closing the class attribute. A third would be the name ending it. assert_eq!(html.matches('"').count(), 2, "{html}"); } #[test] fn the_class_and_the_selector_that_hides_it_agree_on_the_name() { // The reason the fix is a filter and not an escape. A class is read by // the HTML parser and again by a CSS selector; an escaped name would be // safe in the attribute and unmatchable from the stylesheet, so the // narrowing would stop hiding the column it names. let columns = vec![Column { priority: Priority::Optional, ..Column::new("Due date") }]; let cells = vec![Cell { column: "Due date", part: None, content: Markup("x"), }]; let opts = Emit::default(); let html = cells_html(&columns, &cells, &opts); let css = narrowing_css(&columns, ".row", &sizing(), Priority::Essential, &opts); // One class, not the two `col-Due date` parsed as. assert!(html.contains("class=\"cell col-Due-date "), "{html}"); assert!(css.contains(".row > .col-Due-date {"), "{css}"); } #[test] fn a_name_already_made_of_identifier_characters_is_untouched() { // Every column name in the tree is one of these, which is what makes // 0.41.0 a fix rather than a rename. for name in ["description", "due", "progress", "Name", "col_2", "a-b"] { let mut out = String::new(); push_column_name(&mut out, name); assert_eq!(out, name); } } #[test] fn a_name_outside_ascii_keeps_itself() { // CSS identifiers admit everything from U+00A0 up, so folding these to // dashes would collide two columns for nothing. let mut out = String::new(); push_column_name(&mut out, "Größe"); assert_eq!(out, "Größe"); } fn columns() -> Vec> { vec![ Column { width: Width::Fill, priority: Priority::Essential, ..Column::new("description") }, Column { width: Width::Fixed, priority: Priority::Secondary, ..Column::new("due") }, Column { width: Width::Fixed, priority: Priority::Optional, ..Column::new("progress") }, ] } fn sizing() -> Sizing<'static> { Sizing { lengths: &[ ("description", "200px"), ("due", "110px"), ("progress", "100px"), ], fallback: "", } } #[test] fn a_fill_column_gets_a_floor_and_the_slack() { let tracks = grid_template_columns(&columns(), &sizing(), Priority::Optional); assert_eq!(tracks, "minmax(200px, 1fr) 110px 100px"); } #[test] fn a_column_with_no_length_makes_no_claim() { let sizing = Sizing::default(); let tracks = grid_template_columns(&columns(), &sizing, Priority::Optional); assert_eq!(tracks, "minmax(auto, 1fr) auto auto"); } /// The point of the module. Raising the cutoff drops columns by what they /// are worth, and the track list shortens to match, so the two cannot /// disagree the way a hand-written `nth-child` cut and a hand-written /// track list can. #[test] fn raising_the_cutoff_drops_columns_and_their_tracks_together() { let columns = columns(); let wide = grid_template_columns(&columns, &sizing(), Priority::Optional); assert_eq!(wide.split(' ').count(), 4); // minmax(200px, + 1fr) + 2 let narrow = grid_template_columns(&columns, &sizing(), Priority::Secondary); assert_eq!(narrow, "minmax(200px, 1fr) 110px"); let narrowest = grid_template_columns(&columns, &sizing(), Priority::Essential); assert_eq!(narrowest, "minmax(200px, 1fr)"); } #[test] fn narrowing_hides_a_dropped_column_by_its_own_class_not_its_position() { let css = narrowing_css( &columns(), ".ui-mode-mobile .task-row", &sizing(), Priority::Secondary, &Emit::default(), ); assert!( css.contains("grid-template-columns: minmax(200px, 1fr) 110px;"), "{css}" ); assert!( css.contains(".ui-mode-mobile .task-row > .col-progress {"), "{css}" ); assert!(!css.contains("nth-child"), "{css}"); // The kept columns are not mentioned as hidden. assert!(!css.contains(".col-due {\n display: none"), "{css}"); } /// A selector list has to distribute, or the earlier parts of it get the /// child combinator appended to the whole and start matching things they /// never named. This hid an entire table header the first time it ran. #[test] fn a_selector_list_distributes_the_hidden_column() { let css = narrowing_css( &columns(), ".task-header-row, .task-row", &sizing(), Priority::Secondary, &Emit::default(), ); assert!( css.contains(".task-header-row > .col-progress,\n.task-row > .col-progress {"), "{css}" ); // The bare header selector must never appear as a hiding target. assert!( !css.contains(".task-header-row {\n display: none"), "{css}" ); assert!( css.contains(".task-header-row, .task-row {\n grid-template-columns:"), "{css}" ); } #[test] fn cells_follow_the_columns_and_carry_their_column_class() { let cells = [ Cell { column: "due", part: Some(CellPart::Value), content: Markup("tomorrow"), }, Cell::new("description", Markup("Ship it")), ]; let html = cells_html(&columns(), &cells, &Emit::default()); // Column order, not cell order: description was passed second. let description = html.find("Ship it").expect("description cell"); let due = html.find("tomorrow").expect("due cell"); assert!(description < due, "{html}"); // Three classes, not one: the column's own name, how wide it asks to // be, and when it drops. The last two are what lets the stylesheet // carry rules a described table cannot generate per table. assert!( html.contains(r#"
"#), "{html}" ); assert!( html.contains(r#"
"#), "{html}" ); // progress had no cell, so it is present and empty rather than absent, // or the grid would shift left by one. assert!( html.contains(r#"
"#), "{html}" ); } /// A row is emitted once per row per render, so the streaming form is the /// one a host should call and the two have to agree byte for byte. #[test] fn streamed_cells_are_the_cells_the_other_form_returns() { let opts = Emit { class_prefix: "mk-", ..Emit::default() }; let cells = [ Cell { column: "due", part: Some(CellPart::Value), content: Markup("tomorrow"), }, Cell::new("description", Markup("Ship it")), ]; for cells in [&cells[..], &[]] { let mut streamed = String::new(); cells_html_into(&columns(), cells, &opts, &mut streamed); assert_eq!(streamed, cells_html(&columns(), cells, &opts)); } for column in &columns() { let mut streamed = String::new(); push_column_classes(&mut streamed, column, &opts); assert_eq!(streamed, column_classes(column, &opts)); } } #[test] fn a_cell_naming_no_column_is_dropped() { let cells = [Cell::new("nonexistent", Markup("nowhere"))]; let html = cells_html(&columns(), &cells, &Emit::default()); assert!(!html.contains("nowhere"), "{html}"); } #[test] fn the_class_prefix_reaches_the_cells_and_the_narrowing() { let opts = Emit { class_prefix: "mk-", ..Emit::default() }; let cells = [Cell::new("due", Markup("x"))]; assert!( cells_html(&columns(), &cells, &opts).contains("mk-cell mk-col-due"), "prefix missing" ); assert!( narrowing_css(&columns(), ".t", &sizing(), Priority::Secondary, &opts) .contains(".mk-col-progress"), "prefix missing" ); } }