//! Phase B, the forms half: [`makeover_layout::Field`] rendered to HTML. //! //! # Why this emits strings //! //! Both webview apps build their markup as strings and hand it to `innerHTML`: //! goingson's `renderFormField` returns a template literal that fifteen call //! sites interpolate into larger literals, and Balanced Breakfast's builds //! nodes but appends them into the same string-built forms. Returning nodes //! would rewrite the surrounding templates as well, which makes it a migration //! rather than an adoption. So: strings, and the escaping comes with them. //! //! # Why one escaper is enough here //! //! goingson carries four escapers and 543 call sites that must pick between //! them, because `escapeHtml` is built on `textContent` serialization and //! **`textContent` refuses to encode `"`**. That is what makes it unsound in an //! attribute, and it is the whole reason the choice exists. Its `escape.js` //! records the finding as the CHRONIC-XSS seal, and its test suite has a gate //! keeping the unsafe one off the namespace. //! //! [`escape`] here is not built on that, so it encodes the quote along with //! everything else, which makes one function sound in both sinks. The four-way //! choice does not move into Rust: it disappears. Nothing in this module hands //! an unescaped value to the output except through [`Markup`], which a caller //! has to name. //! //! # What the description does not carry //! //! One thing: the **current value**, which arrives in [`Filling`]. //! //! It used to be three. Writing this emitter is what found them, and the other //! two turned out not to be renderer state at all — the placeholder is //! user-facing text that sits with `label` and `hint`, and a select's options //! are needed by every renderer, which is how each of them ends up inventing a //! near-miss of the same struct. Both moved down into `makeover-layout` 0.8.0, //! `Choice` included, and this crate reads them off [`Field`] now. //! //! The value stays, and it is not a leftover. A webview reads it back out of //! the DOM, an immediate-mode renderer writes through a `&mut`, and a terminal //! keeps an edit buffer; a description carrying it would have to carry a way to //! write it back, at which point it is a form model. use crate::{Emit, class, push_class}; use makeover_layout::{Choice, Depth, Field, FieldKind, Intent as _, Selector, ThemeVariant, Tone}; use std::fmt::Write as _; /// Every class this module can put in markup. /// /// [`crate::facet::FACET_CLASSES`]' obligation, and the module where it was /// missing longest. Most of these carry no rule and never will: `.form-group`, /// `.form-label`, `.form-hint` and `.form-error` are the apps' own names, kept /// so adoption deletes goingson's `renderFormField` rather than restyling /// anything, and phase A emits only what it can generate from the description. /// A class with no rule is invisible to [`crate::vocabulary::vocabulary`], /// which reads the generated sheet, so the unruled half of a renderer's /// vocabulary can only be written down. /// /// What went wrong without it: an app checking its stylesheet against /// [`crate::vocabulary::names`] concluded that its live `.form-group` and /// `.form-label` rules matched nothing and were safe to delete. quasi-webview /// carried them in a `MAKEOVER_UNLISTED` constant of its own until 0.59.0 /// rather than let that happen. pub const FIELD_CLASSES: &[&str] = &[ "field", "form-checkbox-label", "form-editor-modes", "form-editor-preview", "form-error", "form-group", "form-hint", "form-interval", "form-label", "form-note", "form-option-detail", "form-option-reason", "form-radio-group", "form-radio-label", "form-unit", ]; // `form-suggestions`, `form-suggestion` and `form-suggestion-detail` are // deliberately absent: [`suggestion_rules`] writes their look and // `quasi-webview` writes their markup, because a suggestion source is a route // and no description layer carries one. They reach the vocabulary through the // generated sheet, which is where a name this crate rules but does not emit // belongs. /// The state classes a field carries, which take no prefix. /// /// `chosen` and `latched`'s convention, stated in /// [`crate::vocabulary::vocabulary`]: a state qualifies a prefixed component /// (`.mk-form-group.has-error`) rather than standing on its own, so a prefix /// moves the thing and not its state. /// /// `has-error` marks the group and `visible` marks the message, which is /// [`makeover_layout::Field::invalid`]'s own reasoning: a renderer with no /// descendant selectors cannot find the group from the message, so both are /// told. pub const FIELD_STATE_CLASSES: &[&str] = &["has-error", "visible"]; /// A string that is already markup, and is emitted without escaping. /// /// The one hole in the escaping, and it has to be named to be used. goingson /// has two live callers that need it, both passing a recurrence-config block /// built elsewhere, and both would otherwise have their markup rendered as /// visible angle brackets. A caller constructing this is stating that the /// contents are trusted; nothing here can check that for them. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Markup<'a>(pub &'a str); /// What the field currently holds. /// /// An enum rather than a bag of optional fields, on the same reasoning /// [`makeover_layout::Depth`] is one: a checkbox holding a string is unsayable /// here, where a struct would let it be said and then have to cope. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Value<'a> { /// Nothing yet. #[default] Absent, /// The value of anything that takes typed text, a select included: what a /// select holds is the `value` of one of [`Field::options`]'s /// [`Choice`]s. /// /// It carried the options too until makeover-layout 0.8.0 moved them onto /// the field, which collapsed a `Chosen { options, value }` variant into /// this one. `makeover-immediate` arrived at the same single-variant shape /// on its own, from the other direction. Text(&'a str), /// A checkbox, on or off. On(bool), /// Both ends of a [`FieldKind::Interval`], lower first. /// /// Two values rather than one string with a separator, for /// [`makeover_layout::Field::upper_name`]'s reason one level down: an /// interval submits under two names, so it comes back as two values, and a /// delimiter this crate owned could appear inside either of them. /// /// Either end may be empty while the other stands. "Over 120 BPM" is a /// lower end and no upper one, and it is an answer rather than a /// half-filled form. /// /// Added 0.56.0 with makeover-layout 0.34.0. Between { /// What the lower box holds now. lower: &'a str, /// What the upper box holds now. upper: &'a str, }, } impl<'a> Value<'a> { /// The value as text, for the kinds that submit one. const fn as_text(&self) -> &'a str { match self { Self::Text(text) | Self::Between { lower: text, .. } => text, Self::Absent | Self::On(_) => "", } } } impl<'a> Value<'a> { /// The upper end, for the one variant that has one. const fn upper_text(&self) -> &'a str { match self { Self::Between { upper, .. } => upper, Self::Absent | Self::Text(_) | Self::On(_) => "", } } } /// Everything about the field that the description does not carry. #[derive(Debug, Clone, Copy, Default)] pub struct Filling<'a> { /// What the field holds now. pub value: Value<'a>, /// Markup appended inside the group, after the hint. Not escaped. pub trailing: Option>, /// Attributes written onto the control element itself. Not escaped. /// /// [`trailing`](Self::trailing)'s argument at attribute scale: a host knows /// facts about the control that no description layer carries, and until /// this existed the only way to attach one was to stop calling this emitter /// and write a second one. quasi's suggestion source is the first caller — /// a field that owns a list of candidates is a `role="combobox"` pointing /// at the list it owns, and neither half is anything /// [`makeover_layout::Field`] can say. /// /// Written verbatim, so a caller supplies `attr="value"` pairs with no /// leading space and does its own escaping. It is [`Markup`]'s hole in the /// same wall, named the same way so a caller has to state that the contents /// are trusted. /// /// A [`FieldKind::Radio`] drops them, and that is deliberate rather than an /// oversight: a radio group is a set of sibling inputs with no one control /// element, so there is nowhere honest to put an attribute meant for the /// control. The group carries the descriptions for the same reason. pub control_attrs: Option>, /// Scopes the `id` attributes to one instance of the form. /// /// The field's `name` is what the value submits under and is the same /// wherever the form appears; its `id` has to be unique in the document, /// and those two facts stop agreeing the moment a form appears twice. /// goingson hits this directly: its new-task and edit-task modals are the /// same field set, so it prefixes `form-modal-task-new` or `-edit` to keep /// `label for` and `aria-describedby` pointing at the right control. /// /// Applies to `id`, `for` and the `-hint` / `-error` associations. Never to /// `name`, which would change what the form submits. pub id_prefix: Option<&'a str>, } impl<'a> Filling<'a> { /// A filling that carries a value and nothing else. #[must_use] pub const fn of(value: Value<'a>) -> Self { Self { value, trailing: None, control_attrs: None, id_prefix: None, } } /// The document-unique id for a field of this name. fn id_for(&self, name: &str) -> String { let mut id = String::new(); if let Some(prefix) = self.id_prefix { escape_into(prefix, &mut id); id.push('-'); } escape_into(name, &mut id); id } } /// Encode the five characters that let a value stop being a value, into a /// buffer the caller already has. /// /// The form the emitters use. [`escape`] is this with a `String` allocated /// around it, and the allocation is the whole difference: a described screen /// escapes once per attribute and once per run of text, so a function that /// returns a `String` allocates a few thousand times to produce one page, where /// a template engine writes its escaped bytes straight into the output buffer. /// Measured 2026-08-14 against a real pane, that gap was 85% of a 42x rendering /// cost, and this is the half of the fix that lives in this crate. /// /// Sound in element text and in a double-quoted attribute alike, which is the /// property `textContent`-based escaping cannot have. Both sinks are covered by /// one function so that no call site has to choose, here or downstream. /// /// Copies in runs rather than per character. All five encoded characters are /// ASCII, so a byte scan cannot land inside a multi-byte character and the /// slice between two of them is always a valid `&str`. Text with nothing to /// encode — which is most text — is one `push_str` of the whole thing. pub fn escape_into(text: &str, out: &mut String) { let mut start = 0; for (index, byte) in text.bytes().enumerate() { let encoded = match byte { b'&' => "&", b'<' => "<", b'>' => ">", b'"' => """, b'\'' => "'", _ => continue, }; out.push_str(&text[start..index]); out.push_str(encoded); start = index + 1; } out.push_str(&text[start..]); } /// Encode the five characters that let a value stop being a value. /// /// [`escape_into`] with a buffer of its own, for the callers that want a value /// rather than an append: a caller assembling an attribute out of several /// pieces, and everything outside this crate that took this function before the /// buffer-writing form existed. Emitting into a buffer you already hold is the /// cheaper path and the one this crate's own emitters take. #[must_use] pub fn escape(text: &str) -> String { let mut out = String::with_capacity(text.len()); escape_into(text, &mut out); out } /// The `type` an input takes for a kind. /// /// [`FieldKind::Secret`] is `password`, which both apps already map by hand. const fn input_type(kind: FieldKind) -> &'static str { match kind { FieldKind::Secret => "password", FieldKind::Number => "number", FieldKind::Checkbox => "checkbox", FieldKind::File => "file", FieldKind::Hidden => "hidden", // Not decoration. Each of these changes the keyboard a touch device // offers and turns on the platform's own validation, which is why the // description names them apart from text rather than letting the app // pass an HTML type through. FieldKind::Email => "email", FieldKind::Url => "url", FieldKind::Tel => "tel", // The same argument, and it buys more here than anywhere else in this // list: a native picker as well as the keyboard and the validation. // Both submit the format `makeover-layout` names, `DATE_FORMAT` and // `DATETIME_FORMAT`, so honouring it costs this renderer nothing. FieldKind::Date => "date", FieldKind::DateTime => "datetime-local", FieldKind::Radio => "radio", // The clearest case in this list that a kind is not decoration: a // number and a range submit the same value and are different controls, // and the browser is the one drawing the difference. FieldKind::Range => "range", // Select and Textarea are not inputs at all; they never reach here. // Radio is one, but it is emitted once per option by `radio_html` and // so does not reach here either. FieldKind::Text | FieldKind::Select | FieldKind::Textarea | FieldKind::Rich => "text", // A kind added to the description since this renderer was built. Text // accepts any value the others would, so it degrades rather than // dropping the field. _ => "text", } } /// The attributes every visible control carries, error state included. /// /// `aria-invalid` is the whole reason the error state is readable at all: the /// generated stylesheet keys the danger ring on `[aria-invalid="true"]` rather /// than on a class, so a control rendered already-invalid without it is styled /// as if nothing were wrong. goingson's runtime validation path sets the /// attribute and its initial render does not, which is exactly the drift one /// emitter removes. /// `id` and `name` arrive separately because they are not the same fact. The /// name is what submits and is fixed by the description; the id has to be /// unique in the document and so carries [`Filling::id_prefix`] when a form /// appears more than once. /// The `accept` attribute, from the description's accept list. /// /// makeover-layout 0.31.0. The list is comma-joined because that is the /// attribute's own format, and each entry writes itself: a family is its /// wildcard media type, a media type is itself, a suffix is itself with its /// leading dot. Nothing is normalised on the way through -- `.tar.gz` is two /// dots and the browser is fine with it. /// /// An empty list emits no attribute at all, which is the browser's own "any /// file" and is what the description means by listing nothing. Emitting /// `accept=""` instead would be a filter that matches nothing on some browsers /// and everything on others. /// /// It is a filter and not a guarantee, on the browser's side as much as here: /// the picker keeps an "All Files" escape and the user may take it. Whoever /// validated still validates. fn push_accept(out: &mut String, field: &Field<'_>) { if field.accept.is_empty() { return; } out.push_str(" accept=\""); for (index, one) in field.accept.iter().enumerate() { if index > 0 { out.push(','); } escape_into(one.as_str(), out); } out.push('"'); } /// The extent and the granularity, as the browser spells them. /// /// Its own function because an interval writes them onto both of its ends: they /// describe the axis rather than either end of it, which is what /// [`FieldKind::Interval`] says and what the six audiofiles filter axes are. fn push_bounds(out: &mut String, field: &Field<'_>) { if let Some(min) = field.min { out.push_str(" min=\""); escape_into(min, out); out.push('"'); } if let Some(max) = field.max { out.push_str(" max=\""); escape_into(max, out); out.push('"'); } // The browser's own default is `step="1"`, which turns a 0-to-1 threshold // into a two-position control. That is the granularity the description // means when it says nothing, so this is emitted only when an app has said // otherwise rather than defaulted here. // // A range takes its granularity from its curve as of makeover-layout // 0.32.0, and every other kind keeps `Field::step`. See the crate header on // what this renderer can and cannot do with a curve. let step = if field.kind == FieldKind::Range { field.curve.step() } else { field.step }; if let Some(step) = step { out.push_str(" step=\""); escape_into(step, out); out.push('"'); } } fn push_control_attributes( out: &mut String, field: &Field<'_>, filling: &Filling<'_>, id: &str, name: &str, ) { let _ = write!(out, " id=\"{id}\" name=\""); escape_into(name, out); out.push('"'); if field.required { out.push_str(" required"); } // makeover-layout 0.11.0's constraints. The description carries the rule and // this emits the browser's idiom for it, which is the model `required` has // been using since before the crate wrote down that it carried none. // Enforcement is still whoever validated's, and arrives back as `error`. if let Some(limit) = field.max_length { let _ = write!(out, " maxlength=\"{limit}\""); } push_bounds(out, field); // The description asks for the wall-clock value to be submitted as the // moment it names, and in a browser that conversion is script's: `` submits what the user typed and nothing in HTML // turns it into an instant. So this emits the mark and quasi-webview's // `instant.js` does the converting -- the same division as `data-clock`, // where the markup says what to do and the shipped script is what a browser // knows that a description cannot. // // Only DateTime. A date and a time are each half a moment and cannot name // one on their own, so the flag is ignored there rather than emitting a // mark nothing can honour. if field.as_instant && matches!(field.kind, FieldKind::DateTime) { out.push_str(" data-instant=\"true\""); } if field.invalid() { out.push_str(" aria-invalid=\"true\""); } push_described_by(out, field, id); // Last, so that a host attaching a fact of its own can see everything this // emitter decided and cannot be overwritten by it. Duplicate attributes are // the caller's to avoid: HTML takes the first of a repeated pair, so an // attribute spelled here as well as there keeps this crate's answer. if let Some(Markup(attrs)) = filling.control_attrs { out.push(' '); out.push_str(attrs); } } /// The `aria-describedby` naming whatever of the hint and the error exist. /// /// Both associations, in the order they are useful: the standing help, then /// what is currently wrong. goingson's runtime path points describedby at the /// error alone and drops the hint association it never made in the first place; /// naming both here means the hint survives an error appearing. /// /// Its own function because a radio group carries it on the group rather than /// on a control, and one reading of "what describes this field" is the point. fn push_described_by(out: &mut String, field: &Field<'_>, id: &str) { let unit = unit_of(field).is_some(); if field.hint.is_none() && field.error.is_none() && field.note.is_none() && !unit { return; } let mut written = false; out.push_str(" aria-describedby=\""); if field.hint.is_some() { let _ = write!(out, "{id}-hint"); written = true; } // The unit before the error and after the hint, which is the order they are // useful in: what the number is measured in is standing context like the // hint, and what is wrong with it now comes last. if unit { if written { out.push(' '); } let _ = write!(out, "{id}-unit"); written = true; } // The note after the unit and before the error, matching the order the // three are drawn in and the order they are useful in: what the answer // costs is context, and what is wrong with it now still comes last. if field.note.is_some() { if written { out.push(' '); } let _ = write!(out, "{id}-note"); written = true; } if field.error.is_some() { if written { out.push(' '); } let _ = write!(out, "{id}-error"); } out.push('"'); } /// The unit to draw beside this field's value, if there is one to draw. /// /// Two conditions rather than one: the field has to carry a unit and its kind /// has to be one that means anything by it. `FieldKind::measurable` is the /// description answering the second, so this renderer keeps no list of its own /// of which kinds are quantities. fn unit_of<'a>(field: &Field<'a>) -> Option<&'a str> { field.unit.filter(|_| field.kind.measurable()) } /// Whether the field's control is a set of elements rather than one. /// /// A DOM concern rather than a description one, which is why it is decided here /// and not in `makeover-layout`: `for` and `id` are an HTML association and /// egui has no counterpart to get wrong. A `