max / quasi
8 files changed,
+172 insertions,
-73 deletions
| @@ -257,6 +257,37 @@ | |||
| 257 | 257 | } | |
| 258 | 258 | } | |
| 259 | 259 | ||
| 260 | + | #[test] | |
| 261 | + | fn every_git_dependency_the_template_declares_carries_a_version() { | |
| 262 | + | // The house rule, asserted here because nothing else can. The tree's | |
| 263 | + | // `internal-deps.py` gate reads `Cargo.toml` files, and every manifest | |
| 264 | + | // in this template is a `Cargo.toml.tmpl` it never opens, so a bare git | |
| 265 | + | // URL added here would be the one site in the tree with no gate over | |
| 266 | + | // it. What the requirement buys is a resolution error instead of a | |
| 267 | + | // compile error further in: a generated app is the one consumer that | |
| 268 | + | // does not get the tree's `[patch]` block, so the URL is what it really | |
| 269 | + | // resolves against. | |
| 270 | + | for entry in FILES { | |
| 271 | + | if !entry.path.ends_with("Cargo.toml") { | |
| 272 | + | continue; | |
| 273 | + | } | |
| 274 | + | let Body::Text(source) = entry.body else { | |
| 275 | + | panic!("{} is text", entry.path); | |
| 276 | + | }; | |
| 277 | + | for line in source | |
| 278 | + | .lines() | |
| 279 | + | .filter(|line| !line.trim_start().starts_with('#')) | |
| 280 | + | .filter(|line| line.contains("git = \"")) | |
| 281 | + | { | |
| 282 | + | assert!( | |
| 283 | + | line.contains("version = \""), | |
| 284 | + | "{}: a git dependency with no version: {line}", | |
| 285 | + | entry.path | |
| 286 | + | ); | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | ||
| 260 | 291 | #[test] | |
| 261 | 292 | fn every_crate_the_workspace_declares_has_a_manifest() { | |
| 262 | 293 | let workspace = FILES |
| @@ -42,7 +42,7 @@ | |||
| 42 | 42 | # The markdown engine. Already in the tree through quasi-webview, which renders | |
| 43 | 43 | # every `Node::Rich` and every `Prose::Rich` with it; declared here because the | |
| 44 | 44 | # core crate needs the other direction, markdown to text, for a row's preview. | |
| 45 | - | docengine = { git = "https://makenot.work/git/max/docengine.git" } | |
| 45 | + | docengine = { git = "https://makenot.work/git/max/docengine.git", version = "0.7" } | |
| 46 | 46 | ||
| 47 | 47 | rusqlite = { version = "0.40.0", features = ["bundled"] } | |
| 48 | 48 | thiserror = "2.0.17" |
| @@ -35,8 +35,11 @@ | |||
| 35 | 35 | # resolution, which is the one part it does not wrap. | |
| 36 | 36 | # | |
| 37 | 37 | # Both track the published suite rather than whatever was current when the | |
| 38 | - | # template was written. A generated app takes these verbatim, so a stale pin | |
| 39 | - | # here is not one app a release behind: it is every app anyone scaffolds from | |
| 40 | - | # now on. 2.4.1 and 0.13.0 sat here while the suite reached 2.5.0 and 0.20.1. | |
| 41 | - | makeover = "2.5" | |
| 42 | - | makeover-build = "0.28.0" | |
| 38 | + | # template was written, and published is the word that matters: a generated app | |
| 39 | + | # resolves these off crates.io, so the number to read is the one in the index | |
| 40 | + | # and never the one in the working copy. A generated app takes these verbatim, | |
| 41 | + | # so a stale pin here is not one app a release behind: it is every app anyone | |
| 42 | + | # scaffolds from now on. 2.4.1 and 0.13.0 sat here once, and 2.5 and 0.28.0 | |
| 43 | + | # after that, while the suite reached 3.0.0 and 0.47.0. | |
| 44 | + | makeover = "3.0" | |
| 45 | + | makeover-build = "0.47.0" |
| @@ -13,7 +13,10 @@ | |||
| 13 | 13 | ||
| 14 | 14 | [dependencies] | |
| 15 | 15 | {{name}}-core = { path = "../{{name}}-core" } | |
| 16 | - | quasi-tauri = { git = "https://makenot.work/git/max/quasi.git" } | |
| 16 | + | # The Tauri host adapter. The `version` is not decoration: cargo checks it | |
| 17 | + | # against whatever the branch head resolves to, so a generated app fails at | |
| 18 | + | # resolution rather than compiling against a sibling it was not written for. | |
| 19 | + | quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.44" } | |
| 17 | 20 | quasi-router = { workspace = true } | |
| 18 | 21 | quasi-webview = { workspace = true } | |
| 19 | 22 |
| @@ -13,7 +13,10 @@ | |||
| 13 | 13 | ||
| 14 | 14 | [dependencies] | |
| 15 | 15 | {{name}}-core = { path = "../{{name}}-core" } | |
| 16 | - | quasi-axum = { git = "https://makenot.work/git/max/quasi.git" } | |
| 16 | + | # The axum host adapter, carrying a `version` for the reason every other git | |
| 17 | + | # dependency here does: the requirement is what makes a mismatch a resolution | |
| 18 | + | # error instead of a compile error somewhere further in. | |
| 19 | + | quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.44" } | |
| 17 | 20 | quasi-router = { workspace = true } | |
| 18 | 21 | quasi-webview = { workspace = true } | |
| 19 | 22 |
| @@ -24,5 +24,5 @@ | |||
| 24 | 24 | # theirs as CSS from `build.rs`; a terminal reads the same theme file and | |
| 25 | 25 | # resolves it to sixteen colours or to truecolor depending on what the terminal | |
| 26 | 26 | # admits to. | |
| 27 | - | makeover = "2.5" | |
| 28 | - | makeover-tui = { version = "0.25.0", features = ["theme"] } | |
| 27 | + | makeover = "3.0" | |
| 28 | + | makeover-tui = { version = "0.30.0", features = ["theme"] } |
| @@ -22,6 +22,12 @@ | |||
| 22 | 22 | //! runtime.apply ◄─────────┘ | |
| 23 | 23 | //! ``` | |
| 24 | 24 | //! | |
| 25 | + | //! A field that consults a route while it is typed in answers with | |
| 26 | + | //! `Step::CallAfter` instead, which is the same picture with a wait in front of | |
| 27 | + | //! it. The wait is run here, because the clock belongs to whoever owns the | |
| 28 | + | //! terminal: `wait` blocks until a key or until the soonest question comes due, | |
| 29 | + | //! and the next key drops what the last one was holding. | |
| 30 | + | //! | |
| 25 | 31 | //! # Authentication | |
| 26 | 32 | //! | |
| 27 | 33 | //! None. See the module docs of `{{name}}_core`. For this host the answer is | |
| @@ -31,6 +37,7 @@ | |||
| 31 | 37 | use std::io; | |
| 32 | 38 | use std::path::PathBuf; | |
| 33 | 39 | use std::sync::Arc; | |
| 40 | + | use std::time::Instant; | |
| 34 | 41 | ||
| 35 | 42 | use quasi_router::{Action, Request, RouteError}; | |
| 36 | 43 | use quasi_tui::{Key, Runtime, Step, Tui}; | |
| @@ -102,6 +109,12 @@ | |||
| 102 | 109 | ) -> io::Result<()> { | |
| 103 | 110 | // What a control asked before doing itself, held while the answer is typed. | |
| 104 | 111 | let mut question: Option<String> = None; | |
| 112 | + | // Questions the description asked for later, each with the moment it comes | |
| 113 | + | // due. `Step::CallAfter` is one keystroke's worth of waiting, so a newer | |
| 114 | + | // keystroke replaces the whole set rather than queueing behind it, and the | |
| 115 | + | // clock is the host's: the runtime turns keys into requests and runs no | |
| 116 | + | // timer of its own. | |
| 117 | + | let mut waiting: Vec<(Instant, Request)> = Vec::new(); | |
| 105 | 118 | ||
| 106 | 119 | loop { | |
| 107 | 120 | terminal.draw(|frame| { | |
| @@ -123,7 +136,18 @@ | |||
| 123 | 136 | } | |
| 124 | 137 | })?; | |
| 125 | 138 | ||
| 126 | - | let Event::Key(pressed) = event::read()? else { | |
| 139 | + | // A key, or the earliest outstanding question coming due, whichever | |
| 140 | + | // arrives first. With nothing waiting this blocks the way it always | |
| 141 | + | // did. | |
| 142 | + | let Some(event) = wait(&waiting)? else { | |
| 143 | + | // Nothing was typed before the wait passed, so the questions it was | |
| 144 | + | // holding are asked now. | |
| 145 | + | for request in due(&mut waiting) { | |
| 146 | + | question = perform(Step::Call(request), runtime, router, state); | |
| 147 | + | } | |
| 148 | + | continue; | |
| 149 | + | }; | |
| 150 | + | let Event::Key(pressed) = event else { | |
| 127 | 151 | continue; | |
| 128 | 152 | }; | |
| 129 | 153 | if pressed.kind != KeyEventKind::Press { | |
| @@ -147,37 +171,83 @@ | |||
| 147 | 171 | continue; | |
| 148 | 172 | }; | |
| 149 | 173 | ||
| 150 | - | let mut step = runtime.key(key); | |
| 151 | - | question = None; | |
| 152 | - | loop { | |
| 153 | - | match step { | |
| 154 | - | Step::Idle => break, | |
| 155 | - | Step::Ask(prompt) => { | |
| 156 | - | question = Some(prompt); | |
| 157 | - | break; | |
| 158 | - | } | |
| 159 | - | // Nothing comes back from an external address, and a terminal | |
| 160 | - | // has no browser to hand it to. Saying so beats opening | |
| 161 | - | // something the user did not ask for. | |
| 162 | - | Step::Open(address) => { | |
| 163 | - | runtime.say(format!("this goes outside the app: {address}")); | |
| 164 | - | break; | |
| 165 | - | } | |
| 166 | - | Step::Call(request) => { | |
| 167 | - | match router.handle(state, request.clone()) { | |
| 168 | - | Ok(response) => match runtime.apply(&request, response) { | |
| 169 | - | // A response that says to go somewhere else is a | |
| 170 | - | // second request, performed the same way. | |
| 171 | - | Some(next) => step = Step::Call(next), | |
| 172 | - | None => break, | |
| 173 | - | }, | |
| 174 | - | Err(error) => { | |
| 175 | - | runtime.say(said(&error)); | |
| 176 | - | break; | |
| 177 | - | } | |
| 178 | - | } | |
| 179 | - | } | |
| 174 | + | // The value the last keystroke was waiting to ask about has moved on, | |
| 175 | + | // so whatever it was holding is dropped rather than asked late. | |
| 176 | + | waiting.clear(); | |
| 177 | + | ||
| 178 | + | question = match runtime.key(key) { | |
| 179 | + | Step::CallAfter { asks } => { | |
| 180 | + | let now = Instant::now(); | |
| 181 | + | waiting = asks | |
| 182 | + | .into_iter() | |
| 183 | + | .map(|delayed| (now + delayed.after, delayed.request)) | |
| 184 | + | .collect(); | |
| 185 | + | None | |
| 180 | 186 | } | |
| 187 | + | step => perform(step, runtime, router, state), | |
| 188 | + | }; | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /// The next key, or `None` when the earliest outstanding question came due | |
| 193 | + | /// first. | |
| 194 | + | /// | |
| 195 | + | /// The whole of this host's timer. One box can pose more than one question at | |
| 196 | + | /// more than one rate, and the soonest of them is what the wait is measured | |
| 197 | + | /// against; the rest stay held and are looked at again on the next pass. | |
| 198 | + | fn wait(waiting: &[(Instant, Request)]) -> io::Result<Option<Event>> { | |
| 199 | + | let Some(deadline) = waiting.iter().map(|(at, _)| *at).min() else { | |
| 200 | + | return event::read().map(Some); | |
| 201 | + | }; | |
| 202 | + | ||
| 203 | + | if event::poll(deadline.saturating_duration_since(Instant::now()))? { | |
| 204 | + | event::read().map(Some) | |
| 205 | + | } else { | |
| 206 | + | Ok(None) | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | /// The questions whose wait has passed, taken out of the held set. | |
| 211 | + | fn due(waiting: &mut Vec<(Instant, Request)>) -> Vec<Request> { | |
| 212 | + | let now = Instant::now(); | |
| 213 | + | let (ready, later) = std::mem::take(waiting) | |
| 214 | + | .into_iter() | |
| 215 | + | .partition::<Vec<_>, _>(|(at, _)| *at <= now); | |
| 216 | + | *waiting = later; | |
| 217 | + | ready.into_iter().map(|(_, request)| request).collect() | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | /// Do what a step says, and whatever that leads to. What comes back is the | |
| 221 | + | /// question a control asked before doing itself, to be drawn on the last row. | |
| 222 | + | fn perform( | |
| 223 | + | mut step: Step, | |
| 224 | + | runtime: &mut Runtime, | |
| 225 | + | router: &quasi_router::Router<AppState>, | |
| 226 | + | state: &AppState, | |
| 227 | + | ) -> Option<String> { | |
| 228 | + | loop { | |
| 229 | + | match step { | |
| 230 | + | Step::Idle => return None, | |
| 231 | + | Step::Ask(prompt) => return Some(prompt), | |
| 232 | + | // Nothing comes back from an external address, and a terminal has | |
| 233 | + | // no browser to hand it to. Saying so beats opening something the | |
| 234 | + | // user did not ask for. | |
| 235 | + | Step::Open(address) => { | |
| 236 | + | runtime.say(format!("this goes outside the app: {address}")); | |
| 237 | + | return None; | |
| 238 | + | } | |
| 239 | + | Step::Call(request) => match router.handle(state, request.clone()) { | |
| 240 | + | // A response that says to go somewhere else is a second | |
| 241 | + | // request, performed the same way; anything else is done. | |
| 242 | + | Ok(response) => step = Step::Call(runtime.apply(&request, response)?), | |
| 243 | + | Err(error) => { | |
| 244 | + | runtime.say(said(&error)); | |
| 245 | + | return None; | |
| 246 | + | } | |
| 247 | + | }, | |
| 248 | + | // Only a key produces a wait, and this is reached from the loop | |
| 249 | + | // above with a step that was never one. | |
| 250 | + | Step::CallAfter { .. } => return None, | |
| 181 | 251 | } | |
| 182 | 252 | } | |
| 183 | 253 | } |
| @@ -37,7 +37,10 @@ | |||
| 37 | 37 | /// from. The two bags never merge, so a form field and a filter may share a | |
| 38 | 38 | /// name without either going missing. | |
| 39 | 39 | fn post_viewing(state: &AppState, path: &str, payload: Params, carried: Params) -> Response { | |
| 40 | - | call(state, Request::post(path).sending(payload).carrying(carried)) | |
| 40 | + | call( | |
| 41 | + | state, | |
| 42 | + | Request::post(path).sending(payload).carrying(carried), | |
| 43 | + | ) | |
| 41 | 44 | } | |
| 42 | 45 | ||
| 43 | 46 | /// The screen out of a response, or a failure naming what came instead. | |
| @@ -48,10 +51,15 @@ | |||
| 48 | 51 | } | |
| 49 | 52 | } | |
| 50 | 53 | ||
| 54 | + | /// The nodes of a region's body, without what each is worth when room runs | |
| 55 | + | /// out. A description says both; every assertion here is about the first. | |
| 56 | + | fn nodes(slot: &Slot) -> impl Iterator<Item = &Node> { | |
| 57 | + | slot.body.iter().map(|ranked| &ranked.node) | |
| 58 | + | } | |
| 59 | + | ||
| 51 | 60 | /// Every row's primary text in a slot's list. | |
| 52 | 61 | fn titles(slot: &Slot) -> Vec<String> { | |
| 53 | - | slot.body | |
| 54 | - | .iter() | |
| 62 | + | nodes(slot) | |
| 55 | 63 | .find_map(|node| match node { | |
| 56 | 64 | Node::List { rows, .. } => Some(rows.iter().map(Row::primary).collect()), | |
| 57 | 65 | _ => None, | |
| @@ -77,9 +85,7 @@ | |||
| 77 | 85 | // The detail pane is the form when nothing is selected, and the form's | |
| 78 | 86 | // action is a route rather than a submit handler somewhere else. | |
| 79 | 87 | let detail = screen.slot(DETAIL).expect("a detail pane"); | |
| 80 | - | let form = detail | |
| 81 | - | .body | |
| 82 | - | .iter() | |
| 88 | + | let form = nodes(detail) | |
| 83 | 89 | .find_map(|node| match node { | |
| 84 | 90 | Node::Form { action, fields, .. } => Some((action, fields)), | |
| 85 | 91 | _ => None, | |
| @@ -95,9 +101,7 @@ | |||
| 95 | 101 | let response = get(&state, "/"); | |
| 96 | 102 | let list = screen_of(&response).slot("list").expect("a list pane"); | |
| 97 | 103 | ||
| 98 | - | let stand_in = list | |
| 99 | - | .body | |
| 100 | - | .iter() | |
| 104 | + | let stand_in = nodes(list) | |
| 101 | 105 | .find_map(|node| match node { | |
| 102 | 106 | Node::StandIn { state, act, .. } => Some((state, act.is_some())), | |
| 103 | 107 | _ => None, | |
| @@ -134,8 +138,7 @@ | |||
| 134 | 138 | ||
| 135 | 139 | // A `Node::Select` sends its value under `Node::SELECTED`, not under the | |
| 136 | 140 | // name the screen happens to call the filter. | |
| 137 | - | let picked = viewing(&state, "/", Params::new().with(Node::SELECTED, "1"), | |
| 138 | - | ); | |
| 141 | + | let picked = viewing(&state, "/", Params::new().with(Node::SELECTED, "1")); | |
| 139 | 142 | let list = screen_of(&picked).slot("list").unwrap(); | |
| 140 | 143 | assert_eq!(titles(list), ["Stale"]); | |
| 141 | 144 | } | |
| @@ -152,9 +155,7 @@ | |||
| 152 | 155 | // The row's own address. Losing the filter here is what drops the user | |
| 153 | 156 | // back into the active list by clicking a note. | |
| 154 | 157 | let list = screen.slot("list").unwrap(); | |
| 155 | - | let activate = list | |
| 156 | - | .body | |
| 157 | - | .iter() | |
| 158 | + | let activate = nodes(list) | |
| 158 | 159 | .find_map(|node| match node { | |
| 159 | 160 | Node::List { rows, .. } => rows[0].activate.clone(), | |
| 160 | 161 | _ => None, | |
| @@ -168,9 +169,7 @@ | |||
| 168 | 169 | ||
| 169 | 170 | // And the form's. | |
| 170 | 171 | let detail = screen.slot(DETAIL).unwrap(); | |
| 171 | - | let form_action = detail | |
| 172 | - | .body | |
| 173 | - | .iter() | |
| 172 | + | let form_action = nodes(detail) | |
| 174 | 173 | .find_map(|node| match node { | |
| 175 | 174 | Node::Form { action, .. } => Some(action.clone()), | |
| 176 | 175 | _ => None, | |
| @@ -213,9 +212,7 @@ | |||
| 213 | 212 | let response = post(&state, "/note", Params::new().with("title", "Newer")); | |
| 214 | 213 | ||
| 215 | 214 | let list = screen_of(&response).slot("list").unwrap(); | |
| 216 | - | let current: Vec<_> = list | |
| 217 | - | .body | |
| 218 | - | .iter() | |
| 215 | + | let current: Vec<_> = nodes(list) | |
| 219 | 216 | .find_map(|node| match node { | |
| 220 | 217 | Node::List { rows, .. } => Some( | |
| 221 | 218 | rows.iter() | |
| @@ -241,9 +238,7 @@ | |||
| 241 | 238 | ); | |
| 242 | 239 | ||
| 243 | 240 | let detail = screen_of(&response).slot(DETAIL).unwrap(); | |
| 244 | - | let fields = detail | |
| 245 | - | .body | |
| 246 | - | .iter() | |
| 241 | + | let fields = nodes(detail) | |
| 247 | 242 | .find_map(|node| match node { | |
| 248 | 243 | Node::Form { fields, .. } => Some(fields), | |
| 249 | 244 | _ => None, | |
| @@ -272,7 +267,7 @@ | |||
| 272 | 267 | // And the detail pane is not still offering to archive something that has | |
| 273 | 268 | // gone. This is the stale-pane case a whole-screen answer exists to avoid. | |
| 274 | 269 | let detail = screen.slot(DETAIL).unwrap(); | |
| 275 | - | assert!(detail.body.iter().any(|node| matches!(node, Node::Form { .. }))); | |
| 270 | + | assert!(nodes(detail).any(|node| matches!(node, Node::Form { .. }))); | |
| 276 | 271 | } | |
| 277 | 272 | ||
| 278 | 273 | #[test] | |
| @@ -325,9 +320,7 @@ | |||
| 325 | 320 | panic!("expected a region"); | |
| 326 | 321 | }; | |
| 327 | 322 | ||
| 328 | - | let source = slot | |
| 329 | - | .body | |
| 330 | - | .iter() | |
| 323 | + | let source = nodes(slot) | |
| 331 | 324 | .find_map(|node| match node { | |
| 332 | 325 | Node::Rich { source } => Some(source.clone()), | |
| 333 | 326 | _ => None, | |
| @@ -349,11 +342,7 @@ | |||
| 349 | 342 | let Node::Region(slot) = node else { | |
| 350 | 343 | panic!("expected a region"); | |
| 351 | 344 | }; | |
| 352 | - | assert!( | |
| 353 | - | slot.body | |
| 354 | - | .iter() | |
| 355 | - | .any(|node| matches!(node, Node::StandIn { .. })) | |
| 356 | - | ); | |
| 345 | + | assert!(nodes(slot).any(|node| matches!(node, Node::StandIn { .. }))); | |
| 357 | 346 | } | |
| 358 | 347 | ||
| 359 | 348 | #[test] |