//! Nodes to markup. //! //! Every function here takes a piece of [`quasi_router`]'s screen tree and //! pushes markup onto a buffer. Nothing returns a `Result`: a description that //! exists is renderable by construction, which is the property the owned mirror //! in `quasi-router` was built to have. //! //! # Where the htmx goes in //! //! In exactly one function, [`action_attrs`]. An [`Action`] is a method, a path //! and some params, and turning that into `hx-get` / `hx-post` / `hx-vals` is //! the whole of what "htmx is the transport" means in code. Nothing else in //! this file knows the word htmx, so decision 13's claim that the transport is //! replaceable is a claim about one function rather than about the crate. //! //! `hx-target` is emitted for one case and only one: [`Action::replaces`], set //! when a control calls a route the description layer does not serve. Decision 7 //! puts the target on the response, where `quasi-http` sets `HX-Retarget` from //! [`Response::Fragment`](quasi_router::Response::Fragment), because the router //! is the only party that knows what it just changed, and a control that also //! named a target would be a second party deciding one thing. That reasoning //! assumes the responder is described. A plain API route is not, cannot name a //! region, and leaves the answer to land wherever the transport defaults, which //! for htmx is inside the pressed button. See `Action::replaces` for the whole //! of it. use std::collections::HashMap; use std::fmt::Write as _; use makeover_layout as layout; // `Tone::token` is the trait method, and `data-tone` is spelled from it rather // than from a match here, so a tone added upstream cannot be named two ways. use makeover_layout::Intent as _; use makeover_webview::chart::chart_html_into; use makeover_webview::figure::figure_html_into; use makeover_webview::form::{Filling, Markup, Value, escape_into, field_html_into}; // `class`, `option_class` and the two part-class mappings below are makeover's, // not copies of it. They were copies until makeover-webview 0.27.0 made them // public: the prefix helper was byte-identical, and the row and cell part names // were a second spelling of a list whose own doc comment carries an obligation // to be grepped on upgrade. A second spelling is a second place to forget, and // the selector names had already drifted. use makeover_webview::{Emit, class, fallback_class, option_class}; // `Cell` is a name both crates use: makeover's is the emitted table cell, ours // is the described one. Aliased rather than qualified at the call site, so the // two never read as the same type. use makeover_webview::list::{ Cell as Emitted, cell_part_class, cells_html_into, flow_class, part_class, push_column_classes, }; use makeover_webview::meter::meter_html_into; use makeover_webview::placeholder::placeholder_html_into; use quasi_router::screen::{ Act, Bar, Cell, Clock, Destination, Field, Node, Repeat, Rest, Row, Slot, Tag, }; use quasi_router::{Action, Candidate, Method, Params, Replaces, Richness, Trust}; /// The facts about the document being written that no [`Node`] carries. /// /// Threaded beside [`Emit`] rather than folded into it: `Emit` is the host's /// standing configuration and is the same for every answer, while this is about /// the one answer being written. Two members and both of them are the screen's: /// what a bespoke region was handed, and where the caret starts. /// /// A struct rather than two more parameters. The walk is deep and every level /// passes it along untouched, so the third document-level fact would otherwise /// be a third edit to every signature between here and the leaf. pub(crate) struct Doc<'a> { /// The markup a host drew for a region the description ceded, by /// [`Slot::id`](quasi_router::Slot). pub(crate) fills: &'a HashMap, /// The [`Field::name`] the caret starts in, when this answer is a whole /// document and the screen named one. /// /// `None` on every fragment, which is the whole of the accessibility /// argument: a swap that moves the caret takes it out of whatever the /// reader was typing into, and a browser answers most interactions with a /// fragment. See [`Screen::opens_at`](quasi_router::Screen::opens_at). pub(crate) opens_at: Option<&'a str>, } /// The empty fill map, for the answers that cede no region. /// /// One allocation for the process rather than one per call. Every site reaching /// for it is on a path that cannot hold a bespoke region at all, so the map is /// empty by construction rather than by luck. static NO_FILLS: std::sync::LazyLock> = std::sync::LazyLock::new(HashMap::new); impl Doc<'static> { /// No fills and no caret, for the fragments and the run-level walks. pub(crate) fn bare() -> Self { Self { fills: &NO_FILLS, opens_at: None, } } } impl<'a> Doc<'a> { /// What a host drew, and no caret. /// /// Everything but a whole document: a fragment, an overlay, the chrome and /// the frame. See [`opens_at`](Self::opens_at). pub(crate) fn caretless(fills: &'a HashMap) -> Self { Self { fills, opens_at: None, } } /// What a host drew, and where the caret starts. pub(crate) fn opening(fills: &'a HashMap, opens_at: Option<&'a str>) -> Self { Self { fills, opens_at } } /// Whether this field is the one the caret starts in. fn opens(&self, name: &str) -> bool { self.opens_at == Some(name) } } /// Write one prefixed class name onto a buffer the caller already has. /// /// The two halves are escaped separately rather than joined and escaped once. /// That is the same bytes, since escaping is per character and has no context to /// carry across the seam, and none of the allocations. /// /// Escaped at all because a prefix is host configuration reaching an attribute /// value. It is a `&'static str` and every real one is identity under this, so /// the cost is a scan; what it buys is that the one string here that did not /// come from this crate cannot end the attribute. pub(crate) fn class_into(name: &str, opts: &Emit, out: &mut String) { escape_into(opts.class_prefix, out); escape_into(name, out); } /// Write a `class="..."` attribute, prefixed. fn class_attr(names: &[&str], opts: &Emit, out: &mut String) { out.push_str(" class=\""); for (i, name) in names.iter().enumerate() { if i > 0 { out.push(' '); } class_into(name, opts, out); } out.push('"'); } /// Write the attribute naming a tone, if the tone is worth naming. /// /// [`Tone::Neutral`] writes nothing: ordinary content is the default, and an /// attribute meaning "nothing unusual" is an attribute on every element in the /// document. /// /// An attribute and not a class, which is the correction. This emitted /// `tone-info`, `tone-success`, `tone-warning` and `tone-danger` as classes, and /// makeover has never defined one of them: its whole vocabulary keys tone off /// `data-tone`, from `.badge[data-tone="danger"]` to the progress fill to a /// figure's value. So every toned thing a description produced arrived with a /// class no stylesheet in the tree had heard of, which is why the SSH-keys tab's /// Remove button came out the same colour as everything else. fn tone_attr(tone: layout::Tone, out: &mut String) { if matches!(tone, layout::Tone::Neutral) { return; } out.push_str(" data-tone=\""); out.push_str(tone.token()); out.push('"'); } /// How much of its row a control asked for, where it is not the default. /// /// `tone_attr`'s shape and its reason. An attribute rather than a class because /// this is a fact the description carried, not a styling hook this renderer /// invented -- the same division `data-selector` and `data-tone` are on the /// right side of. `Fill` is the default and is what a control did before the /// member existed, so saying it would be a hook that changes nothing. fn width_attr(width: layout::Width, out: &mut String) { let said = match width { layout::Width::Content => "content", layout::Width::Fixed => "fixed", // `Fill` is the default, and a member added upstream since this // renderer learned the vocabulary is one it does not draw. _ => return, }; out.push_str(" data-width=\""); out.push_str(said); out.push('"'); } /// How a picture sits in its box, where it is not the default. /// /// `tone_attr`'s shape and for its reason: `Natural` is what an `` does /// with no rule at all, so saying it would be a stylesheet hook that changes /// nothing. The two that need a rule get one. fn fit_attr(fit: layout::Fit, out: &mut String) { let value = match fit { layout::Fit::Natural => return, layout::Fit::Cover => "cover", layout::Fit::Contain => "contain", // `Fit` is `#[non_exhaustive]`, so a member added upstream lands here // rather than failing the build. Drawing it as natural is the safe // read: the picture is whole and its own shape, which is wrong about // the box and never wrong about the content. _ => return, }; out.push_str(" data-fit=\""); out.push_str(value); out.push('"'); } /// What goes back in the box, for a form being offered again after a refusal. /// /// The description carries the value as a string, because that is what came /// off the wire; the kind is what says how to read it. A checkbox is carried /// by presence the way HTML submits one, so any value means ticked and nothing /// means not. /// /// A [`layout::FieldKind::Secret`] is emitted empty whatever it holds. That is /// the third refusal of the same thing and none of the three is redundant: /// `Field::value` will not store one, `makeover_webview::form` will not write /// one into an ``, and this one stands between them /// because `Field::value` is a public field that a struct literal reaches past. fn refill(field: &Field) -> Value<'_> { if field.kind == layout::FieldKind::Secret { return Value::Absent; } // An interval came back under two names, so it goes back into two boxes. // Either end absent is an answer rather than a half-filled form -- "over // 120 BPM" -- so this is not gated on both being present. if field.kind == layout::FieldKind::Interval { return Value::Between { lower: field.value.as_deref().unwrap_or_default(), upper: field.upper_value.as_deref().unwrap_or_default(), }; } match field.value.as_deref() { None => Value::Absent, Some(_) if field.kind == layout::FieldKind::Checkbox => Value::On(true), Some(value) => Value::Text(value), } } /// Whether the reader reaches this control's value by typing characters into /// it. /// /// What `keyup` can hear, which is the question the two consult triggers turn /// on: a `"); } // A span per run of consecutive parts sharing a role, rather than a fixed // sequence of members. The old shape drew primary, secondary, meta, bar, // tokens, actions in that order however the description was built; the run // draws what it was given where it was put, and a row with a tag between // two facts now says so. // // Consecutive same-role parts share one wrapping span for the reason a // cell's tokens do: the part class carries the gap between siblings, so a // span each would space two badges as though they were unrelated. // // Flow joins the role in the grouping key, and has to: the span is what // carries the clamp, so two parts sharing a role and disagreeing about how // many lines they may take cannot share one. A row whose title is relaxed // and whose second title-role part is not gets two spans, which is the // right answer and is also the only one that can be drawn. let mut rest = row.cells.as_slice(); while let Some(head) = rest.first() { let (key, flow) = (head.key.clone(), head.room()); let taken = rest .iter() .take_while(|cell| cell.key == key && cell.room() == flow) .count(); let (group, tail) = rest.split_at(taken); // A list draws over the default column set, so every cell in one is // keyed by role. A cell keyed to a declared column has reached a list // row, which is a description error rather than something to render: // the table constructors (`Row::cells`, `at`, `cell`) produce those // keys and a list row is built by `Row::new` and its siblings. // // Loud in debug and benign in release, which is the bargain // `Table::row`'s two assertions already strike here (`d41d00a`): a // panic in a description is worse than a row that draws its text under // the wrong style. debug_assert!( matches!(key, quasi_router::CellKey::Role(_)), "a cell keyed to a declared column reached a list row, which draws \ over the default column set and has no column to style it from. \ Key: {key:?}", ); let role = match &key { quasi_router::CellKey::Role(role) => *role, _ => quasi_router::layout::RowPart::Primary, }; row_part_html(row, role, flow, group, opts, out); rest = tail; } // After the run, because a menu is not on the line: it is the set of things // that can be done to the row, and it renders as a container the host opens // its own way. A webview hangs a context menu off it, a touch host an action // sheet, a terminal a key-driven list; all three read the same acts. if !row.menu.is_empty() { out.push_str("