Skip to main content

max / makeover-webview

0.40.0: give escape and class a streaming form Measured 2026-08-14: rendering a described screen cost ~42x the Askama template it replaces, and the emitter was 85% of it. Every class name went through a format! and every escaped fragment built its own String, so one table row carried roughly eighty transient allocations. Adds escape_into and push_class, the buffer-writing forms, and converts every emitter in the crate to them: field_html_into, cells_html_into, figure_html_into, figures_html_into, meter_html_into, placeholder_html_into, push_column_class and push_column_classes. The returning forms stay as wrappers for callers holding a name rather than a buffer. Output is byte-identical, which is the point: no described screen changes. Checked by building one harness against 0.39.0 and against this, over every field kind crossed with every optional half, both class prefixes, figures, meters, placeholders, lists and the whole stylesheet. 15.6MB of markup, no diff. Measured on a 50-row table plus a five-field form (fw13, release): 0.39.0 3285 allocations ~85us this, same API 399 allocations ~13us this, streaming API 24 allocations ~6us escape_into also copies in runs between the encoded characters rather than per character, which is where most of the remaining time went. Consumer half is quasicoherent's, against the API this adds.
Author: Max Johnson <me@maxj.phd> · 2026-08-15 03:48 UTC
Signed with PGP, not checked
Commit: 1d9e4ca93cbb6a8e360973cd31e45b1a0a4fb1d9
Parent: a7b8522
7 files changed, +520 insertions, -213 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.39.0"
3 + version = "0.40.0"
4 4 edition = "2024"
5 5 # One copy of this renderer per dependency graph, enforced by cargo rather than
6 6 # by remembering. Two versions means the generated stylesheet and the emitted
M src/figure.rs +86 -30
@@ -24,8 +24,8 @@
24 24 //! the app's, and a renderer that emitted them would be naming sizes. Same line
25 25 //! `meter_html` holds when it emits the tones and never the width.
26 26
27 - use crate::form::escape;
28 - use crate::{Emit, class};
27 + use crate::form::escape_into;
28 + use crate::{Emit, push_class};
29 29 use makeover_layout::{Figure, Intent, Tone};
30 30 use std::fmt::Write as _;
31 31
@@ -61,43 +61,59 @@
61 61 /// ```
62 62 #[must_use]
63 63 pub fn figure_html(figure: &Figure<'_>, opts: &Emit) -> String {
64 - let mut html = format!(
65 - "<div class=\"{}\" aria-label=\"{}\"",
66 - class("figure", opts),
67 - escape(&figure_text(figure))
68 - );
64 + let mut html = String::new();
65 + figure_html_into(figure, opts, &mut html);
66 + html
67 + }
68 +
69 + /// One figure, written into a buffer the caller already has.
70 + ///
71 + /// [`figure_html`]'s streaming form, byte-identical to it. The accessible name
72 + /// is escaped a piece at a time rather than built and then escaped, which is
73 + /// the same output for one allocation fewer: the separators [`figure_text`]
74 + /// puts between the pieces contain nothing an escaper would encode.
75 + pub fn figure_html_into(figure: &Figure<'_>, opts: &Emit, out: &mut String) {
76 + out.push_str("<div class=\"");
77 + push_class(out, "figure", opts);
78 + out.push_str("\" aria-label=\"");
79 + escape_into(figure.caption, out);
80 + out.push_str(": ");
81 + escape_into(figure.value, out);
82 + if let Some(change) = figure.change {
83 + out.push_str(", ");
84 + escape_into(change, out);
85 + }
86 + out.push('"');
69 87 // Neutral is the ordinary fact, and `figure_rules` styles the bare class
70 88 // for it. `data-tone="content-muted"` would match a rule that is not there.
71 89 if figure.tone != Tone::Neutral {
72 - let _ = write!(html, " data-tone=\"{}\"", figure.tone.token());
90 + let _ = write!(out, " data-tone=\"{}\"", figure.tone.token());
73 91 }
74 92 // `aria-hidden` on both, because the element above has already said the
75 93 // whole thing. Without it a reader gets the number twice and the noun
76 94 // twice, in the order the eye wants rather than the order the ear does.
77 - let _ = write!(
78 - html,
79 - "><span class=\"{}\" aria-hidden=\"true\">{}</span>\
80 - <span class=\"{}\" aria-hidden=\"true\">{}</span>",
81 - class("figure-value", opts),
82 - escape(figure.value),
83 - class("figure-caption", opts),
84 - escape(figure.caption),
85 - );
95 + out.push_str("><span class=\"");
96 + push_class(out, "figure-value", opts);
97 + out.push_str("\" aria-hidden=\"true\">");
98 + escape_into(figure.value, out);
99 + out.push_str("</span><span class=\"");
100 + push_class(out, "figure-caption", opts);
101 + out.push_str("\" aria-hidden=\"true\">");
102 + escape_into(figure.caption, out);
103 + out.push_str("</span>");
86 104 // 0.13.0. Its own element rather than more of the caption, so a stylesheet
87 105 // can set it smaller and a renderer with one line can drop it first. The
88 106 // tone is already on the wrapper and the rule keys off it from there, which
89 107 // is why the delta carries no `data-tone` of its own: two elements claiming
90 108 // one tone is how they end up disagreeing.
91 109 if let Some(change) = figure.change {
92 - let _ = write!(
93 - html,
94 - "<span class=\"{}\" aria-hidden=\"true\">{}</span>",
95 - class("figure-change", opts),
96 - escape(change),
97 - );
110 + out.push_str("<span class=\"");
111 + push_class(out, "figure-change", opts);
112 + out.push_str("\" aria-hidden=\"true\">");
113 + escape_into(change, out);
114 + out.push_str("</span>");
98 115 }
99 - html.push_str("</div>");
100 - html
116 + out.push_str("</div>");
101 117 }
102 118
103 119 /// Several figures as one strip.
@@ -108,17 +124,29 @@
108 124 /// than in a log.
109 125 #[must_use]
110 126 pub fn figures_html(figures: &[Figure<'_>], opts: &Emit) -> String {
111 - let mut html = format!("<div class=\"{}\">", class("figures", opts));
112 - for figure in figures {
113 - html.push_str(&figure_html(figure, opts));
114 - }
115 - html.push_str("</div>");
127 + let mut html = String::new();
128 + figures_html_into(figures, opts, &mut html);
116 129 html
117 130 }
118 131
132 + /// Several figures as one strip, written into a buffer the caller already has.
133 + ///
134 + /// [`figures_html`]'s streaming form, byte-identical to it. A strip is where the
135 + /// per-figure `String` used to be paid for once per tile.
136 + pub fn figures_html_into(figures: &[Figure<'_>], opts: &Emit, out: &mut String) {
137 + out.push_str("<div class=\"");
138 + push_class(out, "figures", opts);
139 + out.push_str("\">");
140 + for figure in figures {
141 + figure_html_into(figure, opts, out);
142 + }
143 + out.push_str("</div>");
144 + }
145 +
119 146 #[cfg(test)]
120 147 mod tests {
121 148 use super::*;
149 + use crate::form::escape;
122 150
123 151 #[test]
124 152 fn the_noun_reaches_a_reader_before_the_number() {
@@ -218,6 +246,34 @@
218 246 assert!(!html.contains("figure-"));
219 247 }
220 248
249 + /// The accessible name is assembled by [`figure_text`] in one form and
250 + /// escaped a piece at a time in the other, so this is the assertion holding
251 + /// those two readings of the same sentence together.
252 + #[test]
253 + fn a_streamed_figure_is_the_figure_the_other_form_returns() {
254 + let opts = Emit {
255 + class_prefix: "mo-",
256 + ..Emit::default()
257 + };
258 + for figure in [
259 + Figure::new("17", "Total"),
260 + Figure::new("<b>3</b>", "a & b").tone(Tone::Danger),
261 + Figure::new("1,204", "Views & co").change("+12.5% <up>"),
262 + ] {
263 + let mut streamed = String::new();
264 + figure_html_into(&figure, &opts, &mut streamed);
265 + assert_eq!(streamed, figure_html(&figure, &opts));
266 + assert!(
267 + streamed.contains(&format!("aria-label=\"{}\"", escape(&figure_text(&figure)))),
268 + "{streamed}"
269 + );
270 +
271 + let mut strip = String::new();
272 + figures_html_into(&[figure], &opts, &mut strip);
273 + assert_eq!(strip, figures_html(&[figure], &opts));
274 + }
275 + }
276 +
221 277 #[test]
222 278 fn the_prefix_reaches_every_class() {
223 279 // A prefixed build claims its own names, and the two inner spans are
M src/form.rs +218 -127
@@ -40,7 +40,7 @@
40 40 //! keeps an edit buffer; a description carrying it would have to carry a way to
41 41 //! write it back, at which point it is a form model.
42 42
43 - use crate::{Emit, class};
43 + use crate::{Emit, push_class};
44 44 use makeover_layout::{Choice, Field, FieldKind};
45 45 use std::fmt::Write as _;
46 46
@@ -121,31 +121,64 @@
121 121
122 122 /// The document-unique id for a field of this name.
123 123 fn id_for(&self, name: &str) -> String {
124 - match self.id_prefix {
125 - Some(prefix) => format!("{}-{}", escape(prefix), escape(name)),
126 - None => escape(name),
124 + let mut id = String::new();
125 + if let Some(prefix) = self.id_prefix {
126 + escape_into(prefix, &mut id);
127 + id.push('-');
127 128 }
129 + escape_into(name, &mut id);
130 + id
128 131 }
129 132 }
130 133
131 - /// Encode the five characters that let a value stop being a value.
134 + /// Encode the five characters that let a value stop being a value, into a
135 + /// buffer the caller already has.
136 + ///
137 + /// The form the emitters use. [`escape`] is this with a `String` allocated
138 + /// around it, and the allocation is the whole difference: a described screen
139 + /// escapes once per attribute and once per run of text, so a function that
140 + /// returns a `String` allocates a few thousand times to produce one page, where
141 + /// a template engine writes its escaped bytes straight into the output buffer.
142 + /// Measured 2026-08-14 against a real pane, that gap was 85% of a 42x rendering
143 + /// cost, and this is the half of the fix that lives in this crate.
132 144 ///
133 145 /// Sound in element text and in a double-quoted attribute alike, which is the
134 146 /// property `textContent`-based escaping cannot have. Both sinks are covered by
135 147 /// one function so that no call site has to choose, here or downstream.
148 + ///
149 + /// Copies in runs rather than per character. All five encoded characters are
150 + /// ASCII, so a byte scan cannot land inside a multi-byte character and the
151 + /// slice between two of them is always a valid `&str`. Text with nothing to
152 + /// encode — which is most text — is one `push_str` of the whole thing.
153 + pub fn escape_into(text: &str, out: &mut String) {
154 + let mut start = 0;
155 + for (index, byte) in text.bytes().enumerate() {
156 + let encoded = match byte {
157 + b'&' => "&amp;",
158 + b'<' => "&lt;",
159 + b'>' => "&gt;",
160 + b'"' => "&quot;",
161 + b'\'' => "&#39;",
162 + _ => continue,
163 + };
164 + out.push_str(&text[start..index]);
165 + out.push_str(encoded);
166 + start = index + 1;
167 + }
168 + out.push_str(&text[start..]);
169 + }
170 +
171 + /// Encode the five characters that let a value stop being a value.
172 + ///
173 + /// [`escape_into`] with a buffer of its own, for the callers that want a value
174 + /// rather than an append: a caller assembling an attribute out of several
175 + /// pieces, and everything outside this crate that took this function before the
176 + /// buffer-writing form existed. Emitting into a buffer you already hold is the
177 + /// cheaper path and the one this crate's own emitters take.
136 178 #[must_use]
137 179 pub fn escape(text: &str) -> String {
138 180 let mut out = String::with_capacity(text.len());
139 - for ch in text.chars() {
140 - match ch {
141 - '&' => out.push_str("&amp;"),
142 - '<' => out.push_str("&lt;"),
143 - '>' => out.push_str("&gt;"),
144 - '"' => out.push_str("&quot;"),
145 - '\'' => out.push_str("&#39;"),
146 - other => out.push(other),
147 - }
148 - }
181 + escape_into(text, &mut out);
149 182 out
150 183 }
151 184
@@ -196,30 +229,35 @@
196 229 /// name is what submits and is fixed by the description; the id has to be
197 230 /// unique in the document and so carries [`Filling::id_prefix`] when a form
198 231 /// appears more than once.
199 - fn control_attributes(field: &Field<'_>, id: &str, name: &str) -> String {
200 - let mut attrs = format!(" id=\"{id}\" name=\"{}\"", escape(name));
232 + fn push_control_attributes(out: &mut String, field: &Field<'_>, id: &str, name: &str) {
233 + let _ = write!(out, " id=\"{id}\" name=\"");
234 + escape_into(name, out);
235 + out.push('"');
201 236 if field.required {
202 - attrs.push_str(" required");
237 + out.push_str(" required");
203 238 }
204 239 // makeover-layout 0.11.0's constraints. The description carries the rule and
205 240 // this emits the browser's idiom for it, which is the model `required` has
206 241 // been using since before the crate wrote down that it carried none.
207 242 // Enforcement is still whoever validated's, and arrives back as `error`.
208 243 if let Some(limit) = field.max_length {
209 - let _ = write!(attrs, " maxlength=\"{limit}\"");
244 + let _ = write!(out, " maxlength=\"{limit}\"");
210 245 }
211 246 if let Some(min) = field.min {
212 - let _ = write!(attrs, " min=\"{}\"", escape(min));
247 + out.push_str(" min=\"");
248 + escape_into(min, out);
249 + out.push('"');
213 250 }
214 251 if let Some(max) = field.max {
215 - let _ = write!(attrs, " max=\"{}\"", escape(max));
252 + out.push_str(" max=\"");
253 + escape_into(max, out);
254 + out.push('"');
216 255 }
217 256 if field.invalid() {
218 - attrs.push_str(" aria-invalid=\"true\"");
257 + out.push_str(" aria-invalid=\"true\"");
219 258 }
220 259
221 - attrs.push_str(&described_by(field, id));
222 - attrs
260 + push_described_by(out, field, id);
223 261 }
224 262
225 263 /// The `aria-describedby` naming whatever of the hint and the error exist.
@@ -231,18 +269,21 @@
231 269 ///
232 270 /// Its own function because a radio group carries it on the group rather than
233 271 /// on a control, and one reading of "what describes this field" is the point.
234 - fn described_by(field: &Field<'_>, id: &str) -> String {
235 - let mut described = Vec::new();
272 + fn push_described_by(out: &mut String, field: &Field<'_>, id: &str) {
273 + if field.hint.is_none() && field.error.is_none() {
274 + return;
275 + }
276 + out.push_str(" aria-describedby=\"");
236 277 if field.hint.is_some() {
237 - described.push(format!("{id}-hint"));
278 + let _ = write!(out, "{id}-hint");
238 279 }
239 280 if field.error.is_some() {
240 - described.push(format!("{id}-error"));
281 + if field.hint.is_some() {
282 + out.push(' ');
283 + }
284 + let _ = write!(out, "{id}-error");
241 285 }
242 - if described.is_empty() {
243 - return String::new();
244 - }
245 - format!(" aria-describedby=\"{}\"", described.join(" "))
286 + out.push('"');
246 287 }
247 288
248 289 /// Whether the field's control is a set of elements rather than one.
@@ -270,40 +311,44 @@
270 311 ///
271 312 /// `required` lands on every input, which is how HTML says a group is
272 313 /// compulsory: the constraint is satisfied when any one of them is checked.
273 - fn radio_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
314 + fn push_radio(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
274 315 let id = filling.id_for(field.name);
275 316 let value = filling.value.as_text();
276 317 let name = escape(field.name);
277 318
278 - let mut html = format!(
279 - "<div class=\"{}\" role=\"radiogroup\"",
280 - class("form-radio-group", opts)
281 - );
282 - let _ = write!(html, " aria-labelledby=\"{id}-label\"");
319 + out.push_str("<div class=\"");
320 + push_class(out, "form-radio-group", opts);
321 + let _ = write!(out, "\" role=\"radiogroup\" aria-labelledby=\"{id}-label\"");
283 322 if field.invalid() {
284 - html.push_str(" aria-invalid=\"true\"");
323 + out.push_str(" aria-invalid=\"true\"");
285 324 }
286 - html.push_str(&described_by(field, &id));
287 - html.push('>');
325 + push_described_by(out, field, &id);
326 + out.push('>');
288 327
289 328 // A group described with no options emits an empty group, for the reason
290 329 // `Field::options` gives: an app whose option list has not loaded has
291 330 // exactly that, and an empty group says so on screen rather than in a log.
292 331 for (index, opt) in field.options.iter().enumerate() {
293 - let checked = if opt.value == value { " checked" } else { "" };
294 - let required = if field.required { " required" } else { "" };
332 + out.push_str("<label class=\"");
333 + push_class(out, "form-radio-label", opts);
295 334 let _ = write!(
296 - html,
297 - "<label class=\"{}\"><input type=\"radio\" id=\"{id}-{index}\" name=\"{name}\" \
298 - value=\"{}\"{checked}{required}><span>{}</span></label>",
299 - class("form-radio-label", opts),
300 - escape(opt.value),
301 - escape(opt.label)
335 + out,
336 + "\"><input type=\"radio\" id=\"{id}-{index}\" name=\"{name}\" value=\""
302 337 );
338 + escape_into(opt.value, out);
339 + out.push('"');
340 + if opt.value == value {
341 + out.push_str(" checked");
342 + }
343 + if field.required {
344 + out.push_str(" required");
345 + }
346 + out.push_str("><span>");
347 + escape_into(opt.label, out);
348 + out.push_str("</span></label>");
303 349 }
304 350
305 - html.push_str("</div>");
306 - html
351 + out.push_str("</div>");
307 352 }
308 353
309 354 /// The options of a select, with an unmatched current value carried as its own.
@@ -313,60 +358,82 @@
313 358 /// nobody chose. goingson hit exactly that with a backup-retention default of
314 359 /// 10 against a 1/3/7/14/0 list, and grew this stray-option fix locally; it is
315 360 /// here so the second app gets it without hitting the bug first.
316 - fn options_html(options: &[Choice<'_>], value: &str) -> String {
317 - let mut html = String::new();
361 + fn push_options(out: &mut String, options: &[Choice<'_>], value: &str) {
318 362 if !value.is_empty() && !options.iter().any(|opt| opt.value == value) {
363 + // The one place an escaped value is worth keeping: it is written twice,
364 + // as the option's value and as its text.
319 365 let escaped = escape(value);
320 366 let _ = write!(
321 - html,
367 + out,
322 368 "<option value=\"{escaped}\" selected data-unmatched=\"true\">{escaped}</option>"
323 369 );
324 370 }
325 371 for opt in options {
326 - let selected = if opt.value == value { " selected" } else { "" };
327 - let _ = write!(
328 - html,
329 - "<option value=\"{}\"{selected}>{}</option>",
330 - escape(opt.value),
331 - escape(opt.label)
332 - );
372 + out.push_str("<option value=\"");
373 + escape_into(opt.value, out);
374 + out.push('"');
375 + if opt.value == value {
376 + out.push_str(" selected");
377 + }
378 + out.push('>');
379 + escape_into(opt.label, out);
380 + out.push_str("</option>");
333 381 }
334 - html
335 382 }
336 383
337 384 /// The control itself, without its label, hint or error.
338 - fn control_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
385 + fn push_control(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
386 + // Emitted before anything else is computed: a radio group carries its
387 + // descriptions on the group rather than on a control, so none of the
388 + // attributes below belong to it.
389 + if matches!(field.kind, FieldKind::Radio) {
390 + push_radio(out, field, filling, opts);
391 + return;
392 + }
393 +
339 394 let id = filling.id_for(field.name);
340 - let attrs = control_attributes(field, &id, field.name);
341 - let field_class = class("field", opts);
342 - let placeholder = field.placeholder.map_or_else(String::new, |text| {
343 - format!(" placeholder=\"{}\"", escape(text))
344 - });
395 + let placeholder = |out: &mut String| {
396 + if let Some(text) = field.placeholder {
397 + out.push_str(" placeholder=\"");
398 + escape_into(text, out);
399 + out.push('"');
400 + }
401 + };
345 402
346 403 match field.kind {
347 - FieldKind::Radio => radio_html(field, filling, opts),
348 - FieldKind::Textarea => format!(
349 - "<textarea class=\"{field_class}\"{attrs}{placeholder}>{}</textarea>",
350 - escape(filling.value.as_text())
351 - ),
404 + FieldKind::Textarea => {
405 + out.push_str("<textarea class=\"");
406 + push_class(out, "field", opts);
407 + out.push('"');
408 + push_control_attributes(out, field, &id, field.name);
409 + placeholder(out);
410 + out.push('>');
411 + escape_into(filling.value.as_text(), out);
412 + out.push_str("</textarea>");
413 + }
352 414 FieldKind::Select => {
415 + out.push_str("<select class=\"");
416 + push_class(out, "field", opts);
417 + out.push('"');
418 + push_control_attributes(out, field, &id, field.name);
419 + out.push('>');
353 420 // A select described with no options emits an empty select, which
354 421 // says so on screen rather than in a log. That is the description's
355 422 // own position on `Field::options`, not a fallback invented here.
356 - let options = options_html(field.options, filling.value.as_text());
357 - format!("<select class=\"{field_class}\"{attrs}>{options}</select>")
423 + push_options(out, field.options, filling.value.as_text());
424 + out.push_str("</select>");
358 425 }
359 426 FieldKind::Checkbox => {
360 - let checked = if matches!(filling.value, Value::On(true)) {
361 - " checked"
362 - } else {
363 - ""
364 - };
365 - format!(
366 - "<label class=\"{}\"><input type=\"checkbox\"{attrs}{checked}><span>{}</span></label>",
367 - class("form-checkbox-label", opts),
368 - escape(field.label)
369 - )
427 + out.push_str("<label class=\"");
428 + push_class(out, "form-checkbox-label", opts);
429 + out.push_str("\"><input type=\"checkbox\"");
430 + push_control_attributes(out, field, &id, field.name);
431 + if matches!(filling.value, Value::On(true)) {
432 + out.push_str(" checked");
433 + }
434 + out.push_str("><span>");
435 + escape_into(field.label, out);
436 + out.push_str("</span></label>");
370 437 }
371 438 // A secret never carries its value into the markup. `FieldKind::secret`
372 439 // is documented as a value that must not be round-tripped through
@@ -375,20 +442,34 @@
375 442 // reporter serialises. Neither app pre-fills one today, so this costs
376 443 // nothing and closes the door before something does.
377 444 FieldKind::Secret => {
378 - format!("<input type=\"password\" class=\"{field_class}\"{attrs}{placeholder}>")
445 + out.push_str("<input type=\"password\" class=\"");
446 + push_class(out, "field", opts);
447 + out.push('"');
448 + push_control_attributes(out, field, &id, field.name);
449 + placeholder(out);
450 + out.push('>');
379 451 }
380 452 // A file input carries no value, and this is the browser's rule rather
381 453 // than a preference: setting one from markup is refused, because a page
382 454 // that could preselect a path could read a file the user never offered.
383 455 // Nothing upstream needs to know, which is why the exception is here.
384 456 FieldKind::File => {
385 - format!("<input type=\"file\" class=\"{field_class}\"{attrs}>")
457 + out.push_str("<input type=\"file\" class=\"");
458 + push_class(out, "field", opts);
459 + out.push('"');
460 + push_control_attributes(out, field, &id, field.name);
461 + out.push('>');
462 + }
463 + kind => {
464 + let _ = write!(out, "<input type=\"{}\" class=\"", input_type(kind));
465 + push_class(out, "field", opts);
466 + out.push('"');
467 + push_control_attributes(out, field, &id, field.name);
468 + placeholder(out);
469 + out.push_str(" value=\"");
470 + escape_into(filling.value.as_text(), out);
471 + out.push_str("\">");
386 472 }
387 - kind => format!(
388 - "<input type=\"{}\" class=\"{field_class}\"{attrs}{placeholder} value=\"{}\">",
389 - input_type(kind),
390 - escape(filling.value.as_text())
391 - ),
392 473 }
393 474 }
394 475
@@ -421,73 +502,81 @@
421 502 /// ```
422 503 #[must_use]
423 504 pub fn field_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
505 + let mut html = String::new();
506 + field_html_into(field, filling, opts, &mut html);
507 + html
508 + }
509 +
510 + /// One field, written into a buffer the caller already has.
511 + ///
512 + /// [`field_html`]'s streaming form, byte-identical to it. A form is a run of
513 + /// these, so a host building one should hold a single buffer and append each
514 + /// field into it rather than take a `String` per field and concatenate.
515 + pub fn field_html_into(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit, out: &mut String) {
424 516 let id = filling.id_for(field.name);
425 517
426 518 if !field.kind.visible() {
427 519 // Name only, no id: a hidden field is never pointed at by a label or a
428 520 // description, so the one attribute it needs is the one that submits.
429 - return format!(
430 - "<input type=\"hidden\" name=\"{}\" value=\"{}\">",
431 - escape(field.name),
432 - escape(filling.value.as_text())
433 - );
521 + out.push_str("<input type=\"hidden\" name=\"");
522 + escape_into(field.name, out);
523 + out.push_str("\" value=\"");
524 + escape_into(filling.value.as_text(), out);
525 + out.push_str("\">");
526 + return;
434 527 }
435 528
436 - let mut html = format!("<div class=\"{}", class("form-group", opts));
529 + out.push_str("<div class=\"");
530 + push_class(out, "form-group", opts);
437 531 if field.invalid() {
438 - html.push_str(" has-error");
532 + out.push_str(" has-error");
439 533 }
440 534 if field.extended {
441 535 // The disclosure that hides these is a property of the form, not of the
442 536 // field, so the field is marked and the app opens or closes the group.
443 - html.push_str("\" data-extended=\"true");
537 + out.push_str("\" data-extended=\"true");
444 538 }
445 - html.push_str("\">");
539 + out.push_str("\">");
446 540
447 541 // A checkbox labels itself, on the right of the box. Both apps special-case
448 542 // this inline today, which is the tell that it belongs in the description;
449 543 // `FieldKind::labels_itself` is where it went.
450 544 if !field.kind.labels_itself() {
545 + out.push_str("<label class=\"");
546 + push_class(out, "form-label", opts);
451 547 // A group control is named *by* its label rather than pointing at it,
452 548 // so the two carry opposite halves of the association. See
453 549 // `is_group_control`.
454 - let association = if is_group_control(field.kind) {
455 - format!(" id=\"{id}-label\"")
550 + if is_group_control(field.kind) {
551 + let _ = write!(out, "\" id=\"{id}-label\">");
456 552 } else {
457 - format!(" for=\"{id}\"")
458 - };
459 - let _ = write!(
460 - html,
461 - "<label class=\"{}\"{association}>{}</label>",
462 - class("form-label", opts),
463 - escape(field.label)
464 - );
553 + let _ = write!(out, "\" for=\"{id}\">");
554 + }
555 + escape_into(field.label, out);
556 + out.push_str("</label>");
465 557 }
466 558
467 - html.push_str(&control_html(field, filling, opts));
559 + push_control(out, field, filling, opts);
468 560
469 561 if let Some(hint) = field.hint {
470 - let _ = write!(
471 - html,
472 - "<div class=\"{}\" id=\"{id}-hint\">{}</div>",
473 - class("form-hint", opts),
474 - escape(hint)
475 - );
562 + out.push_str("<div class=\"");
563 + push_class(out, "form-hint", opts);
564 + let _ = write!(out, "\" id=\"{id}-hint\">");
565 + escape_into(hint, out);
566 + out.push_str("</div>");
476 567 }
477 568 if let Some(Markup(markup)) = filling.trailing {
478 - html.push_str(markup);
569 + out.push_str(markup);
479 570 }
480 571 if let Some(error) = field.error {
481 - let _ = write!(
482 - html,
483 - "<div class=\"{} visible\" id=\"{id}-error\" role=\"alert\">{}</div>",
484 - class("form-error", opts),
485 - escape(error)
486 - );
572 + out.push_str("<div class=\"");
573 + push_class(out, "form-error", opts);
574 + let _ = write!(out, " visible\" id=\"{id}-error\" role=\"alert\">");
575 + escape_into(error, out);
576 + out.push_str("</div>");
487 577 }
488 578
489 - html.push_str("</div>");
490 - html
579 + out.push_str("</div>");
491 580 }
492 581
493 582 #[cfg(test)]
@@ -530,6 +619,86 @@
530 619 assert!(escape("\"").contains("&quot;"));
531 620 }
532 621
622 + /// The streaming escaper is the one the emitters call and [`escape`] is a
623 + /// buffer around it, so the two cannot be allowed to drift. It copies in
Lines truncated
M src/lib.rs +19 -1
@@ -413,7 +413,25 @@
413 413 /// identical copy of this function until it could call this one.
414 414 #[must_use]
415 415 pub fn class(name: &str, opts: &Emit) -> String {
416 - format!("{}{name}", opts.class_prefix)
416 + let mut out = String::with_capacity(opts.class_prefix.len() + name.len());
417 + push_class(&mut out, name, opts);
418 + out
419 + }
420 +
421 + /// A prefixed class name, written into a buffer the caller already has.
422 + ///
423 + /// The form the emitters use, and the reason it exists is [`escape_into`]'s:
424 + /// every class on every element went through a `format!` before 0.40.0,
425 + /// including the default case where the prefix is empty and the answer is the
426 + /// argument. A described table row carried roughly eighty transient
427 + /// allocations, and this and the escaper were most of them.
428 + ///
429 + /// [`class`] stays for callers holding a name rather than a buffer.
430 + ///
431 + /// [`escape_into`]: crate::form::escape_into
432 + pub fn push_class(out: &mut String, name: &str, opts: &Emit) {
433 + out.push_str(opts.class_prefix);
434 + out.push_str(name);
417 435 }
418 436
419 437 /// The class an option of a selector carries, which is what the rules key off.
M src/list.rs +78 -17
@@ -31,7 +31,7 @@
31 31 //! by counting.
32 32
33 33 use crate::form::Markup;
34 - use crate::{Emit, class};
34 + use crate::{Emit, push_class};
35 35 use makeover_layout::{CellPart, Column, Priority, RowPart, Width};
36 36 use std::fmt::Write as _;
37 37
@@ -93,7 +93,21 @@
93 93 /// apps already key their cell styling on.
94 94 #[must_use]
95 95 pub fn column_class(column: &Column<'_>, opts: &Emit) -> String {
96 - class(&format!("col-{}", column.name), opts)
96 + let mut out = String::new();
97 + push_column_class(&mut out, column, opts);
98 + out
99 + }
100 +
101 + /// The class a cell of this column carries, written into a buffer the caller
102 + /// already has.
103 + ///
104 + /// [`column_class`]'s streaming form. It is the one that runs per cell per row,
105 + /// and it used to allocate twice to get there: once for `col-<name>` and once
106 + /// for the prefix in front of it.
107 + pub fn push_column_class(out: &mut String, column: &Column<'_>, opts: &Emit) {
108 + out.push_str(opts.class_prefix);
109 + out.push_str("col-");
110 + out.push_str(column.name);
97 111 }
98 112
99 113 /// The class saying how wide a cell of this column asks to be.
@@ -138,12 +152,22 @@
138 152 /// should call this rather than assemble the list a second time.
139 153 #[must_use]
140 154 pub fn column_classes(column: &Column<'_>, opts: &Emit) -> String {
141 - format!(
142 - "{} {} {}",
143 - column_class(column, opts),
144 - class(width_class(column.width), opts),
145 - class(drop_class(column.priority), opts)
146 - )
155 + let mut out = String::new();
156 + push_column_classes(&mut out, column, opts);
157 + out
158 + }
159 +
160 + /// Every class a cell of this column carries, written into a buffer the caller
161 + /// already has.
162 + ///
163 + /// [`column_classes`]'s streaming form, and four allocations fewer per cell: the
164 + /// three names and the string joining them.
165 + pub fn push_column_classes(out: &mut String, column: &Column<'_>, opts: &Emit) {
166 + push_column_class(out, column, opts);
167 + out.push(' ');
168 + push_class(out, width_class(column.width), opts);
169 + out.push(' ');
170 + push_class(out, drop_class(column.priority), opts);
147 171 }
148 172
149 173 /// The `grid-template-columns` value for the columns kept at `cutoff`.
@@ -352,22 +376,31 @@
352 376 /// take [`narrowing_css`] and [`column_class`] and keep building its own rows.
353 377 #[must_use]
354 378 pub fn cells_html(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit) -> String {
355 - let cell_class = class("cell", opts);
356 379 let mut html = String::new();
380 + cells_html_into(columns, cells, opts, &mut html);
381 + html
382 + }
357 383
384 + /// A row's cells, written into a buffer the caller already has.
385 + ///
386 + /// [`cells_html`]'s streaming form, byte-identical to it, and the one a host
387 + /// rendering a table should call: a row is emitted once per row per render, so
388 + /// this is where a `String` per cell class is paid for most often.
389 + pub fn cells_html_into(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit, out: &mut String) {
358 390 for column in columns {
359 391 let found = cells.iter().find(|cell| cell.column == column.name);
360 - let mut classes = format!("{cell_class} {}", column_classes(column, opts));
392 + out.push_str("<div class=\"");
393 + push_class(out, "cell", opts);
394 + out.push(' ');
395 + push_column_classes(out, column, opts);
361 396 if let Some(part) = found.and_then(|cell| cell.part) {
362 - let _ = write!(classes, " {}", class(cell_part_class(part), opts));
397 + out.push(' ');
398 + push_class(out, cell_part_class(part), opts);
363 399 }
364 - let _ = write!(
365 - html,
366 - "<div class=\"{classes}\">{}</div>",
367 - found.map_or("", |cell| cell.content.0)
368 - );
400 + out.push_str("\">");
401 + out.push_str(found.map_or("", |cell| cell.content.0));
402 + out.push_str("</div>");
369 403 }
370 - html
371 404 }
372 405
373 406 #[cfg(test)]
@@ -521,6 +554,34 @@
521 554 );
522 555 }
523 556
557 + /// A row is emitted once per row per render, so the streaming form is the
558 + /// one a host should call and the two have to agree byte for byte.
559 + #[test]
560 + fn streamed_cells_are_the_cells_the_other_form_returns() {
561 + let opts = Emit {
562 + class_prefix: "mk-",
563 + ..Emit::default()
564 + };
565 + let cells = [
566 + Cell {
567 + column: "due",
568 + part: Some(CellPart::Value),
569 + content: Markup("tomorrow"),
570 + },
571 + Cell::new("description", Markup("<span>Ship it</span>")),
572 + ];
573 + for cells in [&cells[..], &[]] {
574 + let mut streamed = String::new();
575 + cells_html_into(&columns(), cells, &opts, &mut streamed);
576 + assert_eq!(streamed, cells_html(&columns(), cells, &opts));
577 + }
578 + for column in &columns() {
579 + let mut streamed = String::new();
580 + push_column_classes(&mut streamed, column, &opts);
581 + assert_eq!(streamed, column_classes(column, &opts));
582 + }
583 + }
584 +
524 585 #[test]
525 586 fn a_cell_naming_no_column_is_dropped() {
526 587 let cells = [Cell::new("nonexistent", Markup("nowhere"))];
M src/meter.rs +56 -14
@@ -20,8 +20,8 @@
20 20 //! app taste — goingson already says it with `Tone::Danger` — and a renderer
21 21 //! that picked a stripe for everyone would be decorating rather than describing.
22 22
23 - use crate::form::escape;
24 - use crate::{Emit, class};
23 + use crate::form::escape_into;
24 + use crate::{Emit, push_class};
25 25 use makeover_layout::{Intent, Meter, Tone};
26 26 use std::fmt::Write as _;
27 27
@@ -62,34 +62,76 @@
62 62 /// way.
63 63 #[must_use]
64 64 pub fn meter_html(meter: &Meter<'_>, opts: &Emit) -> String {
65 - let progress = class("progress", opts);
66 - let fill = class("progress-fill", opts);
65 + let mut html = String::new();
66 + meter_html_into(meter, opts, &mut html);
67 + html
68 + }
69 +
70 + /// A meter, written into a buffer the caller already has.
71 + ///
72 + /// [`meter_html`]'s streaming form, byte-identical to it. The accessible name is
73 + /// written a piece at a time rather than built and then escaped: the numbers
74 + /// carry nothing an escaper would encode, so only the noun goes through one.
75 + pub fn meter_html_into(meter: &Meter<'_>, opts: &Emit, out: &mut String) {
67 76 let reported = meter.done.min(meter.total);
68 77
69 - let mut html = format!(
70 - "<div class=\"{progress}\" role=\"progressbar\" aria-valuenow=\"{reported}\" \
71 - aria-valuemin=\"0\" aria-valuemax=\"{}\" aria-label=\"{}\">",
72 - meter.total,
73 - escape(&meter_text(meter))
78 + out.push_str("<div class=\"");
79 + push_class(out, "progress", opts);
80 + let _ = write!(
81 + out,
82 + "\" role=\"progressbar\" aria-valuenow=\"{reported}\" \
83 + aria-valuemin=\"0\" aria-valuemax=\"{}\" aria-label=\"{} of {}",
84 + meter.total, meter.done, meter.total
74 85 );
86 + if let Some(label) = meter.label {
87 + out.push(' ');
88 + escape_into(label, out);
89 + }
90 + out.push_str("\">");
75 91
76 - let _ = write!(html, "<div class=\"{fill}\"");
92 + out.push_str("<div class=\"");
93 + push_class(out, "progress-fill", opts);
94 + out.push('"');
77 95 // Neutral is the untoned bar, and `progress_rules` gives it `--action`
78 96 // rather than a tone attribute. Emitting `data-tone="content-muted"` would
79 97 // match a rule that does not exist and read as disabled if it did.
80 98 if meter.tone != Tone::Neutral {
81 - let _ = write!(html, " data-tone=\"{}\"", meter.tone.token());
99 + let _ = write!(out, " data-tone=\"{}\"", meter.tone.token());
82 100 }
83 101 if meter.overflowing() {
84 - html.push_str(" data-over=\"true\"");
102 + out.push_str(" data-over=\"true\"");
85 103 }
86 - let _ = write!(html, " style=\"width: {}%\"></div></div>", meter.percent());
87 - html
104 + let _ = write!(out, " style=\"width: {}%\"></div></div>", meter.percent());
88 105 }
89 106
90 107 #[cfg(test)]
91 108 mod tests {
92 109 use super::*;
110 + use crate::form::escape;
111 +
112 + /// The accessible name is built by [`meter_text`] in one form and written a
113 + /// piece at a time in the other, and the over-run case is the one where the
114 + /// numbers differ from what the bar draws.
115 + #[test]
116 + fn a_streamed_meter_is_the_meter_the_other_form_returns() {
117 + let opts = Emit {
118 + class_prefix: "mk-",
119 + ..Emit::default()
120 + };
121 + for meter in [
122 + Meter::new(0, 0),
123 + Meter::new(3, 7).label("sub & tasks"),
124 + Meter::new(9, 7).tone(Tone::Danger).label("<tasks>"),
125 + ] {
126 + let mut streamed = String::new();
127 + meter_html_into(&meter, &opts, &mut streamed);
128 + assert_eq!(streamed, meter_html(&meter, &opts));
129 + assert!(
130 + streamed.contains(&format!("aria-label=\"{}\"", escape(&meter_text(&meter)))),
131 + "{streamed}"
132 + );
133 + }
134 + }
93 135
94 136 #[test]
95 137 fn a_full_bar_says_whether_it_ran_over() {
M src/placeholder.rs +62 -23
@@ -24,8 +24,8 @@
24 24 //! does. The caller states that what it is passing is trusted; nothing here can
25 25 //! check that for them.
26 26
27 - use crate::form::{Markup, escape};
28 - use crate::{Emit, class};
27 + use crate::form::{Markup, escape_into};
28 + use crate::{Emit, push_class};
29 29 use makeover_layout::{Intent, Readiness, Tone};
30 30 use std::fmt::Write as _;
31 31
@@ -54,43 +54,57 @@
54 54 action: Option<Markup<'_>>,
55 55 opts: &Emit,
56 56 ) -> String {
57 + let mut html = String::new();
58 + placeholder_html_into(state, message, action, opts, &mut html);
59 + html
60 + }
61 +
62 + /// A region's stand-in, written into a buffer the caller already has.
63 + ///
64 + /// [`placeholder_html`]'s streaming form, byte-identical to it. A state that
65 + /// draws its own content appends nothing, which is what the empty string the
66 + /// other form returns means.
67 + pub fn placeholder_html_into(
68 + state: Readiness,
69 + message: &str,
70 + action: Option<Markup<'_>>,
71 + opts: &Emit,
72 + out: &mut String,
73 + ) {
57 74 if state.shows_content() {
58 - return String::new();
75 + return;
59 76 }
60 77
61 78 let name = state_name(state);
62 - let mut html = format!(
63 - "<div class=\"{}\" data-state=\"{name}\"",
64 - class("placeholder", opts)
65 - );
79 + out.push_str("<div class=\"");
80 + push_class(out, "placeholder", opts);
81 + let _ = write!(out, "\" data-state=\"{name}\"");
66 82
67 83 // Derived, not carried. "Nothing here yet" and "this broke" mean the same
68 84 // thing in every app that will ever have them, which is what separates this
69 85 // from a meter's tone.
70 86 if state.tone() != Tone::Neutral {
71 - let _ = write!(html, " data-tone=\"{}\"", state.tone().token());
87 + let _ = write!(out, " data-tone=\"{}\"", state.tone().token());
72 88 }
73 89 if state.tone() == Tone::Danger {
74 - html.push_str(" role=\"alert\"");
90 + out.push_str(" role=\"alert\"");
75 91 } else {
76 - html.push_str(" role=\"status\" aria-live=\"polite\"");
92 + out.push_str(" role=\"status\" aria-live=\"polite\"");
77 93 }
78 94
79 - let _ = write!(
80 - html,
81 - "><p class=\"{}\">{}</p>",
82 - class("placeholder-text", opts),
83 - escape(message)
84 - );
95 + out.push_str("><p class=\"");
96 + push_class(out, "placeholder-text", opts);
97 + out.push_str("\">");
98 + escape_into(message, out);
99 + out.push_str("</p>");
85 100 if let Some(Markup(markup)) = action {
86 - let _ = write!(
87 - html,
88 - "<div class=\"{}\">{markup}</div>",
89 - class("placeholder-action", opts)
90 - );
101 + out.push_str("<div class=\"");
102 + push_class(out, "placeholder-action", opts);
103 + out.push_str("\">");
104 + out.push_str(markup);
105 + out.push_str("</div>");
91 106 }
92 - html.push_str("</div>");
93 - html
107 + out.push_str("</div>");
94 108 }
95 109
96 110 /// The `data-state` value for a state.
@@ -113,6 +127,31 @@
113 127 mod tests {
114 128 use super::*;
115 129
130 + /// Including the state that draws nothing: appending nothing and returning
131 + /// an empty string have to stay the same answer.
132 + #[test]
133 + fn a_streamed_placeholder_is_the_placeholder_the_other_form_returns() {
134 + let opts = Emit {
135 + class_prefix: "mk-",
136 + ..Emit::default()
137 + };
138 + for state in [
139 + Readiness::Ready,
140 + Readiness::Pending,
141 + Readiness::Empty,
142 + Readiness::Failed,
143 + ] {
144 + for action in [None, Some(Markup("<button>go</button>"))] {
145 + let mut streamed = String::new();
146 + placeholder_html_into(state, "none & <yet>", action, &opts, &mut streamed);
147 + assert_eq!(
148 + streamed,
149 + placeholder_html(state, "none & <yet>", action, &opts)
150 + );
151 + }
152 + }
153 + }
154 +
116 155 #[test]
117 156 fn the_state_that_shows_content_draws_no_stand_in() {
118 157 // Not an empty box: nothing at all, or every ready region gains an