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