Skip to main content

max / quasi

Response carries an outcome and a notice A response was one enum: a screen or a fragment. Two findings arrived that it could not hold. A write had nowhere to say "go and look over there instead" (80afd652), and nowhere to say "saved" (a92ecb1e). Filed apart they both read as new enum members, and that shape is wrong because the two compose: deleting the thing a screen is about goes somewhere else and says it is gone. So Outcome holds the three exclusive ways to answer with content, Goto among them, and the notice sits beside it. Goto takes an Action rather than a Destination because a redirect has params. A bare address drops the filter the user was reading, and Action::params exists so no app hand-builds a query string. Outcome is deliberately not non_exhaustive, unlike RowPart. A row part a renderer skips is still a row; an outcome a host does not know is a request that silently does nothing, and a wildcard arm is what makes that compile. The http adapter honours all of it: HX-Location for a route, HX-Redirect for an external address, HX-Trigger carrying the notice as JSON. A redirect is not a 303, because fetch follows one before htmx sees the headers and the client would swap a whole screen into a fragment's target. The notice rides in a header so it survives a redirect, which has no body. Closes 844b5ae0's opening half at no cost: a file:// External is already the shape of handing off to the host.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 14:35 UTC
Signed with PGP, not checked
Commit: 9456cdd8cc610f4223f55244b68f3c761b0b9b2d
Parent: e505fbb
6 files changed, +488 insertions, -43 deletions
M Cargo.lock +8 -8
@@ -4534,14 +4534,6 @@
4534 4534 name = "docengine"
4535 4535 version = "0.4.0"
4536 4536
4537 - [[patch.unused]]
4538 - name = "synckit-client"
4539 - version = "0.8.0"
4540 -
4541 - [[patch.unused]]
4542 - name = "synckit-config"
4543 - version = "0.2.0"
4544 -
4545 4537 [[patch.unused]]
4546 4538 name = "kberg"
4547 4539 version = "0.1.0"
@@ -4553,3 +4545,11 @@
4553 4545 [[patch.unused]]
4554 4546 name = "tagtree"
4555 4547 version = "0.4.0"
4548 +
4549 + [[patch.unused]]
4550 + name = "synckit-client"
4551 + version = "0.8.0"
4552 +
4553 + [[patch.unused]]
4554 + name = "synckit-config"
4555 + version = "0.2.0"
@@ -10,6 +10,8 @@
10 10 //! custom-protocol adapter is the second, so it moved here as its own doc
11 11 //! comment said it would.
12 12
13 + use quasi_router::layout;
14 +
13 15 /// The header naming the element a response replaces.
14 16 ///
15 17 /// Set from [`Response::Fragment`](quasi_router::Response::Fragment), because
@@ -18,6 +20,46 @@
18 20 /// as `#detail`, and the webview renderer owes every slot a matching `id`.
19 21 pub const RETARGET: &str = "HX-Retarget";
20 22
23 + /// The header sending the user to another route in this app.
24 + ///
25 + /// Set from [`Outcome::Goto`](quasi_router::Outcome::Goto) with a
26 + /// [`Destination::Route`](quasi_router::Destination::Route). htmx issues the
27 + /// request itself and swaps the body, so history and the back button work and
28 + /// the page is not torn down.
29 + ///
30 + /// Deliberately not a 303. A `fetch` follows a redirect before htmx sees the
31 + /// headers, so the client would swap the destination's body into whatever
32 + /// element the control targeted, which is a fragment-shaped swap of a whole
33 + /// screen. The status stays 200 with an empty body, and this header is the
34 + /// whole answer.
35 + pub const LOCATION: &str = "HX-Location";
36 +
37 + /// The header handing the user to something that is not this app.
38 + ///
39 + /// Set from [`Outcome::Goto`](quasi_router::Outcome::Goto) with a
40 + /// [`Destination::External`](quasi_router::Destination::External). htmx assigns
41 + /// `window.location`, which is a real navigation and the only thing that can
42 + /// reach a `file://` or a `mailto:`.
43 + ///
44 + /// The split from [`LOCATION`] is the difference between going somewhere this
45 + /// router answers and leaving. Only the first can be a swap.
46 + pub const REDIRECT: &str = "HX-Redirect";
47 +
48 + /// The header carrying a client-side event, used here for a notice.
49 + ///
50 + /// Set from [`Response::notice`](quasi_router::Response::notice). The value is
51 + /// JSON naming one event, `quasi:notice`, whose detail is the kind, tone and
52 + /// text. A client listens once and shows the message however that host shows
53 + /// messages.
54 + ///
55 + /// A header rather than markup in the body, because a notice is orthogonal to
56 + /// the outcome: it has to survive a redirect, which has no body at all, and it
57 + /// must not be mistaken for the content of the region being replaced.
58 + pub const TRIGGER: &str = "HX-Trigger";
59 +
60 + /// The event name [`TRIGGER`] carries.
61 + pub const NOTICE_EVENT: &str = "quasi:notice";
62 +
21 63 /// The header naming how a response is put in place.
22 64 ///
23 65 /// Not set by this adapter. How a swap happens is the renderer's business and
@@ -56,10 +98,116 @@
56 98 r#"}'>"#
57 99 );
58 100
101 + /// The [`TRIGGER`] value for one notice, as JSON.
102 + ///
103 + /// Hand-built rather than through a serialiser, because every part but the text
104 + /// is a fixed literal chosen here and the text is the only thing an app
105 + /// supplies. That makes [`escape`] the whole of the correctness argument, and it
106 + /// is tested directly.
107 + #[must_use]
108 + pub fn notice_trigger(kind: layout::Notice, tone: layout::Tone, text: &str) -> String {
109 + let kind = match kind {
110 + layout::Notice::Toast => "toast",
111 + layout::Notice::Banner => "banner",
112 + };
113 + let tone = match tone {
114 + layout::Tone::Neutral => "neutral",
115 + layout::Tone::Info => "info",
116 + layout::Tone::Success => "success",
117 + layout::Tone::Warning => "warning",
118 + layout::Tone::Danger => "danger",
119 + };
120 + format!(
121 + r#"{{"{NOTICE_EVENT}":{{"kind":"{kind}","tone":"{tone}","text":"{}"}}}}"#,
122 + escape(text)
123 + )
124 + }
125 +
126 + /// A string as a JSON string body, without the quotes.
127 + ///
128 + /// A header value cannot hold a control character, so the escapes that exist to
129 + /// keep JSON parseable are also what keep the header legal. Anything below
130 + /// space goes to `\u00XX` rather than being dropped, because dropping it would
131 + /// silently change the message.
132 + fn escape(text: &str) -> String {
133 + let mut out = String::with_capacity(text.len());
134 + for ch in text.chars() {
135 + match ch {
136 + '"' => out.push_str("\\\""),
137 + '\\' => out.push_str("\\\\"),
138 + '\n' => out.push_str("\\n"),
139 + '\r' => out.push_str("\\r"),
140 + '\t' => out.push_str("\\t"),
141 + c if (c as u32) < 0x20 => {
142 + use std::fmt::Write as _;
143 + let _ = write!(out, "\\u{:04x}", c as u32);
144 + }
145 + c => out.push(c),
146 + }
147 + }
148 + out
149 + }
150 +
59 151 #[cfg(test)]
60 152 mod tests {
61 153 use super::*;
62 154
155 + #[test]
156 + fn a_quote_in_a_message_cannot_end_the_json_string() {
157 + // The one thing an app supplies, and the reason this is not a format!
158 + // with the text dropped straight in.
159 + let json = notice_trigger(
160 + layout::Notice::Toast,
161 + layout::Tone::Success,
162 + r#"Deleted "Q3 plan""#,
163 + );
164 + assert!(json.contains(r#"Deleted \"Q3 plan\""#));
165 + assert_eq!(json.matches(r#"","#).count(), 2);
166 + }
167 +
168 + #[test]
169 + fn a_backslash_does_not_escape_the_quote_after_it() {
170 + // `C:\` followed by the closing quote is the case a naive quote-only
171 + // escaper turns into `C:\"`, which ends the string one character early.
172 + let json = notice_trigger(layout::Notice::Toast, layout::Tone::Info, r"C:\");
173 + assert!(json.contains(r#""text":"C:\\""#));
174 + }
175 +
176 + #[test]
177 + fn a_newline_cannot_reach_the_header_value() {
178 + // A raw newline is both invalid JSON and an illegal header value, which
179 + // is header injection if it survives.
180 + let json = notice_trigger(layout::Notice::Banner, layout::Tone::Danger, "one\r\ntwo");
181 + assert!(!json.contains('\n'));
182 + assert!(!json.contains('\r'));
183 + assert!(json.contains(r"one\r\ntwo"));
184 + }
185 +
186 + #[test]
187 + fn a_control_character_is_kept_rather_than_dropped() {
188 + let json = notice_trigger(layout::Notice::Toast, layout::Tone::Neutral, "a\u{1}b");
189 + assert!(json.contains(r"a\u0001b"));
190 + }
191 +
192 + #[test]
193 + fn every_tone_and_kind_has_a_spelling() {
194 + // A new Tone member added upstream fails to compile here rather than
195 + // reaching a client as a tone nothing styles.
196 + for tone in [
197 + layout::Tone::Neutral,
198 + layout::Tone::Info,
199 + layout::Tone::Success,
200 + layout::Tone::Warning,
201 + layout::Tone::Danger,
202 + ] {
203 + for kind in [layout::Notice::Toast, layout::Notice::Banner] {
204 + let json = notice_trigger(kind, tone, "x");
205 + assert!(json.starts_with(&format!(r#"{{"{NOTICE_EVENT}":"#)));
206 + assert!(!json.contains(r#""""#), "{json} has an empty spelling");
207 + }
208 + }
209 + }
210 +
63 211 #[test]
64 212 fn the_meta_tag_carries_the_same_policy_as_the_json() {
65 213 // Two spellings of one fact, so they are checked against each other
@@ -37,7 +37,7 @@
37 37 //!
38 38 //! [`quasi_axum`]: https://makenot.work/git/max/quasi
39 39
40 - use quasi_router::{Method, Node, Params, Response, RouteError};
40 + use quasi_router::{Action, Destination, Method, Node, Outcome, Params, Response, RouteError};
41 41
42 42 pub mod htmx;
43 43 pub mod render;
@@ -147,18 +147,34 @@
147 147 outcome: Result<Response, RouteError>,
148 148 ) -> http::Response<Vec<u8>> {
149 149 match outcome {
150 - Ok(Response::Screen(screen)) => body(render, 200, render.screen(&screen), None),
151 - Ok(Response::Fragment { region, node }) => {
152 - // The router said what it changed, so the client is told rather
153 - // than left to infer it from which element was clicked. The webview
154 - // renderer owes every slot an `id` matching its `Slot::id` for this
155 - // to land.
156 - body(
157 - render,
158 - 200,
159 - render.fragment(&node),
160 - Some(format!("#{region}")),
161 - )
150 + Ok(answer) => {
151 + // The notice is orthogonal to the outcome and is applied to all
152 + // three, including a redirect, which has no body to carry one.
153 + let trigger = answer.notice.as_ref().map(|notice| {
154 + htmx::notice_trigger(notice.kind, notice.tone, &notice.text)
155 + });
156 + let mut response = match answer.outcome {
157 + Outcome::Screen(screen) => body(render, 200, render.screen(&screen), None),
158 + Outcome::Fragment { region, node } => {
159 + // The router said what it changed, so the client is told
160 + // rather than left to infer it from which element was
161 + // clicked. The webview renderer owes every slot an `id`
162 + // matching its `Slot::id` for this to land.
163 + body(
164 + render,
165 + 200,
166 + render.fragment(&node),
167 + Some(format!("#{region}")),
168 + )
169 + }
170 + Outcome::Goto(action) => redirect(&action),
171 + };
172 + if let Some(trigger) = trigger
173 + && let Ok(value) = http::HeaderValue::from_str(&trigger)
174 + {
175 + response.headers_mut().insert(htmx::TRIGGER, value);
176 + }
177 + response
162 178 }
163 179 Err(error) => {
164 180 let node = Node::Notice {
@@ -176,6 +192,38 @@
176 192 }
177 193 }
178 194
195 + /// Send the user somewhere instead of answering with content.
196 + ///
197 + /// 200 and an empty body, with the whole answer in the header. See
198 + /// [`htmx::LOCATION`] for why this is not a 303.
199 + ///
200 + /// A route keeps its params, because a redirect back to a filtered list that
201 + /// drops the filter is a different place. An external address is taken as
202 + /// written: a `mailto:` or a `file://` has no query string this router built.
203 + fn redirect(action: &Action) -> http::Response<Vec<u8>> {
204 + let (header, address) = match &action.destination {
205 + Destination::Route(path) if action.params.is_empty() => (htmx::LOCATION, path.clone()),
206 + Destination::Route(path) => {
207 + // Encoded here rather than in the router, for the reason the router
208 + // does not decode: the host has this code already and a second
209 + // implementation is a second place for an escaping bug.
210 + let query = form_urlencoded::Serializer::new(String::new())
211 + .extend_pairs(action.params.iter())
212 + .finish();
213 + let joiner = if path.contains('?') { '&' } else { '?' };
214 + (htmx::LOCATION, format!("{path}{joiner}{query}"))
215 + }
216 + Destination::External(address) => (htmx::REDIRECT, address.clone()),
217 + };
218 + let mut builder = http::Response::builder().status(200);
219 + if let Ok(value) = http::HeaderValue::from_str(&address) {
220 + builder = builder.header(header, value);
221 + }
222 + builder
223 + .body(Vec::new())
224 + .expect("a response with no body and one checked header is always valid")
225 + }
226 +
179 227 /// Turn the adapter's own refusal into a response.
180 228 ///
181 229 /// No body, because there is nothing to say that the status does not already
@@ -5,7 +5,7 @@
5 5 //! left in each host's own tests is the part only that host has: its mounting,
6 6 //! its body reading and its blocking hop.
7 7
8 - use quasi_router::{Class, Node, RegionKind, Response, RouteError, Screen, Slot};
8 + use quasi_router::{Action, Class, Node, RegionKind, Response, RouteError, Screen, Slot};
9 9
10 10 use super::{DEFAULT_BODY_LIMIT, Refusal, Render, decode, refuse, respond};
11 11
@@ -226,6 +226,117 @@
226 226 );
227 227 }
228 228
229 + #[test]
230 + fn a_redirect_names_where_it_goes_and_carries_no_body() {
231 + // The finding this closes (`80afd652`): deleting the thing a screen is
232 + // about used to answer with a tombstone, because every response was content.
233 + let answer = Response::goto(Action::get("/tasks"));
234 + let response = respond(&Spy, Ok(answer));
235 + assert_eq!(response.status(), 200);
236 + assert!(response.body().is_empty());
237 + assert_eq!(response.headers().get(super::htmx::LOCATION).unwrap(), "/tasks");
238 + // Not a fragment, so nothing is being replaced in place.
239 + assert!(response.headers().get(super::htmx::RETARGET).is_none());
240 + }
241 +
242 + #[test]
243 + fn a_redirect_keeps_its_params_rather_than_dropping_the_filter() {
244 + // Back to a filtered list is a different place from back to the list.
245 + let answer = Response::goto(Action::get("/tasks").with("status", "open"));
246 + let response = respond(&Spy, Ok(answer));
247 + assert_eq!(
248 + response.headers().get(super::htmx::LOCATION).unwrap(),
249 + "/tasks?status=open"
250 + );
251 + }
252 +
253 + #[test]
254 + fn a_param_that_needs_encoding_is_encoded_once() {
255 + let answer = Response::goto(Action::get("/tasks").with("q", "a b&c"));
256 + let response = respond(&Spy, Ok(answer));
257 + assert_eq!(
258 + response.headers().get(super::htmx::LOCATION).unwrap(),
259 + "/tasks?q=a+b%26c"
260 + );
261 + }
262 +
263 + #[test]
264 + fn leaving_the_app_is_a_different_header_from_going_somewhere_in_it() {
265 + // `844b5ae0`'s opening half. An external address cannot be a swap, because
266 + // nothing comes back from it.
267 + let answer = Response::goto(Action::external("file:///home/max/notes.pdf"));
268 + let response = respond(&Spy, Ok(answer));
269 + assert_eq!(
270 + response.headers().get(super::htmx::REDIRECT).unwrap(),
271 + "file:///home/max/notes.pdf"
272 + );
273 + assert!(response.headers().get(super::htmx::LOCATION).is_none());
274 + }
275 +
276 + #[test]
277 + fn a_notice_rides_beside_the_content_rather_than_replacing_it() {
278 + // `a92ecb1e`. The fragment still lands; the message is a header, so it is
279 + // not mistaken for the region's new contents.
280 + let answer = Response::fragment("detail", Node::text("hello"))
281 + .toast(quasi_router::layout::Tone::Success, "Saved");
282 + let response = respond(&Spy, Ok(answer));
283 + assert_eq!(text(&response), "text:hello");
284 + assert_eq!(
285 + response.headers().get(super::htmx::RETARGET).unwrap(),
286 + "#detail"
287 + );
288 + assert_eq!(
289 + response.headers().get(super::htmx::TRIGGER).unwrap(),
290 + r#"{"quasi:notice":{"kind":"toast","tone":"success","text":"Saved"}}"#
291 + );
292 + }
293 +
294 + #[test]
295 + fn a_notice_survives_a_redirect_which_has_no_body_to_put_one_in() {
296 + // The composition the two findings were filed apart from each other and
297 + // could not express: a delete both goes elsewhere and says it is gone.
298 + let answer = Response::goto(Action::get("/tasks"))
299 + .toast(quasi_router::layout::Tone::Success, "Deleted");
300 + let response = respond(&Spy, Ok(answer));
301 + assert!(response.body().is_empty());
302 + assert_eq!(response.headers().get(super::htmx::LOCATION).unwrap(), "/tasks");
303 + assert!(
304 + response
305 + .headers()
306 + .get(super::htmx::TRIGGER)
307 + .unwrap()
308 + .to_str()
309 + .unwrap()
310 + .contains(r#""text":"Deleted""#)
311 + );
312 + }
313 +
314 + #[test]
315 + fn a_response_that_says_nothing_sets_no_trigger() {
316 + let response = respond(&Spy, Ok(Response::fragment("detail", Node::text("hello"))));
317 + assert!(response.headers().get(super::htmx::TRIGGER).is_none());
318 + }
319 +
320 + #[test]
321 + fn a_banner_and_a_toast_are_told_apart_at_the_boundary() {
322 + // They are dismissed differently, so a client that cannot tell them apart
323 + // shows a permanent error as a message that vanishes.
324 + let banner = respond(
325 + &Spy,
326 + Ok(Response::fragment("detail", Node::text("x"))
327 + .banner(quasi_router::layout::Tone::Danger, "Sync is down")),
328 + );
329 + assert!(
330 + banner
331 + .headers()
332 + .get(super::htmx::TRIGGER)
333 + .unwrap()
334 + .to_str()
335 + .unwrap()
336 + .contains(r#""kind":"banner""#)
337 + );
338 + }
339 +
229 340 #[test]
230 341 fn every_class_becomes_its_status_with_the_notice_as_the_body() {
231 342 for (error, status) in [
@@ -25,7 +25,7 @@
25 25 //! ```
26 26 //!
27 27 //! ```
28 - //! use quasi_router::{Action, Method, Node, Params, Response, RouteError, Router, Screen, Slot};
28 + //! use quasi_router::{Action, Method, Node, Params, Outcome, Response, RouteError, Router, Screen, Slot};
29 29 //! use quasi_router::layout::{Arrangement, Region};
30 30 //!
31 31 //! struct App {
@@ -69,7 +69,7 @@
69 69 //!
70 70 //! let app = App { tasks: vec![(7, "Write the router".into(), false)] };
71 71 //! let answer = router.handle(&app, Method::Get, "/task/7", Params::new()).unwrap();
72 - //! assert!(matches!(answer, Response::Screen(_)));
72 + //! assert!(matches!(answer.outcome, Outcome::Screen(_)));
73 73 //! ```
74 74 //!
75 75 //! # What is settled, and where it is written down
@@ -84,7 +84,8 @@
84 84 //! which is the one thing the description layer never names. See [`Screen`].
85 85 //! - **A bespoke region is filled per host** (4). See [`RegionKind::Bespoke`].
86 86 //! - **The router is sync** (6). See [`Handler`].
87 - //! - **A response carries a target** (7). See [`Response`].
87 + //! - **A response carries a target, and may carry a notice or a redirect** (7).
88 + //! See [`Response`] and [`Outcome`].
88 89 //! - **`Router<S>`, generic over app state** (8). See [`Router`].
89 90 //! - **Failure is classified** (9). See [`RouteError`].
90 91
@@ -105,7 +106,7 @@
105 106
106 107 pub use crate::error::{Class, RouteError};
107 108 pub use crate::request::{Method, Params};
108 - pub use crate::response::Response;
109 + pub use crate::response::{Message, Outcome, Response};
109 110 pub use crate::router::{Handler, Router};
110 111 pub use crate::screen::{
111 112 Act, Action, Cells, Choice, Column, Destination, Field, Node, RegionKind, Row, Screen, Slot,
@@ -159,8 +160,8 @@
159 160 }
160 161
161 162 fn text_of(response: &Response) -> Option<&str> {
162 - match response {
163 - Response::Fragment {
163 + match &response.outcome {
164 + Outcome::Fragment {
164 165 node: Node::Text { text, .. },
165 166 ..
166 167 } => Some(text),
@@ -1,9 +1,9 @@
1 - //! What a route answers with, and what it says should be replaced.
1 + //! What a route answers with, what it says should be replaced, and what it says
2 + //! to the user on the way.
2 3 //!
3 - //! Decision 7 on the wiki note. A response is either a whole [`Screen`] or a
4 - //! [`Response::Fragment`] naming the region it replaces, because the router is
5 - //! the only party that knows what it just changed, so it is the party that
6 - //! should say.
4 + //! Decision 7 on the wiki note. A response names the region it replaces, because
5 + //! the router is the only party that knows what it just changed, so it is the
6 + //! party that should say.
7 7 //!
8 8 //! The webview maps a fragment onto `hx-target` and `hx-swap`, which is the
9 9 //! thing htmx exists to do, and it is the reason a full-body swap per action is
@@ -13,12 +13,69 @@
13 13 //!
14 14 //! The rejected alternative was one return type plus a renderer diffing markup
15 15 //! against the DOM. That is a virtual DOM, and htmx was chosen to avoid one.
16 + //!
17 + //! # Why this is a struct and not one enum
18 + //!
19 + //! It was one enum until 2026-08-09, and two findings arrived together that it
20 + //! could not hold: a write had nowhere to say "go and look over there instead"
21 + //! (`80afd652`), and nowhere to say "saved" (`a92ecb1e`). Neither is content, so
22 + //! neither is a [`Screen`] or a [`Fragment`](Outcome::Fragment).
23 + //!
24 + //! Filed separately they both read as new enum members, and that shape is wrong
25 + //! because the two compose. Deleting the thing a screen is about goes somewhere
26 + //! else *and* says it is gone. A save that fails on something no field can carry
27 + //! stays where it is *and* says why. One member cannot be two members, so
28 + //! [`Outcome`] holds the three ways to answer with content and the notice sits
29 + //! beside it, optional, orthogonal to all three.
30 + //!
31 + //! # Why [`Goto`](Outcome::Goto) takes an [`Action`] and not a [`Destination`]
32 + //!
33 + //! A redirect has params: back to a list with a filter still applied, back to a
34 + //! project on the tab you were reading. A bare address drops them and the app
35 + //! rebuilds a query string by hand, which is what [`Action::params`] exists to
36 + //! prevent.
37 + //!
38 + //! [`Action::method`] is meaningless here in the same way it is meaningless for
39 + //! a [`Destination::External`], and is left alone for the reason given there: a
40 + //! method that is ignored is simpler than two shapes of action.
41 + //!
42 + //! [`Destination`]: crate::Destination
43 + //! [`Destination::External`]: crate::Destination::External
44 + //! [`Action::method`]: crate::Action::method
45 + //! [`Action::params`]: crate::Action::params
16 46
17 - use crate::screen::{Node, Screen};
47 + use makeover_layout as layout;
48 +
49 + use crate::screen::{Action, Node, Screen};
18 50
19 51 /// What a route answered with.
20 52 #[derive(Debug, Clone, PartialEq, Eq)]
21 - pub enum Response {
53 + pub struct Response {
54 + /// The content, or the address to go to instead of content.
55 + pub outcome: Outcome,
56 + /// What to tell the user, if anything. Independent of the outcome.
57 + pub notice: Option<Message>,
58 + }
59 +
60 + /// The content half of an answer.
61 + ///
62 + /// [`Goto`](Self::Goto) is not content and sits here anyway, because the three
63 + /// are exclusive: a response replaces a screen, or replaces a region, or sends
64 + /// the user elsewhere, and never two of those.
65 + ///
66 + /// # Deliberately not `#[non_exhaustive]`
67 + ///
68 + /// [`RowPart`](layout::RowPart) took it, and this is the opposite case. A row
69 + /// part a renderer does not know can be skipped, and the row is still a row. An
70 + /// outcome a host does not know is a request that silently does nothing, and
71 + /// `#[non_exhaustive]` is what makes that compile: every adapter grows a
72 + /// wildcard arm with nothing sensible to put in it, and a new member reaches
73 + /// each of them as a fallback rather than as an error.
74 + ///
75 + /// So a member added here breaks every host on purpose, which is the point.
76 + /// Growing [`Response`] itself stays cheap, because it is a struct.
77 + #[derive(Debug, Clone, PartialEq, Eq)]
78 + pub enum Outcome {
22 79 /// The whole screen. A navigation, or an action whose effect is not
23 80 /// contained by one region.
24 81 Screen(Screen),
@@ -29,38 +86,118 @@
29 86 /// What goes in it.
30 87 node: Node,
31 88 },
89 + /// Somewhere else. No content, because the destination will answer.
90 + ///
91 + /// A webview sends a 303 or an `HX-Location`, a terminal pushes a screen,
92 + /// egui sets its route. An [`External`](crate::Destination::External)
93 + /// destination hands off to the host and nothing comes back, which is what
94 + /// opening a file or a mail client is.
95 + Goto(Action),
96 + }
97 +
98 + /// Something to tell the user alongside whatever else the response does.
99 + ///
100 + /// The same three fields as [`Node::Notice`], because it is the same thing said
101 + /// from the other end: that one is a message a screen contains, this is a
102 + /// message an answer carries. A renderer that can draw one can draw the other.
103 + #[derive(Debug, Clone, PartialEq, Eq)]
104 + pub struct Message {
105 + /// Transient and stacked, or persistent and in flow.
106 + pub kind: layout::Notice,
107 + /// What it is saying.
108 + pub tone: layout::Tone,
109 + /// The message.
110 + pub text: String,
32 111 }
33 112
34 113 impl Response {
35 114 /// A whole screen.
36 115 #[must_use]
37 116 pub fn screen(screen: Screen) -> Self {
38 - Self::Screen(screen)
117 + Self::from(Outcome::Screen(screen))
39 118 }
40 119
41 120 /// One region's new contents.
42 121 pub fn fragment(region: impl Into<String>, node: Node) -> Self {
43 - Self::Fragment {
122 + Self::from(Outcome::Fragment {
44 123 region: region.into(),
45 124 node,
46 - }
125 + })
47 126 }
48 127
49 - /// The region being replaced, or `None` for a whole screen.
128 + /// Somewhere else instead of content.
129 + #[must_use]
130 + pub fn goto(action: Action) -> Self {
131 + Self::from(Outcome::Goto(action))
132 + }
133 +
134 + /// Say something transient on the way. It dismisses itself.
135 + #[must_use]
136 + pub fn toast(self, tone: layout::Tone, text: impl Into<String>) -> Self {
137 + self.saying(layout::Notice::Toast, tone, text)
138 + }
139 +
140 + /// Say something persistent on the way. It is dismissed by fixing the cause.
141 + #[must_use]
142 + pub fn banner(self, tone: layout::Tone, text: impl Into<String>) -> Self {
143 + self.saying(layout::Notice::Banner, tone, text)
144 + }
145 +
146 + /// Say something, spelling out which kind it is.
147 + ///
148 + /// [`toast`](Self::toast) and [`banner`](Self::banner) are this with the
149 + /// kind chosen, and are what call sites should reach for.
150 + #[must_use]
151 + pub fn saying(
152 + mut self,
153 + kind: layout::Notice,
154 + tone: layout::Tone,
155 + text: impl Into<String>,
156 + ) -> Self {
157 + self.notice = Some(Message {
158 + kind,
159 + tone,
160 + text: text.into(),
161 + });
162 + self
163 + }
164 +
165 + /// The region being replaced, or `None` for a whole screen or a redirect.
50 166 ///
51 167 /// A webview reads this to set `hx-retarget`. Renderers that repaint
52 168 /// wholesale never call it.
53 169 #[must_use]
54 170 pub fn target(&self) -> Option<&str> {
55 - match self {
56 - Self::Screen(_) => None,
57 - Self::Fragment { region, .. } => Some(region),
171 + match &self.outcome {
172 + Outcome::Screen(_) | Outcome::Goto(_) => None,
173 + Outcome::Fragment { region, .. } => Some(region),
174 + }
175 + }
176 +
177 + /// Where this is sending the user, if it is sending them anywhere.
178 + ///
179 + /// The question a host asks before it looks for a body, because a redirect
180 + /// has none.
181 + #[must_use]
182 + pub fn destination(&self) -> Option<&Action> {
183 + match &self.outcome {
184 + Outcome::Goto(action) => Some(action),
185 + Outcome::Screen(_) | Outcome::Fragment { .. } => None,
186 + }
187 + }
188 + }
189 +
190 + impl From<Outcome> for Response {
191 + fn from(outcome: Outcome) -> Self {
192 + Self {
193 + outcome,
194 + notice: None,
58 195 }
59 196 }
60 197 }
61 198
62 199 impl From<Screen> for Response {
63 200 fn from(screen: Screen) -> Self {
64 - Self::Screen(screen)
201 + Self::screen(screen)
65 202 }
66 203 }