Skip to main content

max / quasi

40.5 KB · 922 lines History Blame Raw
1 //! The webview renderer for [`quasi_router`].
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! # Why this is not in `makeover-webview`
6 //!
7 //! A [`Screen`](quasi_router::Screen) renderer has to import `quasi-router`,
8 //! and the makeover/quasi boundary is audience: a makeover crate is something
9 //! another developer might use on its own, and quasi is what you use once you
10 //! are committed to the whole stack. A `makeover-webview` that depended on
11 //! quasi would stop passing its own test.
12 //!
13 //! So the split runs along that line and not along "who emits markup".
14 //! `makeover-webview` owns the pieces whose description is settled and shared —
15 //! fields, cell containers, the narrowing rules, the escaping — and this crate
16 //! owns screen assembly, regions, and the transport. Every piece it can borrow
17 //! from over there, it does: there is no second field emitter here.
18 //!
19 //! # What phase B is, once the pieces exist
20 //!
21 //! Assembly, mostly. The vocabulary is closed at two arrangements, seven region
22 //! kinds and twelve node variants, and none of them is a widget: the admission
23 //! test in [`quasi_router::screen`] is that a node composes something
24 //! `makeover-layout` already names. So this crate has no opinions to hold, and
25 //! the file to read for the interesting ones is [`node`], where htmx enters in
26 //! a single function.
27 //!
28 //! # The transport is replaceable, and that is measurable here
29 //!
30 //! Nobody hand-writes `hx-post`. Decision 13 parks the fixi question on the
31 //! grounds that a transport nothing authors by hand is a transport that can be
32 //! swapped, and the check on that claim is that
33 //! [`node::action_attrs`](node) is the only place in this crate naming htmx at
34 //! all. A test asserts it.
35 //!
36 //! # Which htmx
37 //!
38 //! htmx 4, and never 2.x. What that decides here, all of it inside the
39 //! seam above plus the shell:
40 //!
41 //! - The swap is `outerMorph`, a swap style 4 ships. Under 2.x morphing came
42 //! from idiomorph, so the shell carried the extension and the emitter asked
43 //! for a morph only when it was loaded. Both are gone.
44 //! - The lock is `hx-disable`, which is 4's name for 2.x's `hx-disabled-elt`.
45 //! 4 spells 2.x's `hx-disable` -- skip this subtree -- as `hx-ignore`, and
46 //! nothing here emits that.
47 //! - Inheritance is explicit: the headers a document sends go on the body as
48 //! `hx-headers:inherited`. An emitter knows every attribute it writes, so
49 //! this costs one attribute name and no design.
50 //! - No `htmx-config` meta tag. 4 swaps every status but 204 and 304, which is
51 //! what decision 9's classified errors needed and had to be configured for
52 //! under 2.x.
53 //! - The scripts this crate ships listen on `htmx:after:settle`, 4's spelling
54 //! of the settle event.
55 //!
56 //! A host serving 2.x behind [`Shell::htmx_src`] gets a page whose controls
57 //! fetch nothing and whose swaps destroy state, so the bundle and this crate
58 //! move together.
59
60 //! # The other language this crate emits
61 //!
62 //! _hyperscript, for what happens without a request. It has the same seam htmx
63 //! does: every program comes out of [`hyperscript`], and a test asserts
64 //! nothing else writes one. [`Shell::hyperscript_src`] is the runtime that
65 //! reads them.
66
67 pub mod chrome;
68 mod clock;
69 pub mod frame;
70 mod hyperscript;
71 mod node;
72 mod shell;
73 pub mod stage;
74 pub mod vocabulary;
75
76 #[cfg(test)]
77 mod tests;
78
79 pub use crate::shell::{Parts, Shell};
80
81 /// The selection script this renderer ships, as source.
82 ///
83 /// A host writes it out where [`Shell::selection_src`] says it is served from.
84 /// Shipped as a constant rather than as a file each app copies, so the
85 /// behaviour and the markup that feeds it move together: the script reads two
86 /// hooks this crate emits (`.row-select` on a tick, `data-over` on a control
87 /// that commits one), and a stale copy in an app's static directory is a
88 /// silently dead bulk bar.
89 ///
90 /// It exists because two things a bulk bar does cannot be said in a
91 /// description: how many rows are ticked, and that a control over none of them
92 /// should not be pressable. The ticks are the browser's until something submits
93 /// them. See [`Shell::selection_src`] for the whole reasoning and for what a
94 /// host that does not serve it gets instead.
95 ///
96 /// ```no_run
97 /// # fn main() -> std::io::Result<()> {
98 /// std::fs::write("static/quasi-selection.js", quasi_webview::SELECTION_JS)?;
99 /// # Ok(())
100 /// # }
101 /// ```
102 pub const SELECTION_JS: &str = include_str!("selection.js");
103
104 /// This crate's clock script, for a host to serve beside its other assets.
105 ///
106 /// The three time-derived [`Node`] members carry an instant and which way the
107 /// readout runs against it; the words and the rate are the renderer's, and in
108 /// this renderer the words after the first set are written by a browser. This
109 /// is that script.
110 ///
111 /// Two rates, one per granularity, and both are in the file rather than here:
112 /// a second for [`Clock::Since`](quasi_router::Clock::Since) and
113 /// [`Clock::Until`](quasi_router::Clock::Until), which read `h:mm:ss`, and
114 /// thirty seconds for [`Clock::Age`](quasi_router::Clock::Age), which reads
115 /// "3h ago" and changes on the minute at its finest. They match the numbers
116 /// `quasi-tui` and `quasi-immediate` picked, so a screen described once and
117 /// drawn three times does not go stale at three different rates.
118 ///
119 /// ```no_run
120 /// # fn main() -> std::io::Result<()> {
121 /// std::fs::write("static/quasi-clock.js", quasi_webview::CLOCK_JS)?;
122 /// # Ok(())
123 /// # }
124 /// ```
125 pub const CLOCK_JS: &str = include_str!("clock.js");
126
127 /// This crate's download script, for a host to serve beside its other assets.
128 ///
129 /// A route can answer [`Outcome::File`](quasi_router::Outcome::File) and
130 /// `quasi-http` writes `Content-Disposition`, which is everything a plain link
131 /// needs and nothing an htmx control can use: htmx makes an XHR, an XHR is not
132 /// a navigation, and the browser saves nothing. This script cancels the swap
133 /// and hands the body over as a download itself.
134 ///
135 /// Shipped as a constant for [`SELECTION_JS`]'s reason: it reads a header this
136 /// stack writes, so the behaviour and the thing it reads move together, and a
137 /// stale copy in an app's static directory is an export control that pastes a
138 /// JSON file into the page.
139 ///
140 /// Its one limit is stated in the file and is real: the body arrives as a
141 /// string, so a payload that is not valid UTF-8 is mangled. Every export in the
142 /// tree is JSON, CSV or ICS.
143 ///
144 /// ```no_run
145 /// # fn main() -> std::io::Result<()> {
146 /// std::fs::write("static/quasi-download.js", quasi_webview::DOWNLOAD_JS)?;
147 /// # Ok(())
148 /// # }
149 /// ```
150 pub const DOWNLOAD_JS: &str = include_str!("download.js");
151
152 /// This crate's fill script, for a host to serve beside its other assets.
153 ///
154 /// [`Act::fills`](quasi_router::Act::fills) names a field on the same screen
155 /// that receives the act's value, and this is the browser's half of "the
156 /// renderer decides where in it the value lands": at the selection, which is
157 /// where every editor a reader has used puts what they insert.
158 ///
159 /// Shipped as a constant for [`SELECTION_JS`]'s reason, and a script rather
160 /// than an emitted [`hyperscript`] program for a sharper one. The value is app
161 /// text — a media file's name reaches the page inside it — and that file's one
162 /// standing rule is that no program is built out of text a user typed. As two
163 /// escaped attributes it is data, which is what it is.
164 ///
165 /// `None` drops it, and the loss is visible on the first press: the control
166 /// runs its own action and writes nothing.
167 ///
168 /// ```no_run
169 /// # fn main() -> std::io::Result<()> {
170 /// std::fs::write("static/quasi-fill.js", quasi_webview::FILL_JS)?;
171 /// # Ok(())
172 /// # }
173 /// ```
174 pub const FILL_JS: &str = include_str!("fill.js");
175
176 /// This crate's clipboard script, for a host to serve beside its other assets.
177 ///
178 /// [`Act::copies`](quasi_router::Act::copies) says a press puts a value on the
179 /// clipboard, and this is the browser's half. It replaces seven
180 /// `window.<name>` globals across 14 MNW sites, six of which read the text
181 /// back off the DOM at press time.
182 ///
183 /// Shipped as a constant for [`SELECTION_JS`]'s reason, and a script rather
184 /// than an emitted [`hyperscript`] program for [`FILL_JS`]'s: the value is app
185 /// text -- a licence key, an embed snippet -- and that file's one standing rule
186 /// is that no program is built out of text a user typed.
187 ///
188 /// It does not say "Copied!". That is a temporary label, which is
189 /// `makeover-timing`'s question rather than this one; see
190 /// [`Act::copies`](quasi_router::Act::copies) for why the two were separated.
191 ///
192 /// `None` drops it, and the loss is a copy button that does nothing when
193 /// pressed. That is worse than the other scripts degrade, and it is why the
194 /// member emits no fallback markup: there is no copying without a script, on
195 /// any browser.
196 ///
197 /// ```no_run
198 /// # fn main() -> std::io::Result<()> {
199 /// std::fs::write("static/quasi-copy.js", quasi_webview::COPY_JS)?;
200 /// # Ok(())
201 /// # }
202 /// ```
203 pub const COPY_JS: &str = include_str!("copy.js");
204
205 /// This crate's reveal script, for a host to serve beside its other assets.
206 ///
207 /// A [`Slot::revealed_by`] names the control that brings a region out and the
208 /// value that does it, and this is the browser's half: it reads what the
209 /// control holds and shows or hides the region, with no request at all. The
210 /// six MNW toggles it replaces are hand-written functions that each read
211 /// `.checked` or `.value` and flip a class.
212 ///
213 /// Shipped as a constant for [`SELECTION_JS`]'s reason, and a script rather
214 /// than an emitted [`hyperscript`] program for [`FILL_JS`]'s: the values a
215 /// region watches for are app text, and that file's one standing rule is that
216 /// no program is built out of text a user typed. As escaped attributes they are
217 /// data, which is what they are.
218 ///
219 /// `None` drops it, and the loss is a page where every conditional region is
220 /// out. More content rather than less, which is the direction the rest of this
221 /// renderer degrades in.
222 ///
223 /// ```no_run
224 /// # fn main() -> std::io::Result<()> {
225 /// std::fs::write("static/quasi-reveal.js", quasi_webview::REVEAL_JS)?;
226 /// # Ok(())
227 /// # }
228 /// ```
229 ///
230 /// [`Slot::revealed_by`]: quasi_router::Slot::revealed_by
231 pub const REVEAL_JS: &str = include_str!("reveal.js");
232
233 /// This crate's repeat script, for a host to serve beside its other assets.
234 ///
235 /// A [`Field::repeats`] says a question is answered zero or more times with
236 /// the reader adding and removing the slots, and this is the browser's half of
237 /// the adding and removing: the emitter writes a `template` holding one blank
238 /// slot, and this clones it, renumbers the names, and keeps the two controls
239 /// in step with the floor and the ceiling.
240 ///
241 /// Shipped as a constant for [`SELECTION_JS`]'s reason, and a script rather
242 /// than an emitted [`hyperscript`] program for [`FILL_JS`]'s: what it reads is
243 /// app text — the question's name and the label its slots are numbered from —
244 /// and that file's one standing rule is that no program is built out of text a
245 /// user typed.
246 ///
247 /// `None` drops it, and the loss is a form showing every slot the description
248 /// offered with two controls that do nothing. More content rather than less,
249 /// which is the direction the rest of this renderer degrades in.
250 ///
251 /// ```no_run
252 /// # fn main() -> std::io::Result<()> {
253 /// std::fs::write("static/quasi-repeat.js", quasi_webview::REPEAT_JS)?;
254 /// # Ok(())
255 /// # }
256 /// ```
257 ///
258 /// [`Field::repeats`]: quasi_router::Field::repeats
259 pub const REPEAT_JS: &str = include_str!("repeat.js");
260
261 /// This crate's awaiting script, for a host to serve beside its other assets.
262 ///
263 /// Wiki `loading-and-progress-standard`. The node emitter has written `data-
264 /// awaiting` since the vocabulary gained `Awaiting`, and `makeover-webview`
265 /// 0.60.0 draws both of its values. Neither knows *when* a wait is running,
266 /// and this is the half that does: it puts `aria-busy` on the element that
267 /// made the request for as long as the request is in flight, which is what the
268 /// design system keys the mark off.
269 ///
270 /// `aria-busy` rather than a class of ours, so the state is announced as well
271 /// as drawn, and so a host driving the same markup without htmx can say the
272 /// same thing the same way.
273 ///
274 /// For a control whose wait carries a measured size it also sets
275 /// `--awaiting-share` from bytes the browser reports as delivered, and from
276 /// nothing else. Rule 1: what is done over what there is, never a remaining
277 /// time and never a rate extrapolated forward. A control with no readable
278 /// progress keeps a share of zero and draws an empty trough, which is the
279 /// honest picture rather than a bar walking forward on a stalled transfer.
280 ///
281 /// **Not independent of htmx**, unlike the reveal, repeat and fill scripts:
282 /// what it listens to is the request lifecycle. [`Shell::without_htmx`] drops
283 /// it with the rest.
284 ///
285 /// `None` drops it, and the loss is a page where nothing marks a wait and every
286 /// control still locks itself through `hx-disable`. Less decoration, never less
287 /// behaviour, which is the direction the rest of this renderer degrades in.
288 ///
289 /// ```no_run
290 /// # fn main() -> std::io::Result<()> {
291 /// std::fs::write("static/quasi-awaiting.js", quasi_webview::AWAITING_JS)?;
292 /// # Ok(())
293 /// # }
294 /// ```
295 ///
296 /// [`Shell::without_htmx`]: crate::Shell::without_htmx
297 pub const AWAITING_JS: &str = include_str!("awaiting.js");
298
299 /// This crate's instant script, for a host to serve beside its other assets.
300 ///
301 /// [`makeover_layout::Field::as_instant`] is a description saying a
302 /// wall-clock value should be submitted as the moment it names, and declining
303 /// to say how, because how is the renderer's: the browser is the only party
304 /// that knows what "your computer's time zone" means. This is that half.
305 ///
306 /// `makeover-webview` marks the input `data-instant`; this rewrites the value
307 /// on the request htmx is about to make. Nothing the reader sees changes -- the
308 /// box keeps showing the time they typed, and the wire carries the moment.
309 ///
310 /// **Not independent of htmx**: what it listens to is the request lifecycle.
311 /// [`Shell::without_htmx`] drops it with the rest.
312 ///
313 /// ```no_run
314 /// # fn main() -> std::io::Result<()> {
315 /// std::fs::write("static/quasi-instant.js", quasi_webview::INSTANT_JS)?;
316 /// # Ok(())
317 /// # }
318 /// ```
319 ///
320 /// [`Shell::without_htmx`]: crate::Shell::without_htmx
321 pub const INSTANT_JS: &str = include_str!("instant.js");
322
323 /// The focus script, to serve beside the renderer's other assets.
324 ///
325 /// htmx saves and restores focus only around the *main* swap target. A focused
326 /// control inside an out-of-band region is destroyed outside that window, so
327 /// focus falls to `body`, the caret goes with it, and the next Tab starts from
328 /// the top of the document. This renderer emits out-of-band swaps -- see
329 /// [`Serves::with_oob`] -- so it owns the gap.
330 ///
331 /// It records what was focused when a request went out and puts it back after
332 /// everything settles, **only where focus was actually lost**. htmx restores
333 /// the main target itself, correctly, and a second party moving focus after it
334 /// would be two scripts fighting over one frame.
335 ///
336 /// An element is found again by `id`, or by `name` where it has no `id`. Both
337 /// are addresses the description chose. Anything with neither is left alone,
338 /// and so is a control that is genuinely gone: guessing at a neighbour by
339 /// position lands focus on the wrong thing, which is worse than losing it.
340 ///
341 /// Not independent of htmx: it reads the request lifecycle, so
342 /// [`Shell::without_htmx`] drops it. Nothing is lost there, since without htmx
343 /// every response is a navigation and a navigation has no focus to preserve.
344 ///
345 /// `None` on [`Shell::focus_src`] drops it, and the loss falls entirely on
346 /// readers navigating by keyboard. It is silent to everyone else, which is both
347 /// why it is on by default and why it went unnoticed until it was looked for.
348 ///
349 /// ```no_run
350 /// # fn main() -> std::io::Result<()> {
351 /// std::fs::write("static/quasi-focus.js", quasi_webview::FOCUS_JS)?;
352 /// # Ok(())
353 /// # }
354 /// ```
355 ///
356 /// [`Serves::with_oob`]: quasi_http::Serves::with_oob
357 /// [`Shell::without_htmx`]: crate::Shell::without_htmx
358 /// [`Shell::focus_src`]: crate::Shell::focus_src
359 pub const FOCUS_JS: &str = include_str!("focus.js");
360
361 /// This crate's menu script, for a host to serve beside its other assets.
362 ///
363 /// [`makeover_layout::Fallback::Menu`] says the members a
364 /// [`Shed`](makeover_layout::Fallback::Shed) would drop move into one overflow
365 /// control instead. Knowing that a run is out of room is a measurement, and CSS
366 /// cannot make it: `@container` compares against a `<length>`, so every honest
367 /// spelling needs an authored breakpoint, which is the thing the room ruling
368 /// forbids. `makeover-webview` says so in `run_rules` and leaves the mechanism
369 /// here.
370 ///
371 /// It reads `.run-menu` and the two priority classes that crate already emits,
372 /// and it builds its control out of that crate's `.run-overflow` classes, so the
373 /// surface is the design system's and only the measuring is this file's.
374 ///
375 /// A tab strip is one member of its run and its tabs are what fold, which is
376 /// the shape all five described MNW strips have.
377 ///
378 /// **Not independent of anything, and it degrades by default.** A page that does
379 /// not run it keeps `.run-menu`'s own `flex-wrap: wrap`: every member stays
380 /// reachable, which is the property, with only the folding missing. The
381 /// `data-menu` mark this script writes is what turns wrapping off, so the
382 /// fallback is what happens when nothing runs rather than what happens when
383 /// something fails.
384 ///
385 /// Independent of htmx, so [`Shell::without_htmx`] keeps it: it listens for a
386 /// settle when there is one and measures on resize and on load regardless.
387 ///
388 /// ```no_run
389 /// # fn main() -> std::io::Result<()> {
390 /// std::fs::write("static/quasi-menu.js", quasi_webview::MENU_JS)?;
391 /// # Ok(())
392 /// # }
393 /// ```
394 ///
395 /// [`Shell::without_htmx`]: crate::Shell::without_htmx
396 pub const MENU_JS: &str = include_str!("menu.js");
397
398 /// The browser's half of an outline: folding a branch.
399 ///
400 /// [`Row::depth`] and [`Row::open`] describe a hierarchy as a flat list of
401 /// rows each saying how deep it sits, and the emitter already read that once —
402 /// every row is in the document, and the ones under a shut branch arrived
403 /// `hidden`. What is left is the gesture, and a gesture that changes only what
404 /// the reader is looking at belongs in the browser. See [`Row::open`] for why
405 /// folding is not a write and asks the app nothing.
406 ///
407 /// It reads two hooks the emitter writes and nothing else: `[data-disclose]` on
408 /// a branch's chevron and `data-depth` on the rows.
409 ///
410 /// Independent of htmx, so [`Shell::without_htmx`] keeps it: it delegates one
411 /// listener on the document, which covers a list arriving in a swap.
412 ///
413 /// `None` drops it, and the loss is an outline in the shape the description
414 /// gave it — every row still reachable, with the shut branches staying shut.
415 /// More content than a reader asked for rather than less, which is the
416 /// direction the rest of this renderer degrades in.
417 ///
418 /// ```no_run
419 /// # fn main() -> std::io::Result<()> {
420 /// std::fs::write("static/quasi-outline.js", quasi_webview::OUTLINE_JS)?;
421 /// # Ok(())
422 /// # }
423 /// ```
424 ///
425 /// [`Row::depth`]: quasi_router::Row::depth
426 /// [`Row::open`]: quasi_router::Row::open
427 /// [`Shell::without_htmx`]: crate::Shell::without_htmx
428 pub const OUTLINE_JS: &str = include_str!("outline.js");
429
430 /// How often this renderer looks again at a [`Slot::live`] region.
431 ///
432 /// The description says the contents move and says nothing about how often to
433 /// look; the rate is picked here, once, so that every live region in every
434 /// screen this crate draws moves at one speed. Two MNW templates hand-write
435 /// `hx-trigger="every 10s"` today and nothing made them comparable, which is
436 /// what one home for the number buys.
437 ///
438 /// The corpus' own number rather than a chosen one, on
439 /// [`Consult::SETTLES`](quasi_router::Consult::SETTLES)' precedent.
440 ///
441 /// [`Slot::live`]: quasi_router::Slot::live
442 pub const CADENCE: std::time::Duration = std::time::Duration::from_secs(10);
443
444 /// This renderer's class, and what it therefore owes
445 /// [`Destination::Local`](quasi_router::Destination::Local).
446 ///
447 /// This is the only hybrid renderer in the stack: it emits markup that some
448 /// other runtime performs later, so it has to decide at emit time whether a
449 /// keystroke becomes a request. The two client renderers get the answer for
450 /// free and this one does not.
451 ///
452 /// What it emits for a local *action* is still the mark, `data-local`, and not
453 /// the behaviour, because what happens locally is named by the member carrying
454 /// the action rather than by the address, which has none. A local action whose
455 /// act names no such member draws a control that performs nothing, which is a
456 /// gap rather than a regression, and the attribute is what makes it findable.
457 ///
458 /// Two members name one today, and both are performed. A field that owns its
459 /// suggestion list is the [`hyperscript`] program `combobox` writes. An act
460 /// that deposits a value into a field is [`FILL_JS`], a script rather than a
461 /// program, because its value is app text and the one rule the emitted
462 /// programs keep is that none of them is built out of any.
463 ///
464 /// The renderer also performs the local interaction it emits chrome for: a
465 /// region showing one child at a time carries the programs that move between
466 /// its frames.
467 pub const CLASS: quasi_router::Renderer = quasi_router::Renderer::Hybrid;
468 pub use makeover_webview::Emit;
469
470 use std::collections::HashMap;
471 use std::fmt::Write as _;
472
473 use makeover_layout::{Arrangement, Measure};
474 use quasi_http::Serves;
475 use quasi_router::{Frame, Node, Screen};
476
477 /// A renderer that answers HTML.
478 ///
479 /// Holds the things a webview needs and a description never carries: where the
480 /// host's assets live, what to prefix class names with, and what goes inside a
481 /// bespoke region. All values rather than constants because they are the parts
482 /// that genuinely differ between an axum route and a Tauri custom-protocol
483 /// handler, and none of them is anything the router can know.
484 ///
485 /// # Why a renderer is cheap
486 ///
487 /// Three fields, two of them usually shared configuration. A host with
488 /// something per-request to say builds one per request — that is what
489 /// [`fills`](Self::fills) is for, and it is an allocation rather than a
490 /// rebuild. `quasi-axum` takes a factory for exactly this.
491 #[derive(Debug, Clone, Default)]
492 pub struct Webview {
493 /// The document around a screen.
494 pub shell: Shell,
495 /// Class naming, shared with `makeover-webview`'s stylesheet half so the
496 /// emitted markup and the emitted CSS agree on every name.
497 pub emit: Emit,
498 /// What to put inside a bespoke region, by [`Slot::id`](quasi_router::Slot).
499 ///
500 /// Markup, inserted verbatim and unescaped, exactly as
501 /// [`Shell::head`] is. It is host code's string: the description never sees
502 /// it, never carries it and cannot be made to produce one. That is what
503 /// keeps decision 4 intact while giving a server-rendered page something to
504 /// serve, and it is why `Node::Html` is still refused.
505 ///
506 /// Keyed by slot id and not by the bespoke name, because a page of N rows
507 /// each carrying a fill shares one name and has N ids. The emitted div
508 /// carries both.
509 ///
510 /// A slot with no entry here renders empty, which is what every client host
511 /// relies on. An entry naming an id the screen does not have is ignored
512 /// rather than appended anywhere.
513 pub fills: HashMap<String, String>,
514 /// The frame this mount puts around the screen.
515 ///
516 /// Held beside the shell rather than arriving with an answer, which is
517 /// what makes it the mount's: one description put up in a modal and in a
518 /// window is two `Webview`s carrying two frames, and the screen inside
519 /// them says nothing about either.
520 ///
521 /// [`Frame::new`] is the frame that draws nothing, and a host that says
522 pub frame: Frame,
523 }
524
525 impl Webview {
526 /// A renderer with the default shell and class naming.
527 #[must_use]
528 pub fn new() -> Self {
529 Self::default()
530 }
531
532 /// A renderer serving its assets from under this prefix.
533 #[must_use]
534 pub fn under(prefix: &str) -> Self {
535 Self {
536 shell: Shell::under(prefix),
537 ..Self::default()
538 }
539 }
540
541 /// Use this shell, chaining.
542 #[must_use]
543 pub fn with_shell(mut self, shell: Shell) -> Self {
544 self.shell = shell;
545 self
546 }
547
548 /// Put this frame around every screen this renderer serves, chaining.
549 ///
550 /// See [`frame`](Self::frame). A mount with two ways of showing one screen
551 /// builds two renderers, which is what it already does for the shell.
552 #[must_use]
553 pub fn with_frame(mut self, frame: Frame) -> Self {
554 self.frame = frame;
555 self
556 }
557
558 /// Use this class naming, chaining.
559 #[must_use]
560 pub fn with_emit(mut self, emit: Emit) -> Self {
561 self.emit = emit;
562 self
563 }
564
565 /// The shell's half of a document the host assembles itself, in this
566 /// renderer's currency.
567 ///
568 /// [`Shell::parts`] is the same document with the default class naming and
569 /// no bespoke fills, which is all a shell holds. This is the one to reach
570 /// for once either has been said: the chrome's panel is drawn here the way
571 /// every other node is, so a prefix the stylesheet expects or a fill the
572 /// panel contains would otherwise be missing from that one path.
573 #[must_use]
574 pub fn parts(&self) -> Parts {
575 let mut parts = self.shell.parts();
576 parts.body_chrome.clear();
577 // No screen here, so no place to mark: `parts` is for a host
578 // assembling its own body, and which place is showing is a fact about a
579 // screen. The nav is drawn; nothing in it is current.
580 // The nav is not here: `parts` is for a host assembling its own body,
581 // and a navigation goes before the content that host is about to write.
582 // `nav` is public on the chrome for exactly that; see `Serves::screen`.
583 crate::chrome::chrome_html(
584 &self.shell.chrome,
585 &self.emit,
586 &node::Doc::caretless(&self.fills),
587 &mut parts.body_chrome,
588 );
589 parts
590 }
591
592 /// Fill the bespoke region with this slot id, chaining. Not escaped.
593 ///
594 /// Repeated calls for one id replace, rather than appending the way
595 /// [`Shell::with_head`] does: a head accumulates unrelated tags, and a fill
596 /// is one region's whole contents.
597 #[must_use]
598 pub fn with_fill(mut self, slot_id: impl Into<String>, markup: impl Into<String>) -> Self {
599 self.fills.insert(slot_id.into(), markup.into());
600 self
601 }
602
603 /// The class naming the arrangement of a screen's regions.
604 ///
605 /// Three, because our apps have three. A fourth arrives when an app has
606 /// one, and not before: naming arrangements an app has not asked for is how
607 /// a description becomes a framework.
608 fn arrangement_class(arrangement: Arrangement) -> &'static str {
609 match arrangement {
610 Arrangement::ListDetail { tabbed: false, .. } => "list-detail",
611 Arrangement::ListDetail { tabbed: true, .. } => "list-detail-tabbed",
612 Arrangement::SidebarContent { .. } => "sidebar-content",
613 Arrangement::Single => "single",
614 }
615 }
616
617 /// The class naming how wide a screen runs.
618 ///
619 /// Spelled out per measure rather than assembled from `measure-` and
620 /// [`Measure::as_str`], which is what this did until the emitter's
621 /// allocations were counted: that form built a `String` on every render of
622 /// every screen to reach a name from a set of three.
623 ///
624 /// These are this renderer's own names, the same way
625 /// [`arrangement_class`](Self::arrangement_class)'s are. makeover has no
626 /// word for how wide a screen runs, because the answer is a page-level
627 /// arrangement rather than anything it styles.
628 fn measure_class(measure: Measure) -> &'static str {
629 match measure {
630 Measure::Wide => "measure-wide",
631 Measure::Contained => "measure-contained",
632 Measure::Reading => "measure-reading",
633 // `Measure` is `#[non_exhaustive]`, so a member added upstream
634 // lands here rather than failing the build. `tone_attr`'s reading:
635 // the widest is the default and the one 53 of the 69 screens want,
636 // so an unlearned measure runs full width rather than carrying a
637 // class no stylesheet defines.
638 _ => "measure-wide",
639 }
640 }
641
642 /// The share, as the grid that honours it.
643 ///
644 /// An inline style rather than a class, because the share is a number the
645 /// description carries and a class can only name a number some stylesheet
646 /// already fixed. Nothing in `makeover-webview` styled these classes at
647 /// all before this, so no shipped rule is being overridden: the check the
648 /// finding asked for was whether adopting the description changes what the
649 /// webview draws, and there was nothing there to change.
650 ///
651 /// `fr` rather than a percentage, so the gap between the regions comes out
652 /// of the whole rather than out of the second one.
653 ///
654 /// Nothing is emitted for [`Arrangement::Single`], which divides no width:
655 /// a one-region screen carrying these variables makes the host spend a rule
656 /// cancelling the grid they describe.
657 fn share_style(arrangement: Arrangement, out: &mut String) {
658 let Some(share) = arrangement.share() else {
659 return;
660 };
661 let first = u16::from(share.as_percent());
662 let _ = write!(
663 out,
664 " style=\"--region-share:{first}fr;--region-rest:{}fr\"",
665 100 - first
666 );
667 }
668 }
669
670 impl Webview {
671 /// The whole document, with the screen's regions rendered from what it
672 /// says.
673 fn document(&self, screen: &Screen, regions: Regions<'_>) -> String {
674 let mut out = String::with_capacity(1024);
675 self.shell.open(
676 &screen.title,
677 Some(&screen.discovery),
678 Some(&screen.document),
679 &mut out,
680 );
681
682 // The one answer that is a whole document, so the one answer that may
683 // move the caret. Every other path here builds a `Doc` with no caret in
684 // it: a fragment, an overlay and an out-of-band swap all land inside a
685 // document the reader is already working in.
686 let doc = node::Doc::opening(&self.fills, screen.opens_at.as_deref());
687 // The chrome and the frame outlive the screen, so neither carries the
688 // screen's caret. A search box in the band is not the question the
689 // screen said to open in.
690 let outside = node::Doc::caretless(&self.fills);
691
692 // Before the content, which is where a navigation belongs in the
693 // document whatever a stylesheet later does with it. See
694 // `chrome::nav_html`.
695 //
696 // Inside the band when the app declared one, and on its own when it did
697 // not. The band is what makes the header one element, which a
698 // narrow-viewport menu written as a checkbox styling its siblings
699 // requires and could not have across two emission points.
700 match &self.shell.chrome.band {
701 Some(band) => crate::chrome::band_html(
702 band,
703 &self.shell.chrome.nav,
704 screen.place.as_deref(),
705 &self.emit,
706 &outside,
707 &mut out,
708 ),
709 None => crate::chrome::nav_html(
710 &self.shell.chrome.nav,
711 screen.place.as_deref(),
712 &self.emit,
713 &mut out,
714 ),
715 }
716 // The same two branches `chrome::header_html` writes for a host
717 // assembling its own body. Not called through it here, because this
718 // path already has the fills and the buffer it is writing into.
719
720 out.push_str("<main class=\"");
721 node::class_into(
722 Self::arrangement_class(screen.arrangement),
723 &self.emit,
724 &mut out,
725 );
726 // How wide the screen runs, beside how its width is divided. Two
727 // classes rather than one compound name: they vary independently, and a
728 // `wide-list-detail` class per pairing is the enumeration `1786cb94`
729 // settled against one level down.
730 out.push(' ');
731 node::class_into(Self::measure_class(screen.measure), &self.emit, &mut out);
732 out.push('"');
733 Self::share_style(screen.arrangement, &mut out);
734 out.push('>');
735
736 // Notices before the regions, because a notice belongs to the screen
737 // rather than to a place in it, and the first thing in the document is
738 // the one place that is true of. Where they visually land is the
739 // stylesheet's answer.
740 //
741 // Unless this mount says it has a place for one to rest, which is what
742 // a status line is: a banner then lands in the frame instead of at the
743 // top of the screen, and a toast floats regardless. `Frame::holds` is
744 // the rule and it is the router's, so the three renderers cannot each
745 // decide it.
746 let (resting, floating): (Vec<&Node>, Vec<&Node>) = screen
747 .notices
748 .iter()
749 .partition(|notice| self.frame.holds(notice));
750 if !floating.is_empty() {
751 out.push_str("<div class=\"");
752 node::class_into("notices", &self.emit, &mut out);
753 out.push_str("\">");
754 for notice in &floating {
755 node::node_html(notice, &self.emit, &doc, &mut out);
756 }
757 out.push_str("</div>");
758 }
759
760 match regions {
761 Regions::Described => {
762 for slot in &screen.slots {
763 node::slot_html(slot, node::Asks::ForItself, &self.emit, &doc, &mut out);
764 }
765 }
766 // Already this renderer's own output, so it is pushed rather than
767 // produced. Nothing is re-escaped and nothing is re-decided: the
768 // bytes were written by the code above on a build machine.
769 Regions::Derived(markup) => out.push_str(markup),
770 }
771
772 // `ae8e8836`. The container for a menu anchored to the screen's ticked
773 // set. Inside `<main>` and after every region, because the selection is
774 // the screen's rather than any one region's; emitted only for a screen
775 // that holds a selection, which is what keeps it off every other
776 // document.
777 if screen.selection.is_some() {
778 node::anchor_container_html(node::SELECTION_ANCHOR_ID, &self.emit, &mut out);
779 }
780
781 out.push_str("</main>");
782 // After the main content, and outside it for the chrome's reason one
783 // lifetime along: a fragment replaces what is inside `<main>`, and the
784 // frame this mount put around the screen outlives every answer that
785 // lands in it.
786 crate::frame::frame_html(&self.frame, &resting, &self.emit, &outside, &mut out);
787 // After the main content: the chrome belongs to the app rather than to
788 // the screen, so it sits outside what a screen's markup is.
789 crate::chrome::chrome_html(&self.shell.chrome, &self.emit, &outside, &mut out);
790 self.shell.close(&mut out);
791 out
792 }
793
794 /// The whole document, around regions this renderer already wrote.
795 ///
796 /// The serving half of a residual (quasicoherent `793d99dd`). A screen's
797 /// regions are the same bytes for every request whenever nothing in them
798 /// reads one, so they are derived on a build machine and compiled into the
799 /// consumer; what is left per request is the document around them, which is
800 /// not fixed because a shell carries whoever is looking.
801 ///
802 /// **The markup has to have come from this renderer**, and there is no way
803 /// to check that here. Handing it something else is handing a browser a
804 /// string, which is `Node::Canvas`'s bargain without `Node::Canvas`'s
805 /// sanitiser. The caller that has the property is the one that derived the
806 /// residual with `stage::derive` against a `Webview` configured the same
807 /// way, and the check that it still holds is an equality between the two,
808 /// which is what MNW's `quasi::residuals` tests are.
809 #[must_use]
810 pub fn served(&self, screen: &Screen, regions: &str) -> String {
811 self.document(screen, Regions::Derived(regions))
812 }
813 }
814
815 /// Where a document's regions come from.
816 ///
817 /// Two, and no third. Either the description is rendered here, or it was
818 /// rendered by this same code somewhere earlier and the answer was kept.
819 #[derive(Clone, Copy)]
820 enum Regions<'a> {
821 /// Render the screen's slots.
822 Described,
823 /// Markup this renderer produced already.
824 Derived(&'a str),
825 }
826
827 impl Serves for Webview {
828 fn screen(&self, screen: &Screen) -> String {
829 self.document(screen, Regions::Described)
830 }
831
832 fn suggestions(&self, field: &str, options: &[quasi_router::Candidate]) -> String {
833 node::suggestions_html(field, options, &self.emit)
834 }
835
836 fn suggestions_target(&self, field: &str) -> Option<String> {
837 hyperscript::handle(field).map(node::suggestions_id)
838 }
839
840 fn overlay(&self, screen: &Screen) -> String {
841 // No shell and no `<main>`: this is the inside of the overlay
842 // container, which the document already has. Notices ride along,
843 // because an answer that raises one while opening an overlay is
844 // raising it about the overlay.
845 let mut out = String::with_capacity(512);
846 // No caret, for `screen`'s reason: an overlay is swapped into a
847 // document the reader is already in, and a swap that moves the caret
848 // takes it out of whatever they were typing.
849 let doc = node::Doc::caretless(&self.fills);
850 for notice in &screen.notices {
851 node::node_html(notice, &self.emit, &doc, &mut out);
852 }
853 for slot in &screen.slots {
854 node::slot_html(slot, node::Asks::ForItself, &self.emit, &doc, &mut out);
855 }
856 out
857 }
858
859 fn overlay_target(&self) -> Option<&str> {
860 Some(crate::chrome::OVERLAY_ID)
861 }
862
863 fn anchored(&self, screen: &Screen) -> String {
864 // The inside of a popover container, which is `overlay`'s job one
865 // container along: no shell and no `<main>`, and notices ride with it
866 // because an answer raising one while opening a menu is raising it about
867 // the menu. Deliberately the same body rather than a second spelling of
868 // it -- what differs between the two outcomes is where the markup lands,
869 // and that is `anchored_target`'s answer, not this one's.
870 self.overlay(screen)
871 }
872
873 fn anchored_target(&self, anchor: &quasi_router::Anchor) -> Option<String> {
874 match anchor {
875 // A region and a control both name a handle the document already
876 // carries, and the container beside it is minted from that name.
877 // `hyperscript::handle` is the same gate a suggestion list passes:
878 // a name that cannot be an id drew no container, so there is nothing
879 // to aim at and the answer lands where it was sent.
880 quasi_router::Anchor::Region(id) | quasi_router::Anchor::Control(id) => {
881 crate::hyperscript::handle(id).map(node::anchored_id)
882 }
883 // The screen's set, whose container is the fixed one. A screen that
884 // holds no selection emitted none, and the retarget then names an
885 // element the document does not have -- which htmx treats as a miss
886 // and leaves the page alone. That is a description bug and gets a
887 // description bug's treatment: nothing happens, loudly enough to
888 // find in the console, rather than the menu landing somewhere it
889 // does not belong.
890 quasi_router::Anchor::Selection => Some(node::SELECTION_ANCHOR_ID.to_owned()),
891 }
892 }
893
894 fn fragment(&self, node: &Node) -> String {
895 // No shell, by definition: a fragment is the inside of one element and
896 // htmx puts it there. The router already said which element through
897 // `HX-Retarget`, so nothing here needs to know.
898 let mut out = String::with_capacity(256);
899 node::node_html(
900 node,
901 &self.emit,
902 &node::Doc::caretless(&self.fills),
903 &mut out,
904 );
905 out
906 }
907
908 fn invalidated(&self, region: &str, node: &Node) -> String {
909 // The one thing this does that `fragment` does not is carry its own
910 // address, because nothing aimed at it. See `node::oob_html`.
911 let mut out = String::with_capacity(256);
912 node::oob_html(
913 region,
914 node,
915 &self.emit,
916 &node::Doc::caretless(&self.fills),
917 &mut out,
918 );
919 out
920 }
921 }
922