Skip to main content

max / quasi

the scaffolder's template resolves and builds outside the tree 813d047a. A generated app is the one consumer that never sees the tree's [patch] block, so its manifests resolve against the real URLs and the real index. They had not been read that way in a while. quasi-tauri and quasi-axum carried bare git URLs, and docengine did too. Each now declares a version, which turns a mismatched sibling into a resolution error rather than a compile error further in. internal-deps.py reads Cargo.toml and never opens a Cargo.toml.tmpl, so the template is the one place in the tree with no gate over it; a test in the scaffolder is that gate now. The crates.io pins were stale enough to be unresolvable, not merely old. makeover-build 0.28.0 wants makeover-webview 0.51 while quasi-webview 0.44 wants 0.52, and that crate carries links, so the graph had no solution at all. makeover-build 0.47.0, makeover 3.0 and makeover-tui 0.30.0 are what is published. Two things fell out of building it. The tui binary did not handle Step::CallAfter, so a generated workspace did not compile: it now holds the questions a keystroke posed with the moment each comes due, blocks on a key or on the soonest deadline, and drops the set on the next key. The worked example's tests matched Node against a Slot body that is Vec<Ranked> now, and read the node out of each member instead. Verified by generating outside ~/Code and building against a mirror of this commit: cargo build, cargo test (30 passed) and cargo clippy all clean.
Author: Max Johnson <me@maxj.phd> · 2026-08-20 17:33 UTC
Signed with PGP, not checked
Commit: ca1ca27828c52eddbf11ad754ee8b0ea94c255fa
Parent: aeb67b6
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]