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 `"` 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/// What a kept column is as wide as, when its floor cannot say.
219///
220/// A floor is a count of `ch`, and a column of controls is not drawn in `ch`.
221/// Measured on MNW's tables at 1440 and 420: a label's letters run 6 to 8px
222/// against the 9px rung while a button's padding and edge take 26px against the
223/// 18 that `quasi_router::screen::ACT_ROOM` allows, so "Add to cart" held 150px
224/// to draw 118 and "Remove" held 96 to draw 99. The first starved the name
225/// beside it to 32px at 420; the second clipped at every width. No constant
226/// fixes both, because the error is the face's and not the count's.
227///
228/// So the host that knows what a column holds writes a copy of it here, once
229/// per distinct run of controls, drawn in the row's own type and taking no
230/// height and no ink. A kept column holding one is as wide as its widest copy,
231/// which the browser measures in the face it draws, and every cell and the
232/// heading hold the same copies, so the column is one width down the table.
233/// Hidden rather than absent: a hidden box still has a width.
234///
235/// Write it with [`push_sizer`] and never by hand, so the wrapper the sheet
236/// rules and the one a host emits cannot be spelled apart.
237pub const CELL_SIZER: &str = "cell-sizer";
238
239/// A [`CELL_SIZER`] around `copies`, which go inside a cell's or a heading's
240/// [`CELL_IN`] after what it holds.
241///
242/// `copies` is the host's: one block per distinct run, each drawn with the
243/// classes and the element the real controls take, since an element rule an
244/// app writes for `button` reaches a copy only if it is one. Nothing in it may
245/// be a control anyone can reach: `aria-hidden` here and `visibility: hidden`
246/// in the sheet take it out of the accessibility tree and the tab order, and a
247/// copy carries no address. Empty writes nothing.
248///
249/// [`Markup`], because the copies are the host's markup and are written as-is.
250pub fn push_sizer(out: &mut String, copies: Markup<'_>, opts: &Emit) {
251 let Markup(copies) = copies;
252 if copies.is_empty() {
253 return;
254 }
255 out.push_str("<span class=\"");
256 push_class(out, CELL_SIZER, opts);
257 out.push_str("\" aria-hidden=\"true\">");
258 out.push_str(copies);
259 out.push_str("</span>");
260}
261
262/// One cell of a row.
263///
264/// The contents are [`Markup`] rather than text, and that is the whole shape of
265/// this module: a cell holds whatever the app builds, and the app says so by
266/// naming it. Escaping a cell here would be wrong as well as impossible, a
267/// task row's description cell is five nested spans and a badge.
268#[derive(Debug, Clone, Copy)]
269pub struct Cell<'a> {
270 /// Which column this fills, by name.
271 pub column: &'a str,
272 /// What the cell holds, when the whole cell is one thing.
273 ///
274 /// Carries the cell-part class the stylesheet half emits, so a cell that is
275 /// nothing but controls says so in the description's own words rather than
276 /// in the app's.
277 ///
278 /// `Option<CellPart>` and never `Option<RowPart>`: a table cell borrowing
279 /// the list row's vocabulary is drift. A row's parts answer a different
280 /// question (which of six emphases this run of text takes) from a
281 /// cell's (whether this is text, tokens, controls or a link).
282 ///
283 /// `None` for a cell mixing parts. A cell holding a value *and* a strip of
284 /// tokens *and* a control is three parts in one container, and each one
285 /// wears its own class inside, this field is for the single-part case,
286 /// where a wrapper span would say nothing the cell has not already said.
287 pub part: Option<CellPart>,
288 /// The contents. Trusted app markup.
289 pub content: Markup<'a>,
290}
291
292impl<'a> Cell<'a> {
293 /// A cell with no cell part.
294 #[must_use]
295 pub const fn new(column: &'a str, content: Markup<'a>) -> Self {
296 Self {
297 column,
298 part: None,
299 content,
300 }
301 }
302}
303
304/// The class for a row part.
305///
306/// [`RowPart`] is `#[non_exhaustive]`, so a member added upstream does not stop
307/// this compiling.
308///
309/// The fallback is what that costs. A member added upstream lands here as a
310/// bare `row-part`, which the sheet lets shrink like its siblings and otherwise
311/// leaves plain: a thing rendering plainly rather than a build that stops. Grep
312/// this function when adopting a new makeover-layout.
313///
314/// Public, because a row's parts are emitted by whoever builds the row element
315/// and that is not always this crate: `cells_html` emits a table's cells, but a
316/// list row carries the app's identity and hooks, so a screen renderer writes
317/// it. A renderer that reimplements this list rather than calling it takes on
318/// the obligation above without knowing it.
319/// Every class [`part_class`] can return, including the fallback.
320///
321/// Beside the match rather than derived from it, because a `match` over a
322/// `#[non_exhaustive]` enum cannot be enumerated from outside. It carries the
323/// same obligation the match does and a test below holds the two together, so
324/// a new arm added without a new entry fails rather than silently narrowing
325/// what a checker believes this crate can emit.
326pub const ROW_PART_CLASSES: &[&str] = &[
327 "row-primary",
328 "row-secondary",
329 "row-meta",
330 "row-actions",
331 "row-tokens",
332 "row-proportion",
333 "row-part",
334];
335
336/// Every class [`flow_class`] can return.
337///
338/// `Flow::Tight` has no class: one line is what a run already does, so a rule
339/// for it would restate the default on every part in every row. Only the tier
340/// that departs from it is named, which is also why a renderer emitting nothing
341/// for an unknown flow is correct rather than lossy.
342pub const FLOW_CLASSES: &[&str] = &["row-relaxed"];
343
344/// The classes a row inside a hierarchy carries.
345///
346/// Declared here because the generated sheet defines rules for them: the
347/// vocabulary has to say so, or the check that every emitted name has a rule
348/// cannot see them.
349pub const NESTING_CLASSES: &[&str] = &["row-nested", "row-branch", "row-disclose"];
350
351/// The class for a part's flow, if it needs one.
352///
353/// `None` for [`Flow::Tight`] and for any tier added upstream that this crate
354/// has not been taught, which lands as one line: the same trade
355/// [`part_class`]'s fallback makes, and the safe direction, since a part that
356/// grows without bound breaks the rows around it while a part that stays on one
357/// line only looks like the old rendering. Grep this when adopting a new
358/// makeover-layout.
359#[must_use]
360pub fn flow_class(flow: Flow) -> Option<&'static str> {
361 match flow {
362 Flow::Relaxed => Some("row-relaxed"),
363 _ => None,
364 }
365}
366
367/// Every class `width_class`(fn@width_class) can return, including the
368/// fallback.
369///
370/// See [`ROW_PART_CLASSES`] for why it is written out. Only two of the three
371/// carry a rule, a fill is what a cell does when the sheet says nothing,
372/// which is exactly why the list is here rather than being read off the
373/// generated CSS: `cell-fill` reached every table in the tree and the
374/// vocabulary named it nowhere.
375pub const CELL_WIDTH_CLASSES: &[&str] = &["cell-content", "cell-fixed", "cell-fill"];
376
377/// Every class `drop_class`(fn@drop_class) can return, including the
378/// fallback.
379///
380/// [`CELL_WIDTH_CLASSES`]' argument, one column property over: `cell-keeps` is
381/// the tier the narrowing never hides, so the sheet writes no rule for it and
382/// a scraped set cannot see it.
383pub const CELL_DROP_CLASSES: &[&str] = &["cell-drops-first", "cell-drops-next", "cell-keeps"];
384
385/// The rungs of the `short-N` ladder [`short_class`] names, in `rem`: the
386/// narrowest and widest table width it can say, and the step between two.
387///
388/// Two `rem` a step, so a table is marked short at most 32px before its
389/// essentials actually stop fitting, which is well inside the one gap of slack
390/// every floor already carries. A table needing more than the ceiling is wider
391/// than any pane the apps draw, and gets no class.
392pub const SHORT_RUNGS: (u16, u16, u16) = (8, 160, 2);
393
394/// The class that says how wide a table has to be to hold every essential
395/// column at its floor, as a rung of the `short-N` ladder, or `None` when no
396/// rung is wide enough.
397///
398/// What the class buys is a pair of space toggles on the table's rows.
399/// `--table-short` is empty while the table is narrower than this and
400/// unset otherwise; `--table-roomy` is the reverse, and empty on every
401/// table's rows that never carries a rung. So a declaration choosing between
402/// two values reads
403/// `var(--table-short, <roomy value>) var(--table-roomy, <short value>)`: one
404/// of the pair is always empty and the other falls back to its value. The table itself
405/// is the container asked, so the answer is about the table's own width and
406/// not the window's, and only a table carrying the class becomes a container:
407/// a size container takes no width from what it holds, which a table drawn in
408/// a shrink-to-fit parent cannot afford.
409///
410/// What a table does when short is not this crate's to say. The answer that
411/// prompted it is a row's face act folding into the row's menu, and the menu is
412/// a renderer's (`quasi-webview`'s), so this says when and the renderer says
413/// what.
414///
415/// `extra` is the floors of cells the renderer adds beside the columns, in
416/// `ch`: a tick, a disclosure, a menu gutter. Each column and each extra cell
417/// costs its floor and the cell's padding at either end, the same arithmetic
418/// the `min-N` ladder sizes a column by, at the pointer density.
419#[must_use]
420pub fn short_class(columns: &[Column<'_>], extra: &[u16], opts: &Emit) -> Option<String> {
421 let floors = columns
422 .iter()
423 .filter(|column| column.priority == Priority::Essential)
424 .map(Column::floor)
425 .chain(extra.iter().copied());
426 let rung = crate::table::short_rung(floors)?;
427 let mut out = String::new();
428 push_class(&mut out, &format!("short-{rung}"), opts);
429 Some(out)
430}
431
432/// Every class [`cell_part_class`] can return, including the fallback.
433///
434/// See [`ROW_PART_CLASSES`] for why it is written out.
435pub const CELL_PART_CLASSES: &[&str] = &[
436 "cell-value",
437 "cell-tokens",
438 "cell-actions",
439 "cell-link",
440 "cell-part",
441];
442
443#[must_use]
444pub fn part_class(part: RowPart) -> &'static str {
445 match part {
446 RowPart::Primary => "row-primary",
447 RowPart::Secondary => "row-secondary",
448 RowPart::Meta => "row-meta",
449 RowPart::Actions => "row-actions",
450 RowPart::Tokens => "row-tokens",
451 RowPart::Proportion => "row-proportion",
452 _ => "row-part",
453 }
454}
455
456/// The class for a cell part.
457///
458/// [`part_class`]'s table half, for [`CellPart`]. The fallback is there for the
459/// same reason and costs the same thing: a member added upstream lands as a
460/// bare `cell-part` with no rule of its own, rather than as a build that stops.
461/// Grep this function too when adopting a new makeover-layout. Public for the
462/// reason [`part_class`] is.
463#[must_use]
464pub fn cell_part_class(part: CellPart) -> &'static str {
465 match part {
466 CellPart::Value => "cell-value",
467 CellPart::Tokens => "cell-tokens",
468 CellPart::Actions => "cell-actions",
469 CellPart::Link => "cell-link",
470 _ => "cell-part",
471 }
472}
473
474/// A row's cells, in column order.
475///
476/// Ordered by the columns and not by the cells, so a row cannot silently
477/// disagree with its table about what comes where. A column with no cell gets
478/// an empty container, which keeps the grid aligned; a cell naming no column is
479/// dropped, because there is nowhere to put it.
480///
481/// Emits the cells alone, not the row element. The row carries the app's
482/// identity and hooks, `data-id`, a context-menu binding, a tabindex, its
483/// state classes, and none of that is describable here.
484///
485/// # Not for a webview's scroll path
486///
487/// This has no consumer in either webview app, deliberately, and wiring one in
488/// would be a mistake worth naming. goingson renders rows through a virtual
489/// scroller whose `_render` calls its row builder **synchronously** while
490/// scrolling; the code's own comment says scroll events fire at 60Hz+ and that
491/// this is the hot path. Reaching Rust from there means an IPC round trip and
492/// an `await` in that loop, per visible range, during a drag.
493///
494/// So this is for the hosts where rendering already happens in Rust: an axum
495/// route, and the router when it lands. There the objection does not apply,
496/// because nothing crosses a process boundary to reach it. A webview app should
497/// take [`column_classes`] and [`CELL_IN`] and keep building its own rows.
498#[must_use]
499pub fn cells_html(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit) -> String {
500 let mut html = String::new();
501 cells_html_into(columns, cells, opts, &mut html);
502 html
503}
504
505/// A row's cells, written into a buffer the caller already has.
506///
507/// [`cells_html`]'s streaming form, byte-identical to it, and the one a host
508/// rendering a table should call: a row is emitted once per row per render, so
509/// this is where a `String` per cell class is paid for most often.
510pub fn cells_html_into(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit, out: &mut String) {
511 emit_cells(columns, &[], cells, opts, out, None);
512}
513
514/// A row's cells with a [`CELL_SIZER`] in the columns that have one.
515///
516/// `sizers` runs parallel to `columns`: the copies for the column at the same
517/// position, empty or missing for a column that has none. The copies are the
518/// same in every row, so they are the column's to pass and not a cell's, and a
519/// column no cell answers still gets them: an empty cell holding no copy would
520/// be the one row narrower than the rest.
521///
522/// Written after the cell's content and inside its block, so a caller passing
523/// `placed` finds them in the block's range and not in the content's: they do
524/// not vary with the cell. Byte-identical to [`cells_html_into`] or
525/// [`cells_html_placed`] when `sizers` is empty.
526pub fn sized_cells_html_into(
527 columns: &[Column<'_>],
528 sizers: &[Markup<'_>],
529 cells: &[Cell<'_>],
530 opts: &Emit,
531 out: &mut String,
532 placed: Option<&mut Vec<Placed>>,
533) {
534 emit_cells(columns, sizers, cells, opts, out, placed);
535}
536
537/// Where one column's markup went, and which of it the cell wrote.
538///
539/// Two ranges because two different things vary, and a caller compiling this
540/// into a template needs to say which it means.
541///
542/// `block` is the whole `<div>`: the column emits one per column whether or not
543/// a cell answers it, so a caller replacing a cell with a different one replaces
544/// the block, class and all.
545///
546/// `content` is what the cell itself contributed. A cell that is absent leaves
547/// the block standing and empty, so a caller compiling "this cell is there or
548/// is not" must cover the content alone, covering the block would delete a
549/// `<div>` the row still draws, and the row would lose a column.
550#[derive(Clone, Debug, PartialEq, Eq)]
551pub struct Placed {
552 /// The whole column block, opening tag to closing tag.
553 pub block: core::ops::Range<usize>,
554 /// What the cell wrote inside it, empty range and all.
555 pub content: core::ops::Range<usize>,
556}
557
558/// A row's cells, saying where each column's block landed.
559///
560/// Byte-identical to [`cells_html_into`], and it appends one entry to `placed`
561/// per column, in column order: the offsets in `out` between which that
562/// column's whole `<div>` was written.
563///
564/// # Who this is for
565///
566/// A caller compiling a described screen into a template, which has to know
567/// which bytes of a row a particular cell produced. Finding that out by
568/// searching the output for the content is the thing it exists to avoid: a
569/// cell's markup may appear twice in a row, and a cell that renders to nothing
570/// cannot be searched for at all. So the writer says where it wrote, which is
571/// the only source that cannot be wrong.
572///
573/// Nothing about the markup changes, and a caller not compiling anything should
574/// call [`cells_html_into`] and pay nothing for this.
575pub fn cells_html_placed(
576 columns: &[Column<'_>],
577 cells: &[Cell<'_>],
578 opts: &Emit,
579 out: &mut String,
580 placed: &mut Vec<Placed>,
581) {
582 emit_cells(columns, &[], cells, opts, out, Some(placed));
583}
584
585fn emit_cells(
586 columns: &[Column<'_>],
587 sizers: &[Markup<'_>],
588 cells: &[Cell<'_>],
589 opts: &Emit,
590 out: &mut String,
591 mut placed: Option<&mut Vec<Placed>>,
592) {
593 for (at_column, column) in columns.iter().enumerate() {
594 let at = out.len();
595 let found = cells.iter().find(|cell| cell.column == column.name);
596 out.push_str("<div class=\"");
597 push_class(out, "cell", opts);
598 out.push(' ');
599 push_column_classes(out, column, opts);
600 if let Some(part) = found.and_then(|cell| cell.part) {
601 out.push(' ');
602 push_class(out, cell_part_class(part), opts);
603 }
604 out.push_str("\"><span class=\"");
605 push_class(out, CELL_IN, opts);
606 out.push_str("\">");
607 let content = out.len();
608 out.push_str(found.map_or("", |cell| cell.content.0));
609 let wrote = content..out.len();
610 if let Some(copies) = sizers.get(at_column) {
611 push_sizer(out, *copies, opts);
612 }
613 out.push_str("</span></div>");
614 if let Some(placed) = placed.as_deref_mut() {
615 placed.push(Placed {
616 block: at..out.len(),
617 content: wrote,
618 });
619 }
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 /// A column's copies go in every cell of it, a cell no row answers
628 /// included, after the content and inside the block; no sizers writes what
629 /// was always written.
630 #[test]
631 fn a_sizer_is_the_columns_and_sits_outside_what_the_cell_wrote() {
632 let columns = [
633 Column::new("Name"),
634 Column::new("").kind(ColumnKind::Actions),
635 ];
636 let cells = [Cell {
637 column: "Name",
638 part: Some(CellPart::Value),
639 content: Markup("Kick"),
640 }];
641 let opts = Emit::default();
642
643 let mut plain = String::new();
644 cells_html_into(&columns, &cells, &opts, &mut plain);
645 let mut bare = String::new();
646 sized_cells_html_into(&columns, &[], &cells, &opts, &mut bare, None);
647 assert_eq!(bare, plain);
648
649 let sizers = [Markup(""), Markup("<span>Delete</span>")];
650 let mut html = String::new();
651 let mut placed = Vec::new();
652 sized_cells_html_into(
653 &columns,
654 &sizers,
655 &cells,
656 &opts,
657 &mut html,
658 Some(&mut placed),
659 );
660 assert_eq!(html.matches(CELL_SIZER).count(), 1, "{html}");
661 assert!(
662 html.contains(
663 r#"<span class="cell-in"><span class="cell-sizer" aria-hidden="true"><span>Delete</span></span></span></div>"#
664 ),
665 "{html}"
666 );
667 assert!(
668 !html[placed[0].block.clone()].contains(CELL_SIZER),
669 "{html}"
670 );
671 assert!(html[placed[1].block.clone()].contains(CELL_SIZER), "{html}");
672 assert!(placed[1].content.is_empty(), "{html}");
673 }
674
675 /// The placed form writes the same bytes, and says where each one went.
676 ///
677 /// Both halves matter. If the two ever disagreed, a compiled screen would
678 /// be built against markup nobody serves; if a range were off by a byte, it
679 /// would cut a tag in half.
680 #[test]
681 fn saying_where_a_cell_landed_does_not_change_what_is_written() {
682 let columns = [Column::new("Name"), Column::new("Price")];
683 let cells = [
684 Cell {
685 column: "Name",
686 part: Some(CellPart::Value),
687 content: Markup("Kick"),
688 },
689 Cell {
690 column: "Price",
691 part: None,
692 content: Markup("<b>Free</b>"),
693 },
694 ];
695 let opts = Emit::default();
696
697 let mut plain = String::new();
698 cells_html_into(&columns, &cells, &opts, &mut plain);
699
700 let mut said = String::from("before:");
701 let mut placed = Vec::new();
702 cells_html_placed(&columns, &cells, &opts, &mut said, &mut placed);
703
704 assert_eq!(said.strip_prefix("before:").unwrap(), plain);
705 assert_eq!(placed.len(), columns.len());
706 assert_eq!(placed[0].block.start, "before:".len());
707 assert_eq!(placed[1].block.end, said.len());
708 for one in &placed {
709 let block = &said[one.block.clone()];
710 assert!(block.starts_with("<div class=\""), "{block}");
711 assert!(block.ends_with("</div>"), "{block}");
712 }
713
714 // The content is what the cell wrote and nothing the column wrote, so
715 // it is the half a caller covers when the cell may be absent: the
716 // block stands either way.
717 assert_eq!(&said[placed[0].content.clone()], "Kick");
718 assert_eq!(&said[placed[1].content.clone()], "<b>Free</b>");
719 for one in &placed {
720 assert!(one.block.start < one.content.start);
721 assert!(one.content.end < one.block.end);
722 }
723
724 // A column no cell answers still draws its block, and the content it
725 // reports is the empty range inside it. That is the case the whole
726 // split exists for.
727 let mut none = String::new();
728 let mut empty = Vec::new();
729 cells_html_placed(&columns, &[], &opts, &mut none, &mut empty);
730 assert_eq!(empty.len(), columns.len());
731 for one in &empty {
732 assert!(one.content.is_empty(), "{one:?}");
733 assert!(!none[one.block.clone()].is_empty());
734 }
735 }
736
737 #[test]
738 fn every_width_and_drop_class_is_one_the_vocabulary_wrote_down() {
739 // The obligation ROW_PART_CLASSES carries. Both matches have a wildcard
740 // arm, so a member added upstream lands on a class that is already in
741 // the list; what this catches is a new arm returning a new name, which
742 // would otherwise narrow what a checker believes this crate emits
743 // without narrowing what it writes.
744 for width in [Width::Content, Width::Fixed, Width::Fill] {
745 assert!(
746 CELL_WIDTH_CLASSES.contains(&width_class(width)),
747 "{width:?} is missing from CELL_WIDTH_CLASSES"
748 );
749 }
750 for priority in [Priority::Optional, Priority::Secondary, Priority::Essential] {
751 assert!(
752 CELL_DROP_CLASSES.contains(&drop_class(priority)),
753 "{priority:?} is missing from CELL_DROP_CLASSES"
754 );
755 }
756 let names = crate::vocabulary::names(&Emit::default());
757 for name in CELL_WIDTH_CLASSES.iter().chain(CELL_DROP_CLASSES) {
758 assert!(names.contains(*name), "{name} is not in the vocabulary");
759 }
760 }
761
762 #[test]
763 fn a_column_name_cannot_break_out_of_the_class_attribute() {
764 // A name written raw would emit
765 // `class="cell col-a" onclick="steal() cell-fill ...">`, a live
766 // handler on every cell of the column. The name is the one
767 // app-supplied string this crate puts in a class rather than in text.
768 let name = "a\" onclick=\"steal()";
769 let columns = vec![Column::new(name)];
770 let cells = vec![Cell {
771 column: name,
772 part: None,
773 content: Markup("x"),
774 }];
775 let html = cells_html(&columns, &cells, &Emit::default());
776
777 assert!(!html.contains("onclick=\"steal()"), "{html}");
778 assert!(html.contains("col-a--onclick--steal--"), "{html}");
779 // Four quotes in the whole cell, all this crate's: the ones opening and
780 // closing the cell's class attribute and the wrapper's. A fifth would
781 // be the name ending one of them.
782 assert_eq!(html.matches('"').count(), 4, "{html}");
783 }
784
785 #[test]
786 fn the_class_and_the_selector_that_names_it_agree_on_the_name() {
787 // The reason the fix is a filter and not an escape. A class is read by
788 // the HTML parser and again by a CSS selector an app writes against
789 // `column_class`; an escaped name would be safe in the attribute and
790 // unmatchable from the stylesheet.
791 let columns = vec![
792 Column::new("Due date")
793 .priority(Priority::Optional)
794 .kind(ColumnKind::Text),
795 ];
796 let cells = vec![Cell {
797 column: "Due date",
798 part: None,
799 content: Markup("x"),
800 }];
801 let opts = Emit::default();
802
803 let html = cells_html(&columns, &cells, &opts);
804
805 // One class, not the two `col-Due date` parsed as.
806 assert!(html.contains("class=\"cell col-Due-date "), "{html}");
807 assert_eq!(column_class(&columns[0], &opts), "col-Due-date");
808 }
809
810 #[test]
811 fn a_name_already_made_of_identifier_characters_is_untouched() {
812 // Every column name in the tree is one of these, which is what makes
813 // 0.41.0 a fix rather than a rename.
814 for name in ["description", "due", "progress", "Name", "col_2", "a-b"] {
815 let mut out = String::new();
816 push_column_name(&mut out, name);
817 assert_eq!(out, name);
818 }
819 }
820
821 #[test]
822 fn a_name_outside_ascii_keeps_itself() {
823 // CSS identifiers admit everything from U+00A0 up, so folding these to
824 // dashes would collide two columns for nothing.
825 let mut out = String::new();
826 push_column_name(&mut out, "Größe");
827 assert_eq!(out, "Größe");
828 }
829
830 fn columns() -> Vec<Column<'static>> {
831 vec![
832 Column::new("description")
833 .width(Width::Fill)
834 .priority(Priority::Essential)
835 .kind(ColumnKind::Text),
836 Column::new("due")
837 .width(Width::Fixed)
838 .priority(Priority::Secondary)
839 .kind(ColumnKind::Text),
840 Column::new("progress")
841 .width(Width::Fixed)
842 .priority(Priority::Optional)
843 .kind(ColumnKind::Text),
844 ]
845 }
846
847 /// The floor rides on the column's classes as a rung the stylesheet has a
848 /// rule for, so a described table narrows at its declared width with no
849 /// CSS travelling beside it.
850 #[test]
851 fn every_column_names_its_floor_as_a_rung_of_the_ladder() {
852 let opts = Emit::default();
853 let declared = Column::new("Buyer").min(15);
854 assert!(column_classes(&declared, &opts).ends_with(" min-16"));
855 let derived = Column::new("description");
856 assert!(column_classes(&derived, &opts).ends_with(" min-16"));
857 // Every floor a column can reach has a rule, prefix and all.
858 let sheet = crate::stylesheet(&opts);
859 for n in (2..=makeover_layout::MIN_CEILING).step_by(2) {
860 assert!(sheet.contains(&format!(".min-{n} {{")), "no rung for {n}");
861 }
862 }
863
864 #[test]
865 fn cells_follow_the_columns_and_carry_their_column_class() {
866 let cells = [
867 Cell {
868 column: "due",
869 part: Some(CellPart::Value),
870 content: Markup("tomorrow"),
871 },
872 Cell::new("description", Markup("<span>Ship it</span>")),
873 ];
874 let html = cells_html(&columns(), &cells, &Emit::default());
875
876 // Column order, not cell order: description was passed second.
877 let description = html.find("Ship it").expect("description cell");
878 let due = html.find("tomorrow").expect("due cell");
879 assert!(description < due, "{html}");
880
881 // Three classes, not one: the column's own name, how wide it asks to
882 // be, and when it drops. The last two are what lets the stylesheet
883 // carry rules a described table cannot generate per table.
884 assert!(
885 html.contains(
886 r#"<div class="cell col-description cell-fill cell-keeps min-16"><span class="cell-in">"#
887 ),
888 "{html}"
889 );
890 assert!(
891 html.contains(
892 r#"<div class="cell col-due cell-fixed cell-drops-next min-8 cell-value"><span class="cell-in">tomorrow</span></div>"#
893 ),
894 "{html}"
895 );
896 // progress had no cell, so it is present and empty rather than absent,
897 // or every column after it would line up under the wrong heading.
898 assert!(
899 html.contains(
900 r#"<div class="cell col-progress cell-fixed cell-drops-first min-10"><span class="cell-in"></span></div>"#
901 ),
902 "{html}"
903 );
904 }
905
906 /// A row is emitted once per row per render, so the streaming form is the
907 /// one a host should call and the two have to agree byte for byte.
908 #[test]
909 fn streamed_cells_are_the_cells_the_other_form_returns() {
910 let opts = Emit {
911 class_prefix: "mk-",
912 ..Emit::default()
913 };
914 let cells = [
915 Cell {
916 column: "due",
917 part: Some(CellPart::Value),
918 content: Markup("tomorrow"),
919 },
920 Cell::new("description", Markup("<span>Ship it</span>")),
921 ];
922 for cells in [&cells[..], &[]] {
923 let mut streamed = String::new();
924 cells_html_into(&columns(), cells, &opts, &mut streamed);
925 assert_eq!(streamed, cells_html(&columns(), cells, &opts));
926 }
927 for column in &columns() {
928 let mut streamed = String::new();
929 push_column_classes(&mut streamed, column, &opts);
930 assert_eq!(streamed, column_classes(column, &opts));
931 }
932 }
933
934 #[test]
935 fn a_cell_naming_no_column_is_dropped() {
936 let cells = [Cell::new("nonexistent", Markup("nowhere"))];
937 let html = cells_html(&columns(), &cells, &Emit::default());
938 assert!(!html.contains("nowhere"), "{html}");
939 }
940
941 #[test]
942 fn the_class_prefix_reaches_the_cells_the_rung_and_the_wrapper() {
943 let opts = Emit {
944 class_prefix: "mk-",
945 ..Emit::default()
946 };
947 let cells = [Cell::new("due", Markup("x"))];
948 let html = cells_html(&columns(), &cells, &opts);
949 assert!(
950 html.contains("mk-cell mk-col-due"),
951 "prefix missing: {html}"
952 );
953 assert!(html.contains(" mk-min-8"), "prefix missing: {html}");
954 assert!(
955 html.contains("class=\"mk-cell-in\""),
956 "prefix missing: {html}"
957 );
958 }
959}