Skip to main content

max / quasi

Implement quasi-router: the host-agnostic router Carries decisions 2 through 9 from the wiki note quasi-overview, which between them fix the whole contract: - Method is Get or Post and nothing else. One address space, the verb separating a read from a write. - Screen, Slot and Node live here rather than in makeover-layout. The tree will churn while the router is proven against a second host, and a route is an address, which the description layer never names. - Every Node member composes something makeover-layout already names. That is the admission test, and it is what keeps this from becoming a widget library. - Owned mirrors for Field, Choice, Column and Region, each converting back, so adding a field over there stops the mirror compiling here. - Response is a whole Screen or a Fragment naming the region it replaces. The router is the only party that knows what it changed. - RouteError carries a class and a Notice, so a host maps one to a status code and another to a banner in one place. - Router<S> is sync and generic over app state, most-specific-first, so /task/new beats /task/:id however they were declared. Also fixes two stale README claims: the description layer it depends on has shipped, and the two stores are not one query layer.
Author: Max Johnson <me@maxj.phd> · 2026-08-08 21:57 UTC
Signed with PGP, not checked
Commit: 4b8d5189023c778ba3d72f05463d1cf2bcfb673c
Parent: 524055f
11 files changed, +1649 insertions, -13 deletions
M Cargo.lock +34 -1
@@ -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"
M Cargo.toml +23
@@ -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"
M README.md +22 -7
@@ -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