max / quasi
11 files changed,
+1649 insertions,
-13 deletions
| @@ -2,10 +2,43 @@ | |||
| 2 | 2 | # It is not intended for manual editing. | |
| 3 | 3 | version = 4 | |
| 4 | 4 | ||
| 5 | + | [[package]] | |
| 6 | + | name = "makeover-layout" | |
| 7 | + | version = "0.8.2" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "9883c75a9d26fce10be2b979c01a74f8c07513800a70ec6bf598de1e7d411f1b" | |
| 10 | + | ||
| 5 | 11 | [[package]] | |
| 6 | 12 | name = "quasi" | |
| 7 | 13 | version = "0.0.0" | |
| 8 | 14 | ||
| 9 | 15 | [[package]] | |
| 10 | 16 | name = "quasi-router" | |
| 11 | - | version = "0.0.0" | |
| 17 | + | version = "0.1.0" | |
| 18 | + | dependencies = [ | |
| 19 | + | "makeover-layout", | |
| 20 | + | ] | |
| 21 | + | ||
| 22 | + | [[patch.unused]] | |
| 23 | + | name = "synckit-client" | |
| 24 | + | version = "0.8.0" | |
| 25 | + | ||
| 26 | + | [[patch.unused]] | |
| 27 | + | name = "synckit-config" | |
| 28 | + | version = "0.2.0" | |
| 29 | + | ||
| 30 | + | [[patch.unused]] | |
| 31 | + | name = "docengine" | |
| 32 | + | version = "0.4.0" | |
| 33 | + | ||
| 34 | + | [[patch.unused]] | |
| 35 | + | name = "kberg" | |
| 36 | + | version = "0.1.0" | |
| 37 | + | ||
| 38 | + | [[patch.unused]] | |
| 39 | + | name = "painhours" | |
| 40 | + | version = "0.1.0" | |
| 41 | + | ||
| 42 | + | [[patch.unused]] | |
| 43 | + | name = "tagtree" | |
| 44 | + | version = "0.4.0" |
| @@ -19,9 +19,32 @@ | |||
| 19 | 19 | ||
| 20 | 20 | [workspace.lints.clippy] | |
| 21 | 21 | pedantic = { level = "warn", priority = -1 } | |
| 22 | + | # The house allow-list, tuned from a measured breakdown across | |
| 23 | + | # server/multithreaded/pter (2026-07-22) and carried verbatim from the makeover | |
| 24 | + | # crates. These are the high-churn / low-signal pedantic lints; everything else | |
| 25 | + | # in `pedantic` stays a warning. Keep this block identical across repos. | |
| 22 | 26 | module_name_repetitions = "allow" | |
| 27 | + | # Doc lints. No docs-completeness push is underway. | |
| 23 | 28 | missing_errors_doc = "allow" | |
| 24 | 29 | missing_panics_doc = "allow" | |
| 30 | + | doc_markdown = "allow" | |
| 31 | + | # Numeric casts. Endemic and mostly intentional in size and byte math. | |
| 32 | + | cast_possible_truncation = "allow" | |
| 33 | + | cast_sign_loss = "allow" | |
| 34 | + | cast_precision_loss = "allow" | |
| 35 | + | cast_possible_wrap = "allow" | |
| 36 | + | cast_lossless = "allow" | |
| 37 | + | # Subjective structure and style nags. High churn, low signal. | |
| 38 | + | must_use_candidate = "allow" | |
| 39 | + | too_many_lines = "allow" | |
| 40 | + | struct_excessive_bools = "allow" | |
| 41 | + | similar_names = "allow" | |
| 42 | + | items_after_statements = "allow" | |
| 43 | + | single_match_else = "allow" | |
| 44 | + | # Frequent false-positives in TUI and router-heavy code. | |
| 45 | + | match_same_arms = "allow" | |
| 46 | + | unnecessary_wraps = "allow" | |
| 47 | + | type_complexity = "allow" | |
| 25 | 48 | ||
| 26 | 49 | [profile.release] | |
| 27 | 50 | lto = "thin" |
| @@ -41,7 +41,12 @@ | |||
| 41 | 41 | Also out of scope: anything whose value *is* its API surface. `sqlx`'s | |
| 42 | 42 | compile-time-checked query macros are the reason to use `sqlx`, so quasi owns | |
| 43 | 43 | which driver is configured and how migrations run, and queries stay written | |
| 44 | - | against `sqlx` directly. | |
| 44 | + | against the driver directly. | |
| 45 | + | ||
| 46 | + | The two stores are not one query layer, and quasi does not pretend otherwise | |
| 47 | + | (settled 2026-08-07). Embedded is `rusqlite` and synchronous; hosted Postgres is | |
| 48 | + | `sqlx` and async. A stack claiming a single query layer would have the | |
| 49 | + | scaffolder generate the wrong one. | |
| 45 | 50 | ||
| 46 | 51 | Boundaries already owned elsewhere stay where they are and are referenced rather | |
| 47 | 52 | than absorbed: `makeover` and `makeover-geometry` for colour and spacing, | |
| @@ -58,13 +63,23 @@ | |||
| 58 | 63 | ||
| 59 | 64 | ## Status | |
| 60 | 65 | ||
| 61 | - | Nothing implemented. The repo exists so the boundary rule has a home and the | |
| 62 | - | router has somewhere to land. Both crates are stubs. | |
| 66 | + | `quasi-router` is implemented and `quasi` is still a stub. | |
| 63 | 67 | ||
| 64 | - | The description layer this depends on is not written either, and the order of | |
| 65 | - | work runs vocabulary first, then renderers, then this. Design and sequencing | |
| 66 | - | live in the wiki note `quasi-overview`; the backlog is in GoingsOn under project | |
| 67 | - | `quasicoherent`. | |
| 68 | + | The router carries the contract in full: one address space where the verb | |
| 69 | + | separates a read from a write, a screen tree composed from `makeover-layout`'s | |
| 70 | + | vocabulary, responses that name the region they replace, and failures classified | |
| 71 | + | so a host can turn one into a status code and another into a banner. It is sync, | |
| 72 | + | because the two renderers shipping first call it inside a frame and an event | |
| 73 | + | loop. | |
| 74 | + | ||
| 75 | + | Not yet written: the host adapters. An axum route and a Tauri custom-protocol | |
| 76 | + | handler are each a thin layer over `Router::handle`, and the protocol form has | |
| 77 | + | to be measured across webkit2gtk, WKWebView and WebView2 before the second one | |
| 78 | + | can be designed. The scaffolder follows the router once its shape is proven | |
| 79 | + | against two hosts, which is why it is still a stub rather than half-written. | |
| 80 | + | ||
| 81 | + | Design and sequencing live in the wiki note `quasi-overview`; the backlog is in | |
| 82 | + | GoingsOn under project `quasicoherent`. | |
| 68 | 83 | ||
| 69 | 84 | ## Licence | |
| 70 | 85 |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "quasi-router" | |
| 3 | - | version = "0.0.0" | |
| 3 | + | version = "0.1.0" | |
| 4 | 4 | description = "Host-agnostic router: a request in, a renderer-agnostic description out" | |
| 5 | 5 | edition.workspace = true | |
| 6 | 6 | rust-version.workspace = true | |
| @@ -13,3 +13,4 @@ | |||
| 13 | 13 | workspace = true | |
| 14 | 14 | ||
| 15 | 15 | [dependencies] | |
| 16 | + | makeover-layout = "0.8.2" |
| @@ -2,8 +2,8 @@ | |||
| 2 | 2 | //! | |
| 3 | 3 | //! <!-- wiki: quasi-overview --> | |
| 4 | 4 | //! | |
| 5 | - | //! The keystone of the stack. A route answers with what a screen *is*, expressed | |
| 6 | - | //! in `makeover-layout`'s vocabulary, and the host decides how that becomes | |
| 5 | + | //! The keystone of the stack. A route answers with what a screen *is*, composed | |
| 6 | + | //! from `makeover-layout`'s vocabulary, and the host decides how that becomes | |
| 7 | 7 | //! pixels. Returning markup instead would pin every consumer to a webview and | |
| 8 | 8 | //! hand the terminal and egui renderers an adapter, which is the failure the | |
| 9 | 9 | //! description layer exists to prevent. | |
| @@ -14,5 +14,297 @@ | |||
| 14 | 14 | //! it is what lets the same route serve a desktop protocol handler and an HTTP | |
| 15 | 15 | //! endpoint with no second implementation. | |
| 16 | 16 | //! | |
| 17 | - | //! Not written yet. The vocabulary this returns has to exist first, and the | |
| 18 | - | //! order of work is on the wiki note. | |
| 17 | + | //! # The shape | |
| 18 | + | //! | |
| 19 | + | //! ```text | |
| 20 | + | //! request (path + params) | |
| 21 | + | //! -> router this crate, imports no host crate | |
| 22 | + | //! -> description makeover-layout, composed by this crate's Screen | |
| 23 | + | //! -> renderer webview | tui | egui | |
| 24 | + | //! -> host adapter axum route | Tauri protocol | wry | direct call | |
| 25 | + | //! ``` | |
| 26 | + | //! | |
| 27 | + | //! ``` | |
| 28 | + | //! use quasi_router::{Action, Method, Node, Params, Response, RouteError, Router, Screen, Slot}; | |
| 29 | + | //! use quasi_router::layout::{Arrangement, Region}; | |
| 30 | + | //! | |
| 31 | + | //! struct App { | |
| 32 | + | //! tasks: Vec<(u32, String, bool)>, | |
| 33 | + | //! } | |
| 34 | + | //! | |
| 35 | + | //! fn show_task(app: &App, params: Params) -> Result<Response, RouteError> { | |
| 36 | + | //! let id: u32 = params | |
| 37 | + | //! .require("id")? | |
| 38 | + | //! .parse() | |
| 39 | + | //! .map_err(|_| RouteError::not_found("no such task"))?; | |
| 40 | + | //! let (_, title, done) = app | |
| 41 | + | //! .tasks | |
| 42 | + | //! .iter() | |
| 43 | + | //! .find(|(t, _, _)| *t == id) | |
| 44 | + | //! .ok_or_else(|| RouteError::not_found("no such task"))?; | |
| 45 | + | //! | |
| 46 | + | //! let mut detail = Slot::new("detail", quasi_router::RegionKind::Pane) | |
| 47 | + | //! .with(Node::page(title.clone())); | |
| 48 | + | //! if !done { | |
| 49 | + | //! detail = detail.with(Node::act( | |
| 50 | + | //! "Complete", | |
| 51 | + | //! Action::post(format!("/task/{id}/complete")), | |
| 52 | + | //! )); | |
| 53 | + | //! } | |
| 54 | + | //! | |
| 55 | + | //! Ok(Screen::list_detail(title.clone(), false).with(detail).into()) | |
| 56 | + | //! } | |
| 57 | + | //! | |
| 58 | + | //! fn complete_task(_app: &App, params: Params) -> Result<Response, RouteError> { | |
| 59 | + | //! let id = params.require("id")?; | |
| 60 | + | //! Ok(Response::fragment( | |
| 61 | + | //! "detail", | |
| 62 | + | //! Node::text(format!("task {id} is done")), | |
| 63 | + | //! )) | |
| 64 | + | //! } | |
| 65 | + | //! | |
| 66 | + | //! let router = Router::<App>::new() | |
| 67 | + | //! .get("/task/:id", show_task) | |
| 68 | + | //! .post("/task/:id/complete", complete_task); | |
| 69 | + | //! | |
| 70 | + | //! let app = App { tasks: vec![(7, "Write the router".into(), false)] }; | |
| 71 | + | //! let answer = router.handle(&app, Method::Get, "/task/7", Params::new()).unwrap(); | |
| 72 | + | //! assert!(matches!(answer, Response::Screen(_))); | |
| 73 | + | //! ``` | |
| 74 | + | //! | |
| 75 | + | //! # What is settled, and where it is written down | |
| 76 | + | //! | |
| 77 | + | //! The design lives on the wiki note `quasi-overview`, and the decisions this | |
| 78 | + | //! crate implements are numbered there. In short: | |
| 79 | + | //! | |
| 80 | + | //! - **An action is a route** (2). One address space for reads and writes, and | |
| 81 | + | //! the verb separates them. See [`Method`]. | |
| 82 | + | //! - **The screen tree lives here, not in `makeover-layout`** (3). It will churn | |
| 83 | + | //! while the router is proven against a second host, and a route is an address, | |
| 84 | + | //! which is the one thing the description layer never names. See [`Screen`]. | |
| 85 | + | //! - **A bespoke region is filled per host** (4). See [`RegionKind::Bespoke`]. | |
| 86 | + | //! - **The router is sync** (6). See [`Handler`]. | |
| 87 | + | //! - **A response carries a target** (7). See [`Response`]. | |
| 88 | + | //! - **`Router<S>`, generic over app state** (8). See [`Router`]. | |
| 89 | + | //! - **Failure is classified** (9). See [`RouteError`]. | |
| 90 | + | ||
| 91 | + | pub mod error; | |
| 92 | + | mod path; | |
| 93 | + | pub mod request; | |
| 94 | + | pub mod response; | |
| 95 | + | pub mod router; | |
| 96 | + | pub mod screen; | |
| 97 | + | ||
| 98 | + | /// The description layer, re-exported. | |
| 99 | + | /// | |
| 100 | + | /// Every intent a [`Screen`] composes is `makeover-layout`'s, and a consumer | |
| 101 | + | /// needs them to build one. Re-exported so that an app and a renderer are | |
| 102 | + | /// provably reading the same version of the vocabulary rather than two | |
| 103 | + | /// semver-compatible ones that happen to resolve together. | |
| 104 | + | pub use makeover_layout as layout; | |
| 105 | + | ||
| 106 | + | pub use crate::error::{Class, RouteError}; | |
| 107 | + | pub use crate::request::{Method, Params}; | |
| 108 | + | pub use crate::response::Response; | |
| 109 | + | pub use crate::router::{Handler, Router}; | |
| 110 | + | pub use crate::screen::{ | |
| 111 | + | Act, Action, Cells, Choice, Column, Field, Node, RegionKind, Row, Screen, Slot, | |
| 112 | + | }; | |
| 113 | + | ||
| 114 | + | #[cfg(test)] | |
| 115 | + | mod tests { | |
| 116 | + | use super::*; | |
| 117 | + | use crate::layout::{Arrangement, Tone}; | |
| 118 | + | ||
| 119 | + | /// The smallest app state a route can be written against. | |
| 120 | + | struct State { | |
| 121 | + | greeting: &'static str, | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | fn home(state: &State, _params: Params) -> Result<Response, RouteError> { | |
| 125 | + | Ok(Screen::sidebar_content("Home") | |
| 126 | + | .with(Slot::new("content", RegionKind::Pane).with(Node::text(state.greeting))) | |
| 127 | + | .into()) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | fn new_task(_state: &State, _params: Params) -> Result<Response, RouteError> { | |
| 131 | + | Ok(Response::fragment("detail", Node::text("a new task"))) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // Taken by value because [`Handler`] says so, and a handler that only reads | |
| 135 | + | // its parameters is the common case rather than an oversight. | |
| 136 | + | #[allow(clippy::needless_pass_by_value)] | |
| 137 | + | fn show_task(_state: &State, params: Params) -> Result<Response, RouteError> { | |
| 138 | + | let id = params.require("id")?.to_owned(); | |
| 139 | + | Ok(Response::fragment("detail", Node::text(id))) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | fn forbidden(_state: &State, _params: Params) -> Result<Response, RouteError> { | |
| 143 | + | Err(RouteError::denied("not yours")) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | fn router() -> Router<State> { | |
| 147 | + | // Deliberately registered least-specific-first, so the ordering being | |
| 148 | + | // tested is the table's own and not the order of these lines. | |
| 149 | + | Router::new() | |
| 150 | + | .get("/task/:id", show_task) | |
| 151 | + | .get("/task/new", new_task) | |
| 152 | + | .get("/", home) | |
| 153 | + | .post("/task/:id/delete", forbidden) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | fn state() -> State { | |
| 157 | + | State { greeting: "hello" } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | fn text_of(response: &Response) -> Option<&str> { | |
| 161 | + | match response { | |
| 162 | + | Response::Fragment { | |
| 163 | + | node: Node::Text { text, .. }, | |
| 164 | + | .. | |
| 165 | + | } => Some(text), | |
| 166 | + | _ => None, | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | #[test] | |
| 171 | + | fn a_static_route_beats_a_capture_whatever_the_order() { | |
| 172 | + | let answer = router() | |
| 173 | + | .handle(&state(), Method::Get, "/task/new", Params::new()) | |
| 174 | + | .unwrap(); | |
| 175 | + | assert_eq!(text_of(&answer), Some("a new task")); | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | #[test] | |
| 179 | + | fn a_capture_reaches_the_handler() { | |
| 180 | + | let answer = router() | |
| 181 | + | .handle(&state(), Method::Get, "/task/7", Params::new()) | |
| 182 | + | .unwrap(); | |
| 183 | + | assert_eq!(text_of(&answer), Some("7")); | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | #[test] | |
| 187 | + | fn the_path_capture_wins_over_a_supplied_value() { | |
| 188 | + | // The path is the address; the body is only what was sent to it. | |
| 189 | + | let sent = Params::new().with("id", "9"); | |
| 190 | + | let answer = router() | |
| 191 | + | .handle(&state(), Method::Get, "/task/7", sent) | |
| 192 | + | .unwrap(); | |
| 193 | + | assert_eq!(text_of(&answer), Some("7")); | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | #[test] | |
| 197 | + | fn a_read_and_a_write_are_different_routes_at_one_address() { | |
| 198 | + | let router = router(); | |
| 199 | + | let missing = router | |
| 200 | + | .handle(&state(), Method::Post, "/task/7", Params::new()) | |
| 201 | + | .unwrap_err(); | |
| 202 | + | assert_eq!(missing.class, Class::NotFound); | |
| 203 | + | assert!(missing.message.contains("another method")); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | #[test] | |
| 207 | + | fn an_unknown_path_says_so_without_mentioning_a_method() { | |
| 208 | + | let missing = router() | |
| 209 | + | .handle(&state(), Method::Get, "/nowhere", Params::new()) | |
| 210 | + | .unwrap_err(); | |
| 211 | + | assert_eq!(missing.class, Class::NotFound); | |
| 212 | + | assert!(!missing.message.contains("another method")); | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | #[test] | |
| 216 | + | fn a_denial_carries_a_banner_and_a_status() { | |
| 217 | + | let denied = router() | |
| 218 | + | .handle(&state(), Method::Post, "/task/7/delete", Params::new()) | |
| 219 | + | .unwrap_err(); | |
| 220 | + | assert_eq!(denied.class, Class::Denied); | |
| 221 | + | assert_eq!(denied.class.http_status(), 403); | |
| 222 | + | assert_eq!(denied.notice, layout::Notice::Banner); | |
| 223 | + | assert_eq!(denied.tone(), Tone::Warning); | |
| 224 | + | assert!(!denied.class.is_ours()); | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | #[test] | |
| 228 | + | fn a_missing_parameter_is_our_bug_not_the_users() { | |
| 229 | + | // Reached only by calling the handler outside the router, which is what | |
| 230 | + | // a renderer emitting an unfilled route amounts to. | |
| 231 | + | let missing = show_task(&state(), Params::new()).unwrap_err(); | |
| 232 | + | assert_eq!(missing.class, Class::Internal); | |
| 233 | + | assert!(missing.class.is_ours()); | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | #[test] | |
| 237 | + | fn a_screen_answers_with_no_target_and_a_fragment_with_one() { | |
| 238 | + | let router = router(); | |
| 239 | + | let screen = router | |
| 240 | + | .handle(&state(), Method::Get, "/", Params::new()) | |
| 241 | + | .unwrap(); | |
| 242 | + | assert_eq!(screen.target(), None); | |
| 243 | + | ||
| 244 | + | let fragment = router | |
| 245 | + | .handle(&state(), Method::Get, "/task/7", Params::new()) | |
| 246 | + | .unwrap(); | |
| 247 | + | assert_eq!(fragment.target(), Some("detail")); | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | #[test] | |
| 251 | + | fn the_route_table_reads_back_most_specific_first() { | |
| 252 | + | let router = router(); | |
| 253 | + | let table: Vec<_> = router.routes().collect(); | |
| 254 | + | let new_at = table.iter().position(|(_, p)| *p == "/task/new").unwrap(); | |
| 255 | + | let id_at = table.iter().position(|(_, p)| *p == "/task/:id").unwrap(); | |
| 256 | + | assert!(new_at < id_at); | |
| 257 | + | assert_eq!(router.len(), 4); | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | #[test] | |
| 261 | + | #[should_panic(expected = "registered twice")] | |
| 262 | + | fn registering_one_route_twice_is_a_bug() { | |
| 263 | + | let _ = Router::<State>::new() | |
| 264 | + | .get("/task/:id", show_task) | |
| 265 | + | .get("/task/:id", show_task); | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | #[test] | |
| 269 | + | fn a_slot_is_found_at_any_depth() { | |
| 270 | + | let screen = Screen::new("Tabs", Arrangement::ListDetail { tabbed: true }).with( | |
| 271 | + | Slot::new("tabs", RegionKind::TabGroup) | |
| 272 | + | .with(Node::Region(Slot::new("pane-a", RegionKind::Pane))), | |
| 273 | + | ); | |
| 274 | + | assert!(screen.slot("tabs").is_some()); | |
| 275 | + | assert!(screen.slot("pane-a").is_some()); | |
| 276 | + | assert!(screen.slot("pane-b").is_none()); | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | #[test] | |
| 280 | + | fn an_owned_field_borrows_back_as_the_description_layers_own() { | |
| 281 | + | let field = Field::select( | |
| 282 | + | "priority", | |
| 283 | + | "Priority", | |
| 284 | + | vec![Choice::plain("high"), Choice::new("low", "Low")], | |
| 285 | + | ) | |
| 286 | + | .required() | |
| 287 | + | .error("pick one"); | |
| 288 | + | ||
| 289 | + | field.with_layout(|borrowed| { | |
| 290 | + | assert_eq!(borrowed.name, "priority"); | |
| 291 | + | assert_eq!(borrowed.options.len(), 2); | |
| 292 | + | assert_eq!(borrowed.options[1].label, "Low"); | |
| 293 | + | assert!(borrowed.required); | |
| 294 | + | // The description layer's own reading of the same state. | |
| 295 | + | assert!(borrowed.invalid()); | |
| 296 | + | assert!(borrowed.kind.offers_options()); | |
| 297 | + | }); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | #[test] | |
| 301 | + | fn a_bespoke_region_is_the_only_undescribed_one() { | |
| 302 | + | assert!(RegionKind::Pane.described()); | |
| 303 | + | assert!( | |
| 304 | + | !RegionKind::Bespoke { | |
| 305 | + | name: "day-plan".into() | |
| 306 | + | } | |
| 307 | + | .described() | |
| 308 | + | ); | |
| 309 | + | } | |
| 310 | + | } |
| @@ -1,0 +1,150 @@ | |||
| 1 | + | //! Failure, classified once so every host renders it the same way. | |
| 2 | + | //! | |
| 3 | + | //! Decision 9 on the wiki note. A handler that cannot do what was asked returns | |
| 4 | + | //! a [`RouteError`] rather than a [`Response`](crate::Response) describing the | |
| 5 | + | //! problem. Two things fall out of that, and both are the reason the type | |
| 6 | + | //! exists. | |
| 7 | + | //! | |
| 8 | + | //! A class is not a message. The class is what the *host* acts on: axum turns | |
| 9 | + | //! it into a status code, the terminal and egui turn it into a banner. Handing | |
| 10 | + | //! back a plain response instead would have hosted axum answer 200 to | |
| 11 | + | //! everything, so caches, logs and monitoring could not tell a denied action | |
| 12 | + | //! from a completed one. | |
| 13 | + | //! | |
| 14 | + | //! The message is already UI. It carries a `makeover-layout` [`Notice`], so | |
| 15 | + | //! errors become something on screen in one place rather than once per handler. | |
| 16 | + | ||
| 17 | + | use makeover_layout::{Notice, Tone}; | |
| 18 | + | ||
| 19 | + | /// What kind of failure it was. | |
| 20 | + | /// | |
| 21 | + | /// Four, and each one is a different thing for a host to do. `NotFound` and | |
| 22 | + | /// `Denied` are separate because a host that conflates them cannot answer the | |
| 23 | + | /// question its own access log asks. `Conflict` is separate because it is the | |
| 24 | + | /// one failure the user can fix by looking at the screen again, which is a | |
| 25 | + | /// banner rather than a toast. | |
| 26 | + | /// | |
| 27 | + | /// `#[non_exhaustive]` for the reason `makeover-layout` puts it on the | |
| 28 | + | /// vocabularies renderers match against: growth must not be a lockstep event | |
| 29 | + | /// across every host adapter. | |
| 30 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] | |
| 31 | + | #[non_exhaustive] | |
| 32 | + | pub enum Class { | |
| 33 | + | /// The thing addressed is not there. | |
| 34 | + | NotFound, | |
| 35 | + | /// The thing is there and this caller may not have it. | |
| 36 | + | Denied, | |
| 37 | + | /// The request was answerable and the current state refuses it. | |
| 38 | + | Conflict, | |
| 39 | + | /// We are broken. | |
| 40 | + | Internal, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | impl Class { | |
| 44 | + | /// What the failure is saying, in `makeover-layout`'s vocabulary. | |
| 45 | + | /// | |
| 46 | + | /// Only `Internal` is [`Tone::Danger`]. The other three are conditions the | |
| 47 | + | /// user can understand and often act on, and spending the loudest tone on | |
| 48 | + | /// all of them is how a `Danger` stops meaning anything. | |
| 49 | + | #[must_use] | |
| 50 | + | pub const fn tone(self) -> Tone { | |
| 51 | + | match self { | |
| 52 | + | Self::NotFound | Self::Denied | Self::Conflict => Tone::Warning, | |
| 53 | + | Self::Internal => Tone::Danger, | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// The status code an HTTP host answers with. | |
| 58 | + | /// | |
| 59 | + | /// A convenience rather than a leak: this is a `u16`, no host crate is | |
| 60 | + | /// imported to produce it, and every HTTP adapter we will write would | |
| 61 | + | /// otherwise hand-roll the same four-arm match. Hosts with no notion of a | |
| 62 | + | /// status code ignore it and read [`Class`] directly. | |
| 63 | + | #[must_use] | |
| 64 | + | pub const fn http_status(self) -> u16 { | |
| 65 | + | match self { | |
| 66 | + | Self::NotFound => 404, | |
| 67 | + | Self::Denied => 403, | |
| 68 | + | Self::Conflict => 409, | |
| 69 | + | Self::Internal => 500, | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// Whether the failure is ours rather than the caller's. | |
| 74 | + | /// | |
| 75 | + | /// The line a host logs on. A `NotFound` at volume is a broken link | |
| 76 | + | /// somewhere; an `Internal` at any volume is a page. | |
| 77 | + | #[must_use] | |
| 78 | + | pub const fn is_ours(self) -> bool { | |
| 79 | + | matches!(self, Self::Internal) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// A route that could not answer. | |
| 84 | + | /// | |
| 85 | + | /// Carries a class for the host and a notice for the screen. The notice | |
| 86 | + | /// defaults to [`Notice::Banner`], because a failure is persistent by nature: | |
| 87 | + | /// it is dismissed by fixing the condition that caused it, which is exactly | |
| 88 | + | /// what `makeover-layout` says a banner is for. Call [`RouteError::as_toast`] | |
| 89 | + | /// where the failure really is transient. | |
| 90 | + | #[derive(Debug, Clone, PartialEq, Eq, Hash)] | |
| 91 | + | pub struct RouteError { | |
| 92 | + | /// What kind of failure it was. | |
| 93 | + | pub class: Class, | |
| 94 | + | /// How the message sits on screen. | |
| 95 | + | pub notice: Notice, | |
| 96 | + | /// What to tell the user. Already user-facing text, not a debug string. | |
| 97 | + | pub message: String, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | impl RouteError { | |
| 101 | + | /// A failure of the given class, shown as a banner. | |
| 102 | + | pub fn new(class: Class, message: impl Into<String>) -> Self { | |
| 103 | + | Self { | |
| 104 | + | class, | |
| 105 | + | notice: Notice::Banner, | |
| 106 | + | message: message.into(), | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /// The thing addressed is not there. | |
| 111 | + | pub fn not_found(message: impl Into<String>) -> Self { | |
| 112 | + | Self::new(Class::NotFound, message) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// The caller may not have it. | |
| 116 | + | pub fn denied(message: impl Into<String>) -> Self { | |
| 117 | + | Self::new(Class::Denied, message) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | /// The current state refuses the request. | |
| 121 | + | pub fn conflict(message: impl Into<String>) -> Self { | |
| 122 | + | Self::new(Class::Conflict, message) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /// We are broken. | |
| 126 | + | pub fn internal(message: impl Into<String>) -> Self { | |
| 127 | + | Self::new(Class::Internal, message) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /// The same failure, shown as a toast instead of a banner. | |
| 131 | + | #[must_use] | |
| 132 | + | pub fn as_toast(mut self) -> Self { | |
| 133 | + | self.notice = Notice::Toast; | |
| 134 | + | self | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// What the failure is saying. Delegates to the class. | |
| 138 | + | #[must_use] | |
| 139 | + | pub const fn tone(&self) -> Tone { | |
| 140 | + | self.class.tone() | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | impl std::fmt::Display for RouteError { | |
| 145 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 146 | + | write!(f, "{:?}: {}", self.class, self.message) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | impl std::error::Error for RouteError {} |
| @@ -1,0 +1,203 @@ | |||
| 1 | + | //! Matching a path against a registered pattern. | |
| 2 | + | //! | |
| 3 | + | //! Segment-wise, with `:name` capturing one segment. No wildcards and no regex. | |
| 4 | + | //! A pattern is a name for a screen or an action, and the moment it can match an | |
| 5 | + | //! arbitrary tail it stops being one. | |
| 6 | + | ||
| 7 | + | use crate::request::Params; | |
| 8 | + | ||
| 9 | + | /// One piece of a pattern between slashes. | |
| 10 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 11 | + | enum Segment { | |
| 12 | + | /// Matches itself. | |
| 13 | + | Static(String), | |
| 14 | + | /// Matches anything and records it under this name. | |
| 15 | + | Capture(String), | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | /// A registered path, parsed once at construction. | |
| 19 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 20 | + | pub(crate) struct Pattern { | |
| 21 | + | /// As written, for error messages and for listing the route table. | |
| 22 | + | source: String, | |
| 23 | + | segments: Vec<Segment>, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | impl Pattern { | |
| 27 | + | /// Parse a pattern. | |
| 28 | + | /// | |
| 29 | + | /// # Panics | |
| 30 | + | /// | |
| 31 | + | /// If a segment is empty or a capture has no name. Registration happens at | |
| 32 | + | /// startup from literals in the source, so a malformed pattern is a bug | |
| 33 | + | /// that should stop the program rather than a condition to thread through | |
| 34 | + | /// every call site as a `Result`. | |
| 35 | + | pub(crate) fn parse(source: &str) -> Self { | |
| 36 | + | assert!( | |
| 37 | + | source.starts_with('/'), | |
| 38 | + | "route pattern `{source}` must start with `/`" | |
| 39 | + | ); | |
| 40 | + | ||
| 41 | + | let segments = split(source) | |
| 42 | + | .map(|raw| { | |
| 43 | + | assert!( | |
| 44 | + | !raw.is_empty(), | |
| 45 | + | "route pattern `{source}` has an empty segment" | |
| 46 | + | ); | |
| 47 | + | match raw.strip_prefix(':') { | |
| 48 | + | Some(name) => { | |
| 49 | + | assert!( | |
| 50 | + | !name.is_empty(), | |
| 51 | + | "route pattern `{source}` has an unnamed capture" | |
| 52 | + | ); | |
| 53 | + | Segment::Capture(name.to_owned()) | |
| 54 | + | } | |
| 55 | + | None => Segment::Static(raw.to_owned()), | |
| 56 | + | } | |
| 57 | + | }) | |
| 58 | + | .collect(); | |
| 59 | + | ||
| 60 | + | Self { | |
| 61 | + | source: source.to_owned(), | |
| 62 | + | segments, | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// The pattern as it was written. | |
| 67 | + | pub(crate) fn source(&self) -> &str { | |
| 68 | + | &self.source | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// Match a concrete path, yielding what the captures caught. | |
| 72 | + | /// | |
| 73 | + | /// `None` if the path does not match. An empty `Params` is a match with no | |
| 74 | + | /// captures, which is the common case and is not a failure. | |
| 75 | + | pub(crate) fn match_path(&self, path: &str) -> Option<Params> { | |
| 76 | + | let mut actual = split(path); | |
| 77 | + | let mut captured = Params::new(); | |
| 78 | + | ||
| 79 | + | for segment in &self.segments { | |
| 80 | + | let part = actual.next()?; | |
| 81 | + | match segment { | |
| 82 | + | Segment::Static(want) if want == part => {} | |
| 83 | + | Segment::Static(_) => return None, | |
| 84 | + | Segment::Capture(name) => { | |
| 85 | + | // An empty capture would let `/task//edit` answer as | |
| 86 | + | // `/task/:id/edit` with a blank id, which is a request no | |
| 87 | + | // renderer of ours emits and a row no store has. | |
| 88 | + | if part.is_empty() { | |
| 89 | + | return None; | |
| 90 | + | } | |
| 91 | + | captured.insert(name.clone(), part.to_owned()); | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | actual.next().is_none().then_some(captured) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /// How specific the pattern is, most significant segment first. | |
| 100 | + | /// | |
| 101 | + | /// Sorted descending at registration so that `/task/new` is tried before | |
| 102 | + | /// `/task/:id` however they were declared. Ordering by declaration instead | |
| 103 | + | /// would make a route table's correctness depend on the order somebody | |
| 104 | + | /// happened to type it in, which is the kind of thing that works until a | |
| 105 | + | /// route is moved. | |
| 106 | + | pub(crate) fn specificity(&self) -> Vec<u8> { | |
| 107 | + | self.segments | |
| 108 | + | .iter() | |
| 109 | + | .map(|s| match s { | |
| 110 | + | Segment::Static(_) => 1, | |
| 111 | + | Segment::Capture(_) => 0, | |
| 112 | + | }) | |
| 113 | + | .collect() | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// The segments of a path, ignoring the leading and trailing slash. | |
| 118 | + | /// | |
| 119 | + | /// `/` is zero segments, `/task` is one, `/task/` is also one. A trailing slash | |
| 120 | + | /// is not a different screen and treating it as one only ever produces a 404 | |
| 121 | + | /// somebody has to debug. | |
| 122 | + | fn split(path: &str) -> impl Iterator<Item = &str> { | |
| 123 | + | let trimmed = path.trim_start_matches('/').trim_end_matches('/'); | |
| 124 | + | // The root has to be zero segments rather than one empty one, or `/` and | |
| 125 | + | // `/x` both look like a single segment to the matcher. Interior empties are | |
| 126 | + | // kept, so `/task//edit` fails to match rather than quietly collapsing. | |
| 127 | + | let inner = (!trimmed.is_empty()).then(|| trimmed.split('/')); | |
| 128 | + | inner.into_iter().flatten() | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | #[cfg(test)] | |
| 132 | + | mod tests { | |
| 133 | + | use super::*; | |
| 134 | + | ||
| 135 | + | fn captures(pattern: &str, path: &str) -> Option<Vec<(String, String)>> { | |
| 136 | + | Pattern::parse(pattern).match_path(path).map(|p| { | |
| 137 | + | p.iter() | |
| 138 | + | .map(|(k, v)| (k.to_owned(), v.to_owned())) | |
| 139 | + | .collect() | |
| 140 | + | }) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | #[test] | |
| 144 | + | fn static_path_matches_itself() { | |
| 145 | + | assert_eq!(captures("/task", "/task"), Some(vec![])); | |
| 146 | + | assert_eq!(captures("/task", "/tasks"), None); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | #[test] | |
| 150 | + | fn root_is_zero_segments() { | |
| 151 | + | assert_eq!(captures("/", "/"), Some(vec![])); | |
| 152 | + | assert_eq!(captures("/", "/task"), None); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | #[test] | |
| 156 | + | fn capture_takes_one_segment() { | |
| 157 | + | assert_eq!( | |
| 158 | + | captures("/task/:id", "/task/7"), | |
| 159 | + | Some(vec![("id".to_owned(), "7".to_owned())]) | |
| 160 | + | ); | |
| 161 | + | assert_eq!(captures("/task/:id", "/task/7/edit"), None); | |
| 162 | + | assert_eq!(captures("/task/:id", "/task"), None); | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | #[test] | |
| 166 | + | fn several_captures_keep_their_names() { | |
| 167 | + | assert_eq!( | |
| 168 | + | captures("/project/:project/task/:id", "/project/quasi/task/7"), | |
| 169 | + | Some(vec![ | |
| 170 | + | ("project".to_owned(), "quasi".to_owned()), | |
| 171 | + | ("id".to_owned(), "7".to_owned()), | |
| 172 | + | ]) | |
| 173 | + | ); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | #[test] | |
| 177 | + | fn trailing_slash_is_the_same_screen() { | |
| 178 | + | assert_eq!(captures("/task", "/task/"), Some(vec![])); | |
| 179 | + | assert_eq!( | |
| 180 | + | captures("/task/:id", "/task/7/"), | |
| 181 | + | Some(vec![("id".to_owned(), "7".to_owned())]) | |
| 182 | + | ); | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | #[test] | |
| 186 | + | fn static_outranks_capture() { | |
| 187 | + | let new = Pattern::parse("/task/new"); | |
| 188 | + | let id = Pattern::parse("/task/:id"); | |
| 189 | + | assert!(new.specificity() > id.specificity()); | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | #[test] | |
| 193 | + | #[should_panic(expected = "must start with `/`")] | |
| 194 | + | fn pattern_without_leading_slash_is_a_bug() { | |
| 195 | + | Pattern::parse("task/:id"); | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | #[test] | |
| 199 | + | #[should_panic(expected = "unnamed capture")] | |
| 200 | + | fn unnamed_capture_is_a_bug() { | |
| 201 | + | Pattern::parse("/task/:"); | |
| 202 | + | } | |
| 203 | + | } |
| @@ -1,0 +1,165 @@ | |||
| 1 | + | //! What arrives: a verb, a path, and a flat bag of named values. | |
| 2 | + | //! | |
| 3 | + | //! Decision 2 on the wiki note is that an action is a route, so reads and | |
| 4 | + | //! mutations share one address space and the verb is what separates them. There | |
| 5 | + | //! is no third member and there is not going to be one: `PUT`, `PATCH` and | |
| 6 | + | //! `DELETE` are HTTP's vocabulary, and a terminal binding a key to a route has | |
| 7 | + | //! no opinion about which of them a deletion is. Two is what the description | |
| 8 | + | //! layer can express. | |
| 9 | + | ||
| 10 | + | use crate::error::RouteError; | |
| 11 | + | ||
| 12 | + | /// Whether the request is asking or telling. | |
| 13 | + | /// | |
| 14 | + | /// [`Method::Get`] is the default, because the safe verb is the one a partly | |
| 15 | + | /// built value should have: a control that forgot to say it mutates asks | |
| 16 | + | /// instead of telling. | |
| 17 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] | |
| 18 | + | pub enum Method { | |
| 19 | + | /// Asking. Answers with a description and changes nothing. | |
| 20 | + | #[default] | |
| 21 | + | Get, | |
| 22 | + | /// Telling. Performs, then answers with the next description. | |
| 23 | + | Post, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | impl Method { | |
| 27 | + | /// Whether the route is allowed to change anything. | |
| 28 | + | #[must_use] | |
| 29 | + | pub const fn mutates(self) -> bool { | |
| 30 | + | matches!(self, Self::Post) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /// The name an HTTP host knows it by. | |
| 34 | + | #[must_use] | |
| 35 | + | pub const fn as_str(self) -> &'static str { | |
| 36 | + | match self { | |
| 37 | + | Self::Get => "GET", | |
| 38 | + | Self::Post => "POST", | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | impl std::fmt::Display for Method { | |
| 44 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 45 | + | f.write_str(self.as_str()) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// The named values a request carries. | |
| 50 | + | /// | |
| 51 | + | /// One bag, holding path captures and whatever the host handed over. Query | |
| 52 | + | /// string and form body are the same thing by the time they get here, which is | |
| 53 | + | /// what lets a terminal call a route with no notion of either. | |
| 54 | + | /// | |
| 55 | + | /// A `Vec` rather than a map, because it keeps insertion order and repeats a | |
| 56 | + | /// name, and both matter: a checkbox group submits one name several times, and | |
| 57 | + | /// dropping the repeats silently is a bug that only shows up on the screen with | |
| 58 | + | /// the multi-select on it. Lookup is linear over a handful of entries. | |
| 59 | + | /// | |
| 60 | + | /// # Decoding is the host's job | |
| 61 | + | /// | |
| 62 | + | /// Values arrive already decoded. quasi does not percent-decode, parse a query | |
| 63 | + | /// string or read a form body, because every host we target already has that | |
| 64 | + | /// code and ours would be a second implementation to keep correct. | |
| 65 | + | #[derive(Debug, Clone, Default, PartialEq, Eq)] | |
| 66 | + | pub struct Params { | |
| 67 | + | entries: Vec<(String, String)>, | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | impl Params { | |
| 71 | + | /// No values. | |
| 72 | + | #[must_use] | |
| 73 | + | pub const fn new() -> Self { | |
| 74 | + | Self { | |
| 75 | + | entries: Vec::new(), | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// Add a value. Does not replace an existing one of the same name. | |
| 80 | + | pub fn insert(&mut self, name: impl Into<String>, value: impl Into<String>) { | |
| 81 | + | self.entries.push((name.into(), value.into())); | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// Add a value, chaining. | |
| 85 | + | #[must_use] | |
| 86 | + | pub fn with(mut self, name: impl Into<String>, value: impl Into<String>) -> Self { | |
| 87 | + | self.insert(name, value); | |
| 88 | + | self | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// The first value under this name. | |
| 92 | + | #[must_use] | |
| 93 | + | pub fn get(&self, name: &str) -> Option<&str> { | |
| 94 | + | self.entries | |
| 95 | + | .iter() | |
| 96 | + | .find(|(k, _)| k == name) | |
| 97 | + | .map(|(_, v)| v.as_str()) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /// Every value under this name, in the order they arrived. | |
| 101 | + | pub fn get_all<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a str> { | |
| 102 | + | self.entries | |
| 103 | + | .iter() | |
| 104 | + | .filter(move |(k, _)| k == name) | |
| 105 | + | .map(|(_, v)| v.as_str()) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// The first value under this name, or a failure the host can act on. | |
| 109 | + | /// | |
| 110 | + | /// [`Class::Internal`](crate::Class), because the caller is our own emitted | |
| 111 | + | /// markup or our own key binding. A missing parameter means the renderer | |
| 112 | + | /// emitted a route it did not fill in, which is our bug rather than the | |
| 113 | + | /// user's, and reporting it as a bad request would file it against them. | |
| 114 | + | pub fn require(&self, name: &str) -> Result<&str, RouteError> { | |
| 115 | + | self.get(name) | |
| 116 | + | .ok_or_else(|| RouteError::internal(format!("route parameter `{name}` is missing"))) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// Take everything from another bag, keeping what is already here in front. | |
| 120 | + | /// | |
| 121 | + | /// How the router merges path captures with what the host sent. Order is | |
| 122 | + | /// the whole content of the method: [`Params::get`] answers with the first | |
| 123 | + | /// match, so whatever is already here wins a collision. | |
| 124 | + | pub fn absorb(&mut self, other: Self) { | |
| 125 | + | self.entries.extend(other.entries); | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | /// Whether anything is under this name. | |
| 129 | + | #[must_use] | |
| 130 | + | pub fn contains(&self, name: &str) -> bool { | |
| 131 | + | self.get(name).is_some() | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | /// Every name and value, in order. | |
| 135 | + | pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> { | |
| 136 | + | self.entries.iter().map(|(k, v)| (k.as_str(), v.as_str())) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /// How many values there are. Repeats count separately. | |
| 140 | + | #[must_use] | |
| 141 | + | pub fn len(&self) -> usize { | |
| 142 | + | self.entries.len() | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// Whether there are none. | |
| 146 | + | #[must_use] | |
| 147 | + | pub fn is_empty(&self) -> bool { | |
| 148 | + | self.entries.is_empty() | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | impl<K, V> FromIterator<(K, V)> for Params | |
| 153 | + | where | |
| 154 | + | K: Into<String>, | |
| 155 | + | V: Into<String>, | |
| 156 | + | { | |
| 157 | + | fn from_iter<I: IntoIterator<Item = (K, V)>>(iter: I) -> Self { | |
| 158 | + | Self { | |
| 159 | + | entries: iter | |
| 160 | + | .into_iter() | |
| 161 | + | .map(|(k, v)| (k.into(), v.into())) | |
| 162 | + | .collect(), | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | } |
| @@ -1,0 +1,66 @@ | |||
| 1 | + | //! What a route answers with, and what it says should be replaced. | |
| 2 | + | //! | |
| 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. | |
| 7 | + | //! | |
| 8 | + | //! The webview maps a fragment onto `hx-target` and `hx-swap`, which is the | |
| 9 | + | //! thing htmx exists to do, and it is the reason a full-body swap per action is | |
| 10 | + | //! not the design: list screens are exactly where losing scroll and focus | |
| 11 | + | //! hurts. egui and the terminal ignore the target and redraw everything, which | |
| 12 | + | //! costs them nothing because they were redrawing anyway. | |
| 13 | + | //! | |
| 14 | + | //! The rejected alternative was one return type plus a renderer diffing markup | |
| 15 | + | //! against the DOM. That is a virtual DOM, and htmx was chosen to avoid one. | |
| 16 | + | ||
| 17 | + | use crate::screen::{Node, Screen}; | |
| 18 | + | ||
| 19 | + | /// What a route answered with. | |
| 20 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 21 | + | pub enum Response { | |
| 22 | + | /// The whole screen. A navigation, or an action whose effect is not | |
| 23 | + | /// contained by one region. | |
| 24 | + | Screen(Screen), | |
| 25 | + | /// One region's new contents. | |
| 26 | + | Fragment { | |
| 27 | + | /// The [`Slot::id`](crate::Slot::id) being replaced. | |
| 28 | + | region: String, | |
| 29 | + | /// What goes in it. | |
| 30 | + | node: Node, | |
| 31 | + | }, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | impl Response { | |
| 35 | + | /// A whole screen. | |
| 36 | + | #[must_use] | |
| 37 | + | pub fn screen(screen: Screen) -> Self { | |
| 38 | + | Self::Screen(screen) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// One region's new contents. | |
| 42 | + | pub fn fragment(region: impl Into<String>, node: Node) -> Self { | |
| 43 | + | Self::Fragment { | |
| 44 | + | region: region.into(), | |
| 45 | + | node, | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// The region being replaced, or `None` for a whole screen. | |
| 50 | + | /// | |
| 51 | + | /// A webview reads this to set `hx-retarget`. Renderers that repaint | |
| 52 | + | /// wholesale never call it. | |
| 53 | + | #[must_use] | |
| 54 | + | pub fn target(&self) -> Option<&str> { | |
| 55 | + | match self { | |
| 56 | + | Self::Screen(_) => None, | |
| 57 | + | Self::Fragment { region, .. } => Some(region), | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | impl From<Screen> for Response { | |
| 63 | + | fn from(screen: Screen) -> Self { | |
| 64 | + | Self::Screen(screen) | |
| 65 | + | } | |
| 66 | + | } |
| @@ -1,0 +1,188 @@ | |||
| 1 | + | //! The route table, and dispatch through it. | |
| 2 | + | //! | |
| 3 | + | //! Decision 8 on the wiki note: `Router<S>`, generic over the app's own state. | |
| 4 | + | //! quasi owns matching and dispatch, the app owns `S`. Axum-shaped on purpose, | |
| 5 | + | //! so that a Rust developer recognises it, and concrete enough that a scaffolder | |
| 6 | + | //! has something to generate. | |
| 7 | + | //! | |
| 8 | + | //! The rejected thin version, where quasi parses paths and each app writes its | |
| 9 | + | //! own `match`, would leave this a path library. There has to be something that | |
| 10 | + | //! only works if you buy in, or the stack claim is documentation. | |
| 11 | + | ||
| 12 | + | use crate::error::{Class, RouteError}; | |
| 13 | + | use crate::path::Pattern; | |
| 14 | + | use crate::request::{Method, Params}; | |
| 15 | + | use crate::response::Response; | |
| 16 | + | ||
| 17 | + | /// What a route does. | |
| 18 | + | /// | |
| 19 | + | /// Decision 6: sync, no `async`. The two renderers that ship at launch decide | |
| 20 | + | /// it. egui calls the router inside a frame and the terminal inside an event | |
| 21 | + | /// loop, and neither can await without a runtime it does not otherwise need; | |
| 22 | + | /// audiofiles has no tokio at all today and this keeps it that way. It also | |
| 23 | + | /// matches the store, since the desktop apps moved to rusqlite and took their | |
| 24 | + | /// repository traits sync in the same pass. A hosted axum pays a | |
| 25 | + | /// `spawn_blocking`, which is what an axum handler over a blocking store pays | |
| 26 | + | /// anyway. | |
| 27 | + | /// | |
| 28 | + | /// A function pointer rather than a boxed closure. Handlers are free functions | |
| 29 | + | /// taking the app's state by reference, which is the whole discipline: a | |
| 30 | + | /// handler that needed to capture something would be holding state the router | |
| 31 | + | /// cannot see. It is also the widenable direction, since an `fn` coerces into a | |
| 32 | + | /// `Box<dyn Fn>` and nothing coerces back. | |
| 33 | + | pub type Handler<S> = fn(&S, Params) -> Result<Response, RouteError>; | |
| 34 | + | ||
| 35 | + | /// One registered route. | |
| 36 | + | struct Route<S> { | |
| 37 | + | method: Method, | |
| 38 | + | pattern: Pattern, | |
| 39 | + | handler: Handler<S>, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// The route table. | |
| 43 | + | /// | |
| 44 | + | /// Built once at startup and read for the life of the program. Registration | |
| 45 | + | /// order does not matter: routes are kept most-specific-first, so `/task/new` | |
| 46 | + | /// is tried before `/task/:id` however they were declared. | |
| 47 | + | pub struct Router<S> { | |
| 48 | + | routes: Vec<Route<S>>, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | impl<S> Router<S> { | |
| 52 | + | /// An empty table. | |
| 53 | + | #[must_use] | |
| 54 | + | pub fn new() -> Self { | |
| 55 | + | Self { routes: Vec::new() } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Register a read. | |
| 59 | + | #[must_use] | |
| 60 | + | pub fn get(self, path: &str, handler: Handler<S>) -> Self { | |
| 61 | + | self.route(Method::Get, path, handler) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /// Register a write. | |
| 65 | + | #[must_use] | |
| 66 | + | pub fn post(self, path: &str, handler: Handler<S>) -> Self { | |
| 67 | + | self.route(Method::Post, path, handler) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// Register a route. | |
| 71 | + | /// | |
| 72 | + | /// # Panics | |
| 73 | + | /// | |
| 74 | + | /// If the path is malformed, or if the same method and pattern are already | |
| 75 | + | /// registered. Both are bugs in a startup literal, and a route table that | |
| 76 | + | /// silently keeps the first of two registrations is a bug that presents as | |
| 77 | + | /// a screen quietly not updating months later. | |
| 78 | + | #[must_use] | |
| 79 | + | pub fn route(mut self, method: Method, path: &str, handler: Handler<S>) -> Self { | |
| 80 | + | let pattern = Pattern::parse(path); | |
| 81 | + | ||
| 82 | + | assert!( | |
| 83 | + | !self | |
| 84 | + | .routes | |
| 85 | + | .iter() | |
| 86 | + | .any(|r| r.method == method && r.pattern == pattern), | |
| 87 | + | "route `{method} {path}` is registered twice" | |
| 88 | + | ); | |
| 89 | + | ||
| 90 | + | // Most specific first, and stable within one specificity so that two | |
| 91 | + | // equally specific routes keep the order they were written in. | |
| 92 | + | let at = self | |
| 93 | + | .routes | |
| 94 | + | .partition_point(|r| r.pattern.specificity() >= pattern.specificity()); | |
| 95 | + | self.routes.insert( | |
| 96 | + | at, | |
| 97 | + | Route { | |
| 98 | + | method, | |
| 99 | + | pattern, | |
| 100 | + | handler, | |
| 101 | + | }, | |
| 102 | + | ); | |
| 103 | + | self | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /// Answer a request. | |
| 107 | + | /// | |
| 108 | + | /// `params` is whatever the host already parsed: a query string, a form | |
| 109 | + | /// body, the values a key binding sends. Path captures are added to it, and | |
| 110 | + | /// a capture wins where the names collide, since the path is the address and | |
| 111 | + | /// the body is only what was sent to it. | |
| 112 | + | pub fn handle( | |
| 113 | + | &self, | |
| 114 | + | state: &S, | |
| 115 | + | method: Method, | |
| 116 | + | path: &str, | |
| 117 | + | params: Params, | |
| 118 | + | ) -> Result<Response, RouteError> { | |
| 119 | + | let mut wrong_method = false; | |
| 120 | + | ||
| 121 | + | for route in &self.routes { | |
| 122 | + | let Some(captures) = route.pattern.match_path(path) else { | |
| 123 | + | continue; | |
| 124 | + | }; | |
| 125 | + | if route.method != method { | |
| 126 | + | wrong_method = true; | |
| 127 | + | continue; | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | let mut merged = captures; | |
| 131 | + | merged.absorb(params); | |
| 132 | + | return (route.handler)(state, merged); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // A path that exists under another verb is still a `NotFound` rather | |
| 136 | + | // than an `Internal`, even though reaching it means our own renderer | |
| 137 | + | // emitted the wrong verb. The reason is what a host does with the | |
| 138 | + | // class: an HTTP adapter answering 500 to a probe turns a scan into a | |
| 139 | + | // page, and the message carries the detail an operator needs anyway. | |
| 140 | + | Err(RouteError::new( | |
| 141 | + | Class::NotFound, | |
| 142 | + | if wrong_method { | |
| 143 | + | format!("no route for {method} {path}, though the path answers another method") | |
| 144 | + | } else { | |
| 145 | + | format!("no route for {method} {path}") | |
| 146 | + | }, | |
| 147 | + | )) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | /// Every registered route, most specific first. | |
| 151 | + | /// | |
| 152 | + | /// For a scaffolder generating a client, a test asserting the table, and an | |
| 153 | + | /// adapter that wants to log what it is serving. | |
| 154 | + | pub fn routes(&self) -> impl Iterator<Item = (Method, &str)> { | |
| 155 | + | self.routes.iter().map(|r| (r.method, r.pattern.source())) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// How many routes are registered. | |
| 159 | + | #[must_use] | |
| 160 | + | pub fn len(&self) -> usize { | |
| 161 | + | self.routes.len() | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | /// Whether the table is empty. | |
| 165 | + | #[must_use] | |
| 166 | + | pub fn is_empty(&self) -> bool { | |
| 167 | + | self.routes.is_empty() | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | impl<S> Default for Router<S> { | |
| 172 | + | fn default() -> Self { | |
| 173 | + | Self::new() | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | impl<S> std::fmt::Debug for Router<S> { | |
| 178 | + | /// The table, without pretending a function pointer is worth printing. | |
| 179 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 180 | + | f.debug_list() | |
| 181 | + | .entries( | |
| 182 | + | self.routes | |
| 183 | + | .iter() | |
| 184 | + | .map(|r| format!("{} {}", r.method, r.pattern.source())), | |
| 185 | + | ) | |
| 186 | + | .finish() | |
| 187 | + | } | |
| 188 | + | } |
| @@ -1,0 +1,784 @@ | |||
| 1 | + | //! The screen tree: what a route answers with. | |
| 2 | + | //! | |
| 3 | + | //! Decision 3 on the wiki note puts this in quasi rather than in | |
| 4 | + | //! `makeover-layout`, for two reasons. The tree will churn while the router is | |
| 5 | + | //! being proven against a second host, and churning it here costs nothing where | |
| 6 | + | //! churning it there is a breaking release against three adopters. And a route | |
| 7 | + | //! is an address, which is the one thing `makeover-layout`'s deferral rule says | |
| 8 | + | //! it never names. | |
| 9 | + | //! | |
| 10 | + | //! # What this crate adds, and what it does not | |
| 11 | + | //! | |
| 12 | + | //! It adds three things: an [`Action`], which is an address; a [`Slot`], which | |
| 13 | + | //! is a region with a name a fragment can be aimed at; and ownership. | |
| 14 | + | //! | |
| 15 | + | //! Everything else is `makeover-layout`'s. Every member of [`Node`] composes a | |
| 16 | + | //! vocabulary that already exists there, and that is the admission test for a | |
| 17 | + | //! new one: if the thing being drawn has no name in the description layer, it | |
| 18 | + | //! does not get a node here, it gets a [`Region::Bespoke`] or it gets named | |
| 19 | + | //! there first. Without that rule this file becomes a widget library, which is | |
| 20 | + | //! the failure `makeover-layout` was extracted to prevent. | |
| 21 | + | //! | |
| 22 | + | //! # Why these are owned when the description layer is borrowed | |
| 23 | + | //! | |
| 24 | + | //! `makeover-layout`'s structs borrow, because a description is built, read | |
| 25 | + | //! once and dropped inside one frame. A router's answer outlives its handler by | |
| 26 | + | //! construction: it is returned from a function, and its text is usually built | |
| 27 | + | //! from state rather than found in it. So [`Field`], [`Choice`] and [`Column`] | |
| 28 | + | //! have owned mirrors here, each with a conversion back, and the conversion is | |
| 29 | + | //! what keeps them from drifting: adding a field over there stops the mirror | |
| 30 | + | //! compiling over here. | |
| 31 | + | ||
| 32 | + | use makeover_layout as layout; | |
| 33 | + | ||
| 34 | + | use crate::request::{Method, Params}; | |
| 35 | + | ||
| 36 | + | /// An address a control calls when it acts. | |
| 37 | + | /// | |
| 38 | + | /// Decision 2: an action is a route. The webview emits this as an `hx-get` or | |
| 39 | + | /// `hx-post`, the terminal binds a key to it, egui calls it directly. All three | |
| 40 | + | /// are calling the same path with the same verb. | |
| 41 | + | /// | |
| 42 | + | /// It does not carry a target. What a response replaces is the *response's* | |
| 43 | + | /// business, per decision 7, because the router is the only party that knows | |
| 44 | + | /// what it just changed. | |
| 45 | + | #[derive(Debug, Clone, PartialEq, Eq, Default)] | |
| 46 | + | pub struct Action { | |
| 47 | + | /// Asking or telling. | |
| 48 | + | pub method: Method, | |
| 49 | + | /// Where. | |
| 50 | + | pub path: String, | |
| 51 | + | /// Values the control sends that are not in the path. | |
| 52 | + | /// | |
| 53 | + | /// A webview appends these to a query string or emits them as `hx-vals`; a | |
| 54 | + | /// terminal passes them straight through. Here so that no app hand-builds a | |
| 55 | + | /// query string, which is where escaping bugs live. | |
| 56 | + | pub params: Params, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | impl Action { | |
| 60 | + | /// A read. | |
| 61 | + | pub fn get(path: impl Into<String>) -> Self { | |
| 62 | + | Self { | |
| 63 | + | method: Method::Get, | |
| 64 | + | path: path.into(), | |
| 65 | + | params: Params::new(), | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// A write. | |
| 70 | + | pub fn post(path: impl Into<String>) -> Self { | |
| 71 | + | Self { | |
| 72 | + | method: Method::Post, | |
| 73 | + | path: path.into(), | |
| 74 | + | params: Params::new(), | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Send a value along with the call. | |
| 79 | + | #[must_use] | |
| 80 | + | pub fn with(mut self, name: impl Into<String>, value: impl Into<String>) -> Self { | |
| 81 | + | self.params.insert(name, value); | |
| 82 | + | self | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// One option offered by a field, owned. | |
| 87 | + | /// | |
| 88 | + | /// The borrowed original is `makeover-layout`'s [`layout::Choice`]. Two strings | |
| 89 | + | /// rather than one for the reason recorded there: the submitted value and the | |
| 90 | + | /// read label are different facts, and every renderer that collapsed them has | |
| 91 | + | /// had to un-collapse them later. | |
| 92 | + | #[derive(Debug, Clone, PartialEq, Eq, Hash)] | |
| 93 | + | pub struct Choice { | |
| 94 | + | /// What is submitted. | |
| 95 | + | pub value: String, | |
| 96 | + | /// What is read. | |
| 97 | + | pub label: String, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | impl Choice { | |
| 101 | + | /// An option whose submitted value is also its label. | |
| 102 | + | pub fn plain(value: impl Into<String>) -> Self { | |
| 103 | + | let value = value.into(); | |
| 104 | + | Self { | |
| 105 | + | label: value.clone(), | |
| 106 | + | value, | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /// An option that reads differently from what it submits. | |
| 111 | + | pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self { | |
| 112 | + | Self { | |
| 113 | + | value: value.into(), | |
| 114 | + | label: label.into(), | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// Borrow as the description layer's own type. | |
| 119 | + | #[must_use] | |
| 120 | + | pub fn as_layout(&self) -> layout::Choice<'_> { | |
| 121 | + | layout::Choice { | |
| 122 | + | value: &self.value, | |
| 123 | + | label: &self.label, | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | /// One field of a form, owned. | |
| 129 | + | /// | |
| 130 | + | /// The borrowed original is [`layout::Field`], and everything it says about | |
| 131 | + | /// what a field carries applies unchanged. In particular this does not carry | |
| 132 | + | /// the current value, and it is not going to: that is genuinely renderer state, | |
| 133 | + | /// and a description that carried it would need a way to write it back, at | |
| 134 | + | /// which point it is a form model. | |
| 135 | + | #[derive(Debug, Clone, PartialEq, Eq, Hash)] | |
| 136 | + | pub struct Field { | |
| 137 | + | /// What kind of value it takes. | |
| 138 | + | pub kind: layout::FieldKind, | |
| 139 | + | /// The name the value is submitted under, and the name the handler reads | |
| 140 | + | /// back out of [`Params`]. | |
| 141 | + | pub name: String, | |
| 142 | + | /// What the user is asked for. | |
| 143 | + | pub label: String, | |
| 144 | + | /// Standing help. | |
| 145 | + | pub hint: Option<String>, | |
| 146 | + | /// What is currently wrong with the value. Supplied by whoever validated; | |
| 147 | + | /// nothing here decides that a value is wrong. | |
| 148 | + | pub error: Option<String>, | |
| 149 | + | /// Ghost text shown while the field is empty. | |
| 150 | + | pub placeholder: Option<String>, | |
| 151 | + | /// The options offered, in order. Empty for kinds that offer none. | |
| 152 | + | pub options: Vec<Choice>, | |
| 153 | + | /// Whether the form refuses to submit without it. | |
| 154 | + | pub required: bool, | |
| 155 | + | /// Whether the field lives behind a "more options" disclosure. | |
| 156 | + | pub extended: bool, | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | impl Field { | |
| 160 | + | /// A plain optional field of the given kind. | |
| 161 | + | pub fn new(kind: layout::FieldKind, name: impl Into<String>, label: impl Into<String>) -> Self { | |
| 162 | + | Self { | |
| 163 | + | kind, | |
| 164 | + | name: name.into(), | |
| 165 | + | label: label.into(), | |
| 166 | + | hint: None, | |
| 167 | + | error: None, | |
| 168 | + | placeholder: None, | |
| 169 | + | options: Vec::new(), | |
| 170 | + | required: false, | |
| 171 | + | extended: false, | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | /// A select offering the given options. | |
| 176 | + | pub fn select(name: impl Into<String>, label: impl Into<String>, options: Vec<Choice>) -> Self { | |
| 177 | + | Self { | |
| 178 | + | options, | |
| 179 | + | ..Self::new(layout::FieldKind::Select, name, label) | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | /// A radio group offering the given options. | |
| 184 | + | pub fn radio(name: impl Into<String>, label: impl Into<String>, options: Vec<Choice>) -> Self { | |
| 185 | + | Self { | |
| 186 | + | options, | |
| 187 | + | ..Self::new(layout::FieldKind::Radio, name, label) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | /// The form refuses to submit without it. | |
| 192 | + | #[must_use] | |
| 193 | + | pub fn required(mut self) -> Self { | |
| 194 | + | self.required = true; | |
| 195 | + | self | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | /// Standing help, shown whether or not anything is wrong. | |
| 199 | + | #[must_use] | |
| 200 | + | pub fn hint(mut self, hint: impl Into<String>) -> Self { | |
| 201 | + | self.hint = Some(hint.into()); | |
| 202 | + | self | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | /// What is wrong with the value now. | |
| 206 | + | #[must_use] | |
| 207 | + | pub fn error(mut self, error: impl Into<String>) -> Self { | |
| 208 | + | self.error = Some(error.into()); | |
| 209 | + | self | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | /// Whether the field is currently reporting a problem. | |
| 213 | + | #[must_use] | |
| 214 | + | pub fn invalid(&self) -> bool { | |
| 215 | + | self.error.is_some() | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | /// Read this field as the description layer's own type. | |
| 219 | + | /// | |
| 220 | + | /// A callback rather than a return, because [`layout::Field`] holds its | |
| 221 | + | /// options as a slice and ours holds them as owned values, so the borrowed | |
| 222 | + | /// slice has to live somewhere for the duration of the read. Building it | |
| 223 | + | /// here means one allocation at the renderer's boundary instead of the | |
| 224 | + | /// borrow leaking into every caller's signature. | |
| 225 | + | pub fn with_layout<R>(&self, f: impl FnOnce(layout::Field<'_>) -> R) -> R { | |
| 226 | + | let options: Vec<layout::Choice<'_>> = self.options.iter().map(Choice::as_layout).collect(); | |
| 227 | + | f(layout::Field { | |
| 228 | + | kind: self.kind, | |
| 229 | + | name: &self.name, | |
| 230 | + | label: &self.label, | |
| 231 | + | hint: self.hint.as_deref(), | |
| 232 | + | error: self.error.as_deref(), | |
| 233 | + | placeholder: self.placeholder.as_deref(), | |
| 234 | + | options: &options, | |
| 235 | + | required: self.required, | |
| 236 | + | extended: self.extended, | |
| 237 | + | }) | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | /// One column of a table, owned. | |
| 242 | + | /// | |
| 243 | + | /// The borrowed original is [`layout::Column`]. The `name` is both the heading | |
| 244 | + | /// and the address a cell is found by, which is what replaces addressing | |
| 245 | + | /// columns by position. | |
| 246 | + | #[derive(Debug, Clone, PartialEq, Eq, Hash)] | |
| 247 | + | pub struct Column { | |
| 248 | + | /// The heading, and the name the cell is addressed by. | |
| 249 | + | pub name: String, | |
| 250 | + | /// How much room it asks for. | |
| 251 | + | pub width: layout::Width, | |
| 252 | + | /// What it is worth when room runs out. | |
| 253 | + | pub priority: layout::Priority, | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | impl Column { | |
| 257 | + | /// A column that absorbs slack and drops after the optional ones. | |
| 258 | + | pub fn new(name: impl Into<String>) -> Self { | |
| 259 | + | Self { | |
| 260 | + | name: name.into(), | |
| 261 | + | width: layout::Width::Fill, | |
| 262 | + | priority: layout::Priority::Secondary, | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | /// Set how much room it asks for. | |
| 267 | + | #[must_use] | |
| 268 | + | pub fn width(mut self, width: layout::Width) -> Self { | |
| 269 | + | self.width = width; | |
| 270 | + | self | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | /// Set what it is worth when room runs out. | |
| 274 | + | #[must_use] | |
| 275 | + | pub fn priority(mut self, priority: layout::Priority) -> Self { | |
| 276 | + | self.priority = priority; | |
| 277 | + | self | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | /// Borrow as the description layer's own type. | |
| 281 | + | #[must_use] | |
| 282 | + | pub fn as_layout(&self) -> layout::Column<'_> { | |
| 283 | + | layout::Column { | |
| 284 | + | name: &self.name, | |
| 285 | + | width: self.width, | |
| 286 | + | priority: self.priority, | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | /// Which region this is, owned. | |
| 292 | + | /// | |
| 293 | + | /// The borrowed original is [`layout::Region`], and only one member borrows: | |
| 294 | + | /// [`layout::Region::Bespoke`] carries a name the app owns and this crate never | |
| 295 | + | /// interprets. | |
| 296 | + | #[derive(Debug, Clone, PartialEq, Eq, Hash)] | |
| 297 | + | pub enum RegionKind { | |
| 298 | + | /// A full-width strip with a title slot and an actions cluster. | |
| 299 | + | Band, | |
| 300 | + | /// A persistent column beside the content, holding navigation. | |
| 301 | + | Sidebar, | |
| 302 | + | /// A region of content with its own scroll. | |
| 303 | + | Pane, | |
| 304 | + | /// Two panes side by side, the left choosing what the right shows. | |
| 305 | + | Split, | |
| 306 | + | /// A set of panes, one visible at a time, with tabs above. | |
| 307 | + | TabGroup, | |
| 308 | + | /// Content over a scrim, taking input until dismissed. | |
| 309 | + | Modal, | |
| 310 | + | /// A place, and nothing else. The app fills it per host. | |
| 311 | + | /// | |
| 312 | + | /// Decision 4: the renderer hands the space over and the app puts a JS | |
| 313 | + | /// component, an egui closure or a TUI widget in it. The rejected | |
| 314 | + | /// alternative was giving the placeholder its own route and fetching a | |
| 315 | + | /// fragment for it, which is uniform on paper and wrong in currency: a byte | |
| 316 | + | /// payload is not what egui or a terminal wants. | |
| 317 | + | Bespoke { | |
| 318 | + | /// What the app calls it. Never interpreted here. | |
| 319 | + | name: String, | |
| 320 | + | }, | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | impl RegionKind { | |
| 324 | + | /// Borrow as the description layer's own type. | |
| 325 | + | #[must_use] | |
| 326 | + | pub fn as_layout(&self) -> layout::Region<'_> { | |
| 327 | + | match self { | |
| 328 | + | Self::Band => layout::Region::Band, | |
| 329 | + | Self::Sidebar => layout::Region::Sidebar, | |
| 330 | + | Self::Pane => layout::Region::Pane, | |
| 331 | + | Self::Split => layout::Region::Split, | |
| 332 | + | Self::TabGroup => layout::Region::TabGroup, | |
| 333 | + | Self::Modal => layout::Region::Modal, | |
| 334 | + | Self::Bespoke { name } => layout::Region::Bespoke { name }, | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | /// Whether the description can say anything about the contents. | |
| 339 | + | #[must_use] | |
| 340 | + | pub fn described(&self) -> bool { | |
| 341 | + | self.as_layout().described() | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | /// How the region sits on what is behind it. | |
| 345 | + | #[must_use] | |
| 346 | + | pub fn depth(&self) -> layout::Depth { | |
| 347 | + | self.as_layout().depth() | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | /// A named region, and the thing a fragment is aimed at. | |
| 352 | + | /// | |
| 353 | + | /// The name is what decision 7 needs and [`layout::Region`] deliberately does | |
| 354 | + | /// not have: two panes in a split are both `Pane`, so the kind cannot be an | |
| 355 | + | /// address. A webview maps the id onto `hx-target`; egui and the terminal | |
| 356 | + | /// ignore it and redraw, which costs them nothing because they were redrawing | |
| 357 | + | /// anyway. | |
| 358 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 359 | + | pub struct Slot { | |
| 360 | + | /// The address. Unique within a screen, and stable across responses, or a | |
| 361 | + | /// fragment lands nowhere. | |
| 362 | + | pub id: String, | |
| 363 | + | /// Which region it is. | |
| 364 | + | pub kind: RegionKind, | |
| 365 | + | /// Whether the content is here or on its way. | |
| 366 | + | pub readiness: layout::Readiness, | |
| 367 | + | /// What is in it. | |
| 368 | + | pub body: Vec<Node>, | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | impl Slot { | |
| 372 | + | /// An empty region under this address. | |
| 373 | + | pub fn new(id: impl Into<String>, kind: RegionKind) -> Self { | |
| 374 | + | Self { | |
| 375 | + | id: id.into(), | |
| 376 | + | kind, | |
| 377 | + | readiness: layout::Readiness::Ready, | |
| 378 | + | body: Vec::new(), | |
| 379 | + | } | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | /// A place the app fills itself. | |
| 383 | + | pub fn bespoke(id: impl Into<String>, name: impl Into<String>) -> Self { | |
| 384 | + | Self::new(id, RegionKind::Bespoke { name: name.into() }) | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | /// Add a node, chaining. | |
| 388 | + | #[must_use] | |
| 389 | + | pub fn with(mut self, node: Node) -> Self { | |
| 390 | + | self.body.push(node); | |
| 391 | + | self | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | /// Add several nodes, chaining. | |
| 395 | + | #[must_use] | |
| 396 | + | pub fn extend(mut self, nodes: impl IntoIterator<Item = Node>) -> Self { | |
| 397 | + | self.body.extend(nodes); | |
| 398 | + | self | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | /// The content is on its way rather than here. | |
| 402 | + | #[must_use] | |
| 403 | + | pub fn pending(mut self) -> Self { | |
| 404 | + | self.readiness = layout::Readiness::Pending; | |
| 405 | + | self | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | /// This slot, or the first slot under this address anywhere inside it. | |
| 409 | + | #[must_use] | |
| 410 | + | pub fn find(&self, id: &str) -> Option<&Self> { | |
| 411 | + | if self.id == id { | |
| 412 | + | return Some(self); | |
| 413 | + | } | |
| 414 | + | self.body.iter().find_map(|node| match node { | |
| 415 | + | Node::Region(slot) => slot.find(id), | |
| 416 | + | _ => None, | |
| 417 | + | }) | |
| 418 | + | } | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | /// A control that calls a route. | |
| 422 | + | /// | |
| 423 | + | /// A button, a link and a menu item are the same thing to a description: a | |
| 424 | + | /// label, an address, and how loudly it is saying it. Which of the three a | |
| 425 | + | /// renderer draws is a renderer decision. | |
| 426 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 427 | + | pub struct Act { | |
| 428 | + | /// What it is called. | |
| 429 | + | pub label: String, | |
| 430 | + | /// What it calls. | |
| 431 | + | pub action: Action, | |
| 432 | + | /// What it is saying. [`layout::Tone::Danger`] is what marks the button | |
| 433 | + | /// that destroys something. | |
| 434 | + | pub tone: layout::Tone, | |
| 435 | + | /// Focused, disabled, or neither. | |
| 436 | + | pub state: Option<layout::State>, | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | impl Act { | |
| 440 | + | /// A neutral control calling this route. | |
| 441 | + | pub fn new(label: impl Into<String>, action: Action) -> Self { | |
| 442 | + | Self { | |
| 443 | + | label: label.into(), | |
| 444 | + | action, | |
| 445 | + | tone: layout::Tone::Neutral, | |
| 446 | + | state: None, | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | /// Set what it is saying. | |
| 451 | + | #[must_use] | |
| 452 | + | pub fn tone(mut self, tone: layout::Tone) -> Self { | |
| 453 | + | self.tone = tone; | |
| 454 | + | self | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | /// Present, visible, and not answering. | |
| 458 | + | #[must_use] | |
| 459 | + | pub fn disabled(mut self) -> Self { | |
| 460 | + | self.state = Some(layout::State::Disabled); | |
| 461 | + | self | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | /// Whether the control currently answers input. | |
| 465 | + | #[must_use] | |
| 466 | + | pub fn interactive(&self) -> bool { | |
| 467 | + | !self | |
| 468 | + | .state | |
| 469 | + | .is_some_and(layout::State::suppresses_interaction) | |
| 470 | + | } | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | /// One row of a list. | |
| 474 | + | /// | |
| 475 | + | /// The four parts are [`layout::RowPart`]'s four, which is where they came | |
| 476 | + | /// from: a row that needed a fifth would be a table. | |
| 477 | + | #[derive(Debug, Clone, PartialEq, Eq, Default)] | |
| 478 | + | pub struct Row { | |
| 479 | + | /// The thing itself. What the row is called. | |
| 480 | + | pub primary: String, | |
| 481 | + | /// Supporting text under the primary. | |
| 482 | + | pub secondary: Option<String>, | |
| 483 | + | /// A short trailing fact: a count, a size, a date. | |
| 484 | + | pub meta: Option<String>, | |
| 485 | + | /// Controls that act on this row. | |
| 486 | + | pub actions: Vec<Act>, | |
| 487 | + | /// The route that selects this row, if selecting it does anything. | |
| 488 | + | pub activate: Option<Action>, | |
| 489 | + | /// Whether this is the row the detail side is currently showing. | |
| 490 | + | pub selected: bool, | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | impl Row { | |
| 494 | + | /// A row with only its primary text. | |
| 495 | + | pub fn new(primary: impl Into<String>) -> Self { | |
| 496 | + | Self { | |
| 497 | + | primary: primary.into(), | |
| 498 | + | ..Self::default() | |
| 499 | + | } | |
| 500 | + | } |
Lines truncated