Skip to main content

max / quasi

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