max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+488 insertions,
-43 deletions
| @@ -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, ¬ice.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 | } |