Skip to main content

max / quasi

Let each renderer decide focus, and delete the machinery that argued quasi-tui honoured the description's focus claim once at startup and overrode it forever after, which is what Runtime::start_focus and Spot::wants_focus existed to do. Both go. The runtime starts on the first reach unconditionally, which is what it did in practice anyway. quasi-webview drops its autofocus arm for the same reason: the browser owns reach and focus, and a description states neither. focus.rs's header was already the best statement of reach in the tree and now says focus is this renderer's too. Takes makeover-layout 0.19.0.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-13 13:54 UTC
Signed with PGP, not checked
Commit: db4f0f979e318c8f10d86f1a7a400dc8afbc88d2
Parent: 7367f73
9 files changed, +53 insertions, -71 deletions
M Cargo.lock +17 -17
@@ -2059,9 +2059,7 @@
2059 2059
2060 2060 [[package]]
2061 2061 name = "makeover"
2062 - version = "2.5.0"
2063 - source = "registry+https://github.com/rust-lang/crates.io-index"
2064 - checksum = "3ba9cbc9da41bffe0514e6d6744cecf8097eb18676edcaf62a7dd08cea42e4aa"
2062 + version = "2.5.1"
2065 2063 dependencies = [
2066 2064 "include_dir",
2067 2065 "serde",
@@ -2071,20 +2069,14 @@
2071 2069 [[package]]
2072 2070 name = "makeover-geometry"
2073 2071 version = "0.7.0"
2074 - source = "registry+https://github.com/rust-lang/crates.io-index"
2075 - checksum = "91a82b16c4f5b66dd7e87ed92fff7d22185e4d88e5355de6166dd64450cf6a16"
2076 2072
2077 2073 [[package]]
2078 2074 name = "makeover-layout"
2079 - version = "0.18.0"
2080 - source = "registry+https://github.com/rust-lang/crates.io-index"
2081 - checksum = "48e5725b8dc2d30d5e46b324dd97757e14e61345607c9f55534a34768a6b54dd"
2075 + version = "0.19.0"
2082 2076
2083 2077 [[package]]
2084 2078 name = "makeover-touch"
2085 - version = "0.11.0"
2086 - source = "registry+https://github.com/rust-lang/crates.io-index"
2087 - checksum = "aa8a698cf7ba31dc0a2256293139774657ae373425ccd58eabb05431dd283876"
2079 + version = "0.12.0"
2088 2080 dependencies = [
2089 2081 "makeover-geometry",
2090 2082 "makeover-layout",
@@ -2092,9 +2084,7 @@
2092 2084
2093 2085 [[package]]
2094 2086 name = "makeover-tui"
2095 - version = "0.17.0"
2096 - source = "registry+https://github.com/rust-lang/crates.io-index"
2097 - checksum = "6bec0a014d4da44d95dca4db82b5f9418edb4dd10fa5009e13832489939fcabd"
2087 + version = "0.18.0"
2098 2088 dependencies = [
2099 2089 "makeover",
2100 2090 "makeover-layout",
@@ -2103,9 +2093,7 @@
2103 2093
2104 2094 [[package]]
2105 2095 name = "makeover-webview"
2106 - version = "0.34.0"
2107 - source = "registry+https://github.com/rust-lang/crates.io-index"
2108 - checksum = "55628364f54e172d255701c3044be78f8a8eff3a2362d9b2960ad8cd54ccf9b7"
2096 + version = "0.35.0"
2109 2097 dependencies = [
2110 2098 "makeover-geometry",
2111 2099 "makeover-layout",
@@ -5175,6 +5163,18 @@
5175 5163 name = "tagtree"
5176 5164 version = "0.4.0"
5177 5165
5166 + [[patch.unused]]
5167 + name = "alloy_tui"
5168 + version = "7.0.0"
5169 +
5170 + [[patch.unused]]
5171 + name = "makeover-build"
5172 + version = "0.27.0"
5173 +
5174 + [[patch.unused]]
5175 + name = "makeover-immediate"
5176 + version = "0.17.0"
5177 +
5178 5178 [[patch.unused]]
5179 5179 name = "synckit-client"
5180 5180 version = "0.8.0"
@@ -13,4 +13,4 @@
13 13 workspace = true
14 14
15 15 [dependencies]
16 - makeover-layout = "0.18.0"
16 + makeover-layout = "0.19.0"
@@ -14,13 +14,13 @@
14 14
15 15 [dependencies]
16 16 quasi-router = { path = "../quasi-router", version = "0.1.0" }
17 - makeover-layout = "0.18.0"
17 + makeover-layout = "0.19.0"
18 18 # The depth palette, the theme bridge and the table. Everything else this crate
19 19 # draws is written here first and lifted upstream once a second consumer wants
20 20 # it, which is the order the suite has always moved in: the constrained consumer
21 21 # finds the shape, and the shared crate takes it after it is known rather than
22 22 # before.
23 - makeover-tui = { version = "0.17.0", features = ["theme"] }
23 + makeover-tui = { version = "0.18.0", features = ["theme"] }
24 24 ratatui = { version = "0.30", default-features = false }
25 25 # `Node::Rich` carries markdown source. A terminal has no markup to hand it to,
26 26 # so it takes the runs: `render_runs` is the words with the marks that were over
@@ -15,8 +15,8 @@
15 15 [dependencies]
16 16 quasi-router = { path = "../quasi-router", version = "0.1.0" }
17 17 quasi-http = { path = "../quasi-http", version = "0.1.0" }
18 - makeover-layout = "0.18.0"
19 - makeover-webview = "0.34.0"
18 + makeover-layout = "0.19.0"
19 + makeover-webview = "0.35.0"
20 20 # `Node::Rich` carries markdown source and this is what turns it into markup.
21 21 # Sanitising comes with it, which is why the node can carry what a user typed.
22 22 #
@@ -26,6 +26,21 @@
26 26 //! that takes typing. A [`Node::Meter`] and a [`Node::Figure`] are readouts and
27 27 //! are skipped, and a disabled [`Act`] is drawn and passed over, which is what
28 28 //! `disabled` means on every host.
29 + //!
30 + //! # Reach is this module; focus is the view's
31 + //!
32 + //! Both are this renderer's, and neither is describable. **Reach** is what this
33 + //! module computes: which things can take focus, and in what order. **Focus** is
34 + //! which reached thing holds the keyboard right now, and it lives in
35 + //! [`crate::View`] because it is a fact about where the user has walked rather
36 + //! than about the screen. The **focus ring** is what the drawing paints on it.
37 + //!
38 + //! A description used to be able to claim focus for a control, and
39 + //! `makeover-layout` removed the member in 0.19.0 on the grounds that focus is
40 + //! fundamentally different per host. The runtime now starts on the first reach
41 + //! unconditionally, which is what it did in practice anyway once the user
42 + //! pressed anything. The three terms are defined once in `makeover_layout`'s
43 + //! crate header, "Reach, focus and the focus ring".
29 44
30 45 use makeover_layout as layout;
31 46 use quasi_router::{Act, Action, Field, Node, Part, Row, Screen, Slot};
@@ -44,14 +59,6 @@
44 59 /// The one place the description already anticipated a terminal, and
45 60 /// the runtime is what finally binds it.
46 61 key: Option<String>,
47 - /// Whether the description says this is the control to start on.
48 - ///
49 - /// [`layout::State::Focused`] is a fact two parties now claim: the
50 - /// screen says which control matters, and the view holds where the user
51 - /// has walked to since. The rule is that the description decides where
52 - /// focus *starts* and never moves it afterwards, because a redraw that
53 - /// pulled the caret back would take the keyboard off the user.
54 - wants_focus: bool,
55 62 /// The screen's selection this acts on, if it acts on one.
56 63 ///
57 64 /// The commit half of a staged tick. The runtime reads the set the view
@@ -237,7 +244,6 @@
237 244 action,
238 245 confirm: None,
239 246 key: None,
240 - wants_focus: false,
241 247 over: None,
242 248 });
243 249 }
@@ -338,7 +344,6 @@
338 344 action: act.action.clone(),
339 345 confirm: act.confirm.clone(),
340 346 key: act.key.clone(),
341 - wants_focus: matches!(act.state, Some(layout::State::Focus)),
342 347 over: act.over.clone(),
343 348 },
344 349 });
@@ -125,7 +125,6 @@
125 125 saying: None,
126 126 };
127 127 runtime.view.seed(&runtime.screen);
128 - runtime.start_focus();
129 128 runtime
130 129 }
131 130
@@ -377,7 +376,6 @@
377 376 // user's ticks are the truth, which is why it is applied once
378 377 // on arrival rather than read on every draw.
379 378 self.view.seed(&self.screen);
380 - self.start_focus();
381 379 self.announce();
382 380 None
383 381 }
@@ -475,23 +473,6 @@
475 473 }
476 474 }
477 475
478 - /// Start on what the description says matters, when it says anything.
479 - fn start_focus(&mut self) {
480 - let reaches = crate::focus::reaches(&self.screen);
481 - let at = reaches.iter().position(|reach| {
482 - matches!(
483 - reach.spot,
484 - Spot::Act {
485 - wants_focus: true,
486 - ..
487 - }
488 - )
489 - });
490 - if let Some(at) = at {
491 - self.view.focus_on(at, reaches.len());
492 - }
493 - }
494 -
495 476 /// Type into a field, honouring what the description says it will take.
496 477 fn type_into(&mut self, field: &crate::FieldSpot, ch: char) {
497 478 if matches!(field.kind, layout::FieldKind::Checkbox) {
@@ -530,17 +530,15 @@
530 530 }
531 531
532 532 #[test]
533 - fn the_description_says_which_control_to_start_on() {
534 - // `layout::State::Focus` is a fact the screen can state, and this is the one
535 - // place it is honoured: where focus starts, never where it goes back to.
533 + fn the_runtime_starts_on_the_first_reach_and_the_description_gets_no_say() {
534 + // The guarantee that replaced `layout::State::Focus`. A description used to
535 + // be able to claim the starting control; focus is this renderer's now, and
536 + // the rule is the plain one: first thing you can reach.
536 537 let mut runtime = Runtime::new(screen_of([
537 538 Node::Act(Act::new("First", Action::post("/first"))),
538 - Node::Act(Act {
539 - state: Some(layout::State::Focus),
540 - ..Act::new("Wanted", Action::post("/wanted"))
541 - }),
539 + Node::Act(Act::new("Second", Action::post("/second"))),
542 540 ]));
543 - assert_eq!(calling(&runtime.key(Key::Enter)), Some("/wanted"));
541 + assert_eq!(calling(&runtime.key(Key::Enter)), Some("/first"));
544 542 }
545 543
546 544 #[test]
@@ -57,7 +57,12 @@
57 57 /// draws the description's value and the other draws a box the user has
58 58 /// cleared.
59 59 edits: BTreeMap<String, String>,
60 - /// Which reachable thing has focus, as an index into [`crate::focus::spots`].
60 + /// Which reached thing has focus, as an index into [`crate::focus::spots`].
61 + ///
62 + /// Focus is this renderer's and lives here rather than in a description,
63 + /// which is why it survives a redraw: reach is recomputed from the screen,
64 + /// focus is a fact about where the user has walked. See
65 + /// [`crate::focus`]'s header for the three terms.
61 66 focus: usize,
62 67 /// How far each region has been scrolled, in rows, by
63 68 /// [`Slot::id`](quasi_router::Slot::id).
@@ -453,17 +453,10 @@
453 453 out.push_str(" disabled");
454 454 }
455 455 }
456 - Some(layout::State::Focus) => {
457 - out.push_str(" autofocus");
458 - action_attrs(
459 - &act.action,
460 - Fires::Click,
461 - act.confirm.as_deref(),
462 - morphs,
463 - gathers,
464 - out,
465 - );
466 - }
456 + // No `autofocus` arm. A description does not state focus: the browser
457 + // owns reach and focus here, which is what `makeover-layout` 0.19.0
458 + // settled by removing the member this used to read.
459 + //
467 460 // `State` is `#[non_exhaustive]`, so a member added upstream lands
468 461 // here. Emitting the transport is the right default for anything that
469 462 // is not a suppression: a state this renderer has not learned yet