Skip to main content

max / quasi

11.5 KB · 295 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 settled 2026-08-08 is audience: a makeover
9 //! crate is something another developer might use on its own, and quasi is what
10 //! you use once you are committed to the whole stack. A `makeover-webview` that
11 //! depended on 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 pub mod chrome;
37 mod node;
38 mod shell;
39
40 #[cfg(test)]
41 mod tests;
42
43 pub use crate::shell::{Parts, Shell};
44 pub use makeover_webview::Emit;
45
46 use std::collections::HashMap;
47 use std::fmt::Write as _;
48
49 use makeover_layout::{Arrangement, Measure};
50 use quasi_http::Serves;
51 use quasi_router::{Node, Screen};
52
53 /// A renderer that answers HTML.
54 ///
55 /// Holds the things a webview needs and a description never carries: where the
56 /// host's assets live, what to prefix class names with, and what goes inside a
57 /// bespoke region. All values rather than constants because they are the parts
58 /// that genuinely differ between an axum route and a Tauri custom-protocol
59 /// handler, and none of them is anything the router can know.
60 ///
61 /// # Why a renderer is cheap
62 ///
63 /// Three fields, two of them usually shared configuration. A host with
64 /// something per-request to say builds one per request — that is what
65 /// [`fills`](Self::fills) is for, and it is an allocation rather than a
66 /// rebuild. `quasi-axum` takes a factory for exactly this.
67 #[derive(Debug, Clone, Default)]
68 pub struct Webview {
69 /// The document around a screen.
70 pub shell: Shell,
71 /// Class naming, shared with `makeover-webview`'s stylesheet half so the
72 /// emitted markup and the emitted CSS agree on every name.
73 pub emit: Emit,
74 /// What to put inside a bespoke region, by [`Slot::id`](quasi_router::Slot).
75 ///
76 /// Markup, inserted verbatim and unescaped, exactly as
77 /// [`Shell::head`] is. It is host code's string: the description never sees
78 /// it, never carries it and cannot be made to produce one. That is what
79 /// keeps decision 4 intact while giving a server-rendered page something to
80 /// serve, and it is why `Node::Html` is still refused.
81 ///
82 /// Keyed by slot id and not by the bespoke name, because a page of N rows
83 /// each carrying a fill shares one name and has N ids. The emitted div
84 /// carries both.
85 ///
86 /// A slot with no entry here renders empty, which is what every client host
87 /// relies on. An entry naming an id the screen does not have is ignored
88 /// rather than appended anywhere.
89 pub fills: HashMap<String, String>,
90 }
91
92 impl Webview {
93 /// A renderer with the default shell and class naming.
94 #[must_use]
95 pub fn new() -> Self {
96 Self::default()
97 }
98
99 /// A renderer serving its assets from under this prefix.
100 #[must_use]
101 pub fn under(prefix: &str) -> Self {
102 Self {
103 shell: Shell::under(prefix),
104 ..Self::default()
105 }
106 }
107
108 /// Use this shell, chaining.
109 #[must_use]
110 pub fn with_shell(mut self, shell: Shell) -> Self {
111 self.shell = shell;
112 self
113 }
114
115 /// Use this class naming, chaining.
116 #[must_use]
117 pub fn with_emit(mut self, emit: Emit) -> Self {
118 self.emit = emit;
119 self
120 }
121
122 /// Fill the bespoke region with this slot id, chaining. Not escaped.
123 ///
124 /// Repeated calls for one id replace, rather than appending the way
125 /// [`Shell::with_head`] does: a head accumulates unrelated tags, and a fill
126 /// is one region's whole contents.
127 #[must_use]
128 pub fn with_fill(mut self, slot_id: impl Into<String>, markup: impl Into<String>) -> Self {
129 self.fills.insert(slot_id.into(), markup.into());
130 self
131 }
132
133 /// The class naming the arrangement of a screen's regions.
134 ///
135 /// Two, because our apps have two. A third arrives when an app has one,
136 /// and not before: naming arrangements an app has not asked for is how a
137 /// description becomes a framework.
138 fn arrangement_class(arrangement: Arrangement) -> &'static str {
139 match arrangement {
140 Arrangement::ListDetail { tabbed: false, .. } => "list-detail",
141 Arrangement::ListDetail { tabbed: true, .. } => "list-detail-tabbed",
142 Arrangement::SidebarContent { .. } => "sidebar-content",
143 }
144 }
145
146 /// The class naming how wide a screen runs.
147 ///
148 /// Spelled out per measure rather than assembled from `measure-` and
149 /// [`Measure::as_str`], which is what this did until the emitter's
150 /// allocations were counted: that form built a `String` on every render of
151 /// every screen to reach a name from a set of three.
152 ///
153 /// These are this renderer's own names, the same way
154 /// [`arrangement_class`](Self::arrangement_class)'s are. makeover has no
155 /// word for how wide a screen runs, because the answer is a page-level
156 /// arrangement rather than anything it styles.
157 fn measure_class(measure: Measure) -> &'static str {
158 match measure {
159 Measure::Wide => "measure-wide",
160 Measure::Contained => "measure-contained",
161 Measure::Reading => "measure-reading",
162 // `Measure` is `#[non_exhaustive]`, so a member added upstream
163 // lands here rather than failing the build. `tone_attr`'s reading:
164 // the widest is the default and the one 53 of the 69 screens want,
165 // so an unlearned measure runs full width rather than carrying a
166 // class no stylesheet defines.
167 _ => "measure-wide",
168 }
169 }
170
171 /// The share, as the grid that honours it.
172 ///
173 /// `e0fd485e`. An inline style rather than a class, because the share is a
174 /// number the description carries and a class can only name a number some
175 /// stylesheet already fixed. Nothing in `makeover-webview` styled these
176 /// classes at all before this, so no shipped rule is being overridden: the
177 /// check the finding asked for was whether adopting the description changes
178 /// what the webview draws, and there was nothing there to change.
179 ///
180 /// `fr` rather than a percentage, so the gap between the regions comes out
181 /// of the whole rather than out of the second one.
182 fn share_style(arrangement: Arrangement, out: &mut String) {
183 let first = u16::from(arrangement.share().as_percent());
184 let _ = write!(
185 out,
186 " style=\"--region-share:{first}fr;--region-rest:{}fr\"",
187 100 - first
188 );
189 }
190 }
191
192 impl Serves for Webview {
193 fn screen(&self, screen: &Screen) -> String {
194 let mut out = String::with_capacity(1024);
195 self.shell
196 .open(&screen.title, Some(&screen.discovery), &mut out);
197
198 out.push_str("<main class=\"");
199 node::class_into(
200 Self::arrangement_class(screen.arrangement),
201 &self.emit,
202 &mut out,
203 );
204 // How wide the screen runs, beside how its width is divided. Two
205 // classes rather than one compound name: they vary independently, and a
206 // `wide-list-detail` class per pairing is the enumeration `1786cb94`
207 // settled against one level down.
208 out.push(' ');
209 node::class_into(Self::measure_class(screen.measure), &self.emit, &mut out);
210 out.push('"');
211 Self::share_style(screen.arrangement, &mut out);
212 out.push('>');
213
214 // Notices before the regions, because a notice belongs to the screen
215 // rather than to a place in it, and the first thing in the document is
216 // the one place that is true of. Where they visually land is the
217 // stylesheet's answer.
218 if !screen.notices.is_empty() {
219 out.push_str("<div class=\"");
220 node::class_into("notices", &self.emit, &mut out);
221 out.push_str("\">");
222 for notice in &screen.notices {
223 node::node_html(
224 notice,
225 self.shell.morphs(),
226 &self.emit,
227 &self.fills,
228 &mut out,
229 );
230 }
231 out.push_str("</div>");
232 }
233
234 for slot in &screen.slots {
235 node::slot_html(slot, self.shell.morphs(), &self.emit, &self.fills, &mut out);
236 }
237
238 out.push_str("</main>");
239 // After the main content: the chrome belongs to the app rather than to
240 // the screen, so it sits outside what a screen's markup is.
241 crate::chrome::chrome_html(&self.shell.chrome, self.shell.morphs(), &mut out);
242 Shell::close(&mut out);
243 out
244 }
245
246 fn overlay(&self, screen: &Screen) -> String {
247 // No shell and no `<main>`: this is the inside of the overlay
248 // container, which the document already has. Notices ride along,
249 // because an answer that raises one while opening an overlay is
250 // raising it about the overlay.
251 let mut out = String::with_capacity(512);
252 for notice in &screen.notices {
253 node::node_html(
254 notice,
255 self.shell.morphs(),
256 &self.emit,
257 &self.fills,
258 &mut out,
259 );
260 }
261 for slot in &screen.slots {
262 node::slot_html(slot, self.shell.morphs(), &self.emit, &self.fills, &mut out);
263 }
264 out
265 }
266
267 fn overlay_target(&self) -> Option<&str> {
268 Some(crate::chrome::OVERLAY_ID)
269 }
270
271 fn fragment(&self, node: &Node) -> String {
272 // No shell, by definition: a fragment is the inside of one element and
273 // htmx puts it there. The router already said which element through
274 // `HX-Retarget`, so nothing here needs to know.
275 let mut out = String::with_capacity(256);
276 node::node_html(node, self.shell.morphs(), &self.emit, &self.fills, &mut out);
277 out
278 }
279
280 fn invalidated(&self, region: &str, node: &Node) -> String {
281 // The one thing this does that `fragment` does not is carry its own
282 // address, because nothing aimed at it. See `node::oob_html`.
283 let mut out = String::with_capacity(256);
284 node::oob_html(
285 region,
286 node,
287 self.shell.morphs(),
288 &self.emit,
289 &self.fills,
290 &mut out,
291 );
292 out
293 }
294 }
295