max / alloy
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3 files changed,
+368 insertions,
-13 deletions
| @@ -13,6 +13,7 @@ | |||
| 13 | 13 | mod pkg; | |
| 14 | 14 | mod shell; | |
| 15 | 15 | mod theme; | |
| 16 | + | mod wizard; | |
| 16 | 17 | ||
| 17 | 18 | use anyhow::Result; | |
| 18 | 19 | use clap::{Parser, Subcommand}; |
| @@ -24,14 +24,12 @@ | |||
| 24 | 24 | /// rather than compare. | |
| 25 | 25 | /// | |
| 26 | 26 | /// `Confirm` and `Suspend` are `alloy pkg`'s: removing a box raises the first, | |
| 27 | - | /// entering one raises the second. `Exit` still has no production consumer — | |
| 28 | - | /// every view so far leaves through the shell's own quit key — and is kept | |
| 29 | - | /// because a view that needs to close itself should not have to add a variant | |
| 30 | - | /// to do it. Covered by tests in this module. | |
| 27 | + | /// entering one raises the second. `Exit` is the shell's own answer to Esc, | |
| 28 | + | /// via [`View::cancel`]'s default, and is what a view returns from that hook | |
| 29 | + | /// when it has nothing left to back out of. | |
| 31 | 30 | #[derive(Debug)] | |
| 32 | 31 | pub enum Flow { | |
| 33 | 32 | Continue, | |
| 34 | - | #[allow(dead_code)] | |
| 35 | 33 | Exit, | |
| 36 | 34 | /// Open a confirmation modal. The view keeps whatever it was about to do | |
| 37 | 35 | /// and performs it in [`View::confirmed`] if the user agrees. | |
| @@ -87,6 +85,34 @@ | |||
| 87 | 85 | /// Handle a key the shell did not claim. | |
| 88 | 86 | fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow; | |
| 89 | 87 | ||
| 88 | + | /// The user pressed Esc outside a modal. | |
| 89 | + | /// | |
| 90 | + | /// Default is to close the view, which is what Esc has always meant in the | |
| 91 | + | /// console and what every list-shaped view still wants. The hook exists for | |
| 92 | + | /// the shape that reads Esc as "back": a wizard steps to the previous | |
| 93 | + | /// question and returns [`Flow::Continue`], and returns [`Flow::Exit`] only | |
| 94 | + | /// once it is on the first step. "Esc backs out until there is nothing left | |
| 95 | + | /// to back out of, then it leaves" then falls out of the default rather | |
| 96 | + | /// than being special-cased in the loop. | |
| 97 | + | fn cancel(&mut self) -> Flow { | |
| 98 | + | Flow::Exit | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// The view has an active text field, so the character keys in the reserved | |
| 102 | + | /// map are letters rather than commands. | |
| 103 | + | /// | |
| 104 | + | /// [`classify`](alloy_tui::keys::classify) documents this as an obligation | |
| 105 | + | /// of whoever calls it: `q`, `/`, `:`, `h`, and `l` are all characters a | |
| 106 | + | /// user types into a field. The shell is that caller for every view, so it | |
| 107 | + | /// honors the obligation here rather than leaving each view to remember it. | |
| 108 | + | /// Only the quit claim is affected. Esc, Ctrl-S, and the focus movers are | |
| 109 | + | /// not characters and keep working mid-word. | |
| 110 | + | /// | |
| 111 | + | /// Default is false, since no shipped view takes text input yet. | |
| 112 | + | fn text_entry(&self) -> bool { | |
| 113 | + | false | |
| 114 | + | } | |
| 115 | + | ||
| 90 | 116 | /// The user confirmed the modal this view raised with [`Flow::Confirm`]. | |
| 91 | 117 | /// | |
| 92 | 118 | /// Default is nothing, so a view with no destructive actions ignores the | |
| @@ -179,18 +205,52 @@ | |||
| 179 | 205 | continue; | |
| 180 | 206 | } | |
| 181 | 207 | ||
| 182 | - | match action { | |
| 183 | - | Action::Quit | Action::Cancel => return Ok(()), | |
| 184 | - | _ => match view.handle(key, log) { | |
| 185 | - | Flow::Exit => return Ok(()), | |
| 186 | - | Flow::Continue => {} | |
| 187 | - | Flow::Confirm(confirm) => modal = Some(confirm), | |
| 188 | - | Flow::Suspend(command) => suspend(terminal, view, log, command)?, | |
| 189 | - | }, | |
| 208 | + | // Both reserved claims are the view's to decline, so the routing is | |
| 209 | + | // decided first and the flow it produces is handled in one place. | |
| 210 | + | let flow = match reserved(action, view.text_entry()) { | |
| 211 | + | Reserved::Cancel => view.cancel(), | |
| 212 | + | Reserved::Quit => Flow::Exit, | |
| 213 | + | Reserved::Pass => view.handle(key, log), | |
| 214 | + | }; | |
| 215 | + | ||
| 216 | + | match flow { | |
| 217 | + | Flow::Exit => return Ok(()), | |
| 218 | + | Flow::Continue => {} | |
| 219 | + | Flow::Confirm(confirm) => modal = Some(confirm), | |
| 220 | + | Flow::Suspend(command) => suspend(terminal, view, log, command)?, | |
| 190 | 221 | } | |
| 191 | 222 | } | |
| 192 | 223 | } | |
| 193 | 224 | ||
| 225 | + | /// What the shell does with a key before the view sees it. | |
| 226 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 227 | + | enum Reserved { | |
| 228 | + | /// Esc: ask the view, whose default is to close. | |
| 229 | + | Cancel, | |
| 230 | + | /// `q` with no text field open: close the view. | |
| 231 | + | Quit, | |
| 232 | + | /// Not the shell's, hand it to the view. | |
| 233 | + | Pass, | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | /// Route a classified action against the shell's two reserved claims. | |
| 237 | + | /// | |
| 238 | + | /// Pure and separate from the loop for the same reason [`modal_key`] is: this | |
| 239 | + | /// is where the consequences live. It decides whether `q` closes the console or | |
| 240 | + | /// lands in the hostname field the user is typing, and a rule that wants | |
| 241 | + | /// testing does not belong somewhere that needs a live terminal to reach. | |
| 242 | + | /// | |
| 243 | + | /// Esc is routed to the view unconditionally rather than gated on | |
| 244 | + | /// `text_entry`. It is not a character, so a text field has no claim on it, and | |
| 245 | + | /// a wizard needs it as "back" on every step including the ones it types on. | |
| 246 | + | const fn reserved(action: Action, text_entry: bool) -> Reserved { | |
| 247 | + | match action { | |
| 248 | + | Action::Cancel => Reserved::Cancel, | |
| 249 | + | Action::Quit if !text_entry => Reserved::Quit, | |
| 250 | + | _ => Reserved::Pass, | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | ||
| 194 | 254 | /// What a key does to an open modal. | |
| 195 | 255 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 196 | 256 | enum ModalOutcome { | |
| @@ -414,6 +474,94 @@ | |||
| 414 | 474 | Bare.cancelled(); | |
| 415 | 475 | } | |
| 416 | 476 | ||
| 477 | + | // The behavior every shipped view relies on, pinned so the wizard hooks | |
| 478 | + | // cannot quietly change what Esc means for the four screens already out. | |
| 479 | + | #[test] | |
| 480 | + | fn esc_closes_a_view_that_does_not_claim_it() { | |
| 481 | + | assert_eq!(reserved(Action::Cancel, false), Reserved::Cancel); | |
| 482 | + | assert!(matches!(StubView::default().cancel(), Flow::Exit)); | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | // A wizard reads Esc as "back" and stays open, which is the whole reason | |
| 486 | + | // the hook is a `Flow` rather than the loop returning directly. | |
| 487 | + | #[test] | |
| 488 | + | fn a_view_can_decline_esc_and_stay_open() { | |
| 489 | + | struct Backs { | |
| 490 | + | depth: usize, | |
| 491 | + | } | |
| 492 | + | impl View for Backs { | |
| 493 | + | fn title(&self) -> String { | |
| 494 | + | "backs".into() | |
| 495 | + | } | |
| 496 | + | fn hints(&self) -> Vec<Hint> { | |
| 497 | + | Vec::new() | |
| 498 | + | } | |
| 499 | + | fn render(&self, _frame: &mut Frame, _area: Rect, _theme: &Theme) {} | |
| 500 | + | fn handle(&mut self, _key: KeyEvent, _log: &mut CommandLog) -> Flow { | |
| 501 | + | Flow::Continue | |
| 502 | + | } | |
| 503 | + | fn cancel(&mut self) -> Flow { | |
| 504 | + | match self.depth.checked_sub(1) { | |
| 505 | + | Some(next) => { | |
| 506 | + | self.depth = next; | |
| 507 | + | Flow::Continue | |
| 508 | + | } | |
| 509 | + | None => Flow::Exit, | |
| 510 | + | } | |
| 511 | + | } | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | let mut view = Backs { depth: 2 }; | |
| 515 | + | assert!(matches!(view.cancel(), Flow::Continue)); | |
| 516 | + | assert!(matches!(view.cancel(), Flow::Continue)); | |
| 517 | + | assert!( | |
| 518 | + | matches!(view.cancel(), Flow::Exit), | |
| 519 | + | "backing out of the first step leaves" | |
| 520 | + | ); | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | // `q` is the console's quit key everywhere except inside a text field, | |
| 524 | + | // where it is a letter. Without this the first user to type a hostname | |
| 525 | + | // containing `q` loses the installer mid-word. | |
| 526 | + | #[test] | |
| 527 | + | fn q_quits_unless_a_text_field_is_open() { | |
| 528 | + | assert_eq!(reserved(Action::Quit, false), Reserved::Quit); | |
| 529 | + | assert_eq!(reserved(Action::Quit, true), Reserved::Pass); | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | // Esc is not a character, so a text field has no claim on it: a wizard | |
| 533 | + | // needs "back" on the steps it types on as much as the ones it does not. | |
| 534 | + | #[test] | |
| 535 | + | fn text_entry_does_not_swallow_esc() { | |
| 536 | + | assert_eq!(reserved(Action::Cancel, true), Reserved::Cancel); | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | #[test] | |
| 540 | + | fn everything_else_reaches_the_view() { | |
| 541 | + | for action in [ | |
| 542 | + | Action::NextFocus, | |
| 543 | + | Action::PrevFocus, | |
| 544 | + | Action::NextTab, | |
| 545 | + | Action::PrevTab, | |
| 546 | + | Action::Activate, | |
| 547 | + | Action::Save, | |
| 548 | + | Action::Filter, | |
| 549 | + | Action::Command, | |
| 550 | + | Action::Help, | |
| 551 | + | Action::Passthrough, | |
| 552 | + | ] { | |
| 553 | + | assert_eq!(reserved(action, false), Reserved::Pass, "{action:?}"); | |
| 554 | + | assert_eq!(reserved(action, true), Reserved::Pass, "{action:?}"); | |
| 555 | + | } | |
| 556 | + | } | |
| 557 | + | ||
| 558 | + | // No shipped view takes text input, so the default must leave all four of | |
| 559 | + | // them on exactly the reserved map they were written against. | |
| 560 | + | #[test] | |
| 561 | + | fn views_default_to_letting_the_shell_claim_q() { | |
| 562 | + | assert!(!StubView::default().text_entry()); | |
| 563 | + | } | |
| 564 | + | ||
| 417 | 565 | #[test] | |
| 418 | 566 | fn a_destructive_confirm_carries_the_error_accent() { | |
| 419 | 567 | let confirm = Confirm::destructive("remove", "Remove tailscale?"); |
| @@ -1,0 +1,206 @@ | |||
| 1 | + | //! A linear step sequence: the navigation model behind `alloy install`. | |
| 2 | + | //! | |
| 3 | + | //! Every console view so far is a surface onto live state, where the user | |
| 4 | + | //! moves between panes and acts on rows. An installer is the other shape: a | |
| 5 | + | //! fixed sequence of questions, answered in order, with the destructive act at | |
| 6 | + | //! the end. [`Steps`] is that shape's cursor. | |
| 7 | + | //! | |
| 8 | + | //! Deliberately parallel to [`Cursor`](alloy_tui::Cursor) and | |
| 9 | + | //! [`FocusRing`](alloy_tui::FocusRing), and deliberately a third type rather | |
| 10 | + | //! than a reuse of either. A focus ring wraps, because Tab past the last pane | |
| 11 | + | //! means the first. A list cursor clamps over rows that appear and disappear | |
| 12 | + | //! on refresh. A step sequence does neither: its length is fixed at | |
| 13 | + | //! construction, it never wraps (wrapping from the last step to the first | |
| 14 | + | //! would turn "one more Enter" into "start over"), and advancing is gated on | |
| 15 | + | //! the current step being answerable, which is a question neither of the other | |
| 16 | + | //! two has to ask. | |
| 17 | + | //! | |
| 18 | + | //! Lives in the console binary rather than in `alloy_tui`. The design-system | |
| 19 | + | //! crate is published separately, so promoting a type into it is a release; | |
| 20 | + | //! this one waits until a second consumer wants it, which would be | |
| 21 | + | //! `alloy config`'s multi-page forms if those land. | |
| 22 | + | ||
| 23 | + | // The navigation model landed before the view that drives it, so that the | |
| 24 | + | // question of what Esc and Enter mean in a wizard could be settled and tested | |
| 25 | + | // on its own. Comes off with `install.rs`, which constructs the first one. | |
| 26 | + | #![allow(dead_code)] | |
| 27 | + | ||
| 28 | + | /// A position in a fixed sequence of steps. | |
| 29 | + | /// | |
| 30 | + | /// Knows nothing about what a step *is*. Whether the current step is answered | |
| 31 | + | /// is the view's question, so [`advance`](Self::advance) is called only once | |
| 32 | + | /// the view has decided the answer holds. Keeping validation out here is what | |
| 33 | + | /// lets the whole navigation model be tested without a terminal, a form, or a | |
| 34 | + | /// disk. | |
| 35 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 36 | + | pub struct Steps { | |
| 37 | + | index: usize, | |
| 38 | + | len: usize, | |
| 39 | + | /// The furthest step reached, which is not always the current one. | |
| 40 | + | /// | |
| 41 | + | /// The summary step is the case this exists for. A user reads the summary, | |
| 42 | + | /// steps back to fix a hostname, and advances again; without this, the | |
| 43 | + | /// step indicator would show the steps ahead of them flipping from done to | |
| 44 | + | /// pending and back on the way through. What a user has answered stays | |
| 45 | + | /// answered when they step behind it. | |
| 46 | + | furthest: usize, | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | impl Steps { | |
| 50 | + | /// A sequence of `len` steps, positioned on the first. | |
| 51 | + | /// | |
| 52 | + | /// A zero-step sequence is inert rather than a panic: every mover no-ops | |
| 53 | + | /// and both ends report true. A wizard with no steps is a programming | |
| 54 | + | /// error, but it is not one worth taking the process down for, and | |
| 55 | + | /// [`Cursor`](alloy_tui::Cursor) sets the precedent of staying total over | |
| 56 | + | /// an empty range. | |
| 57 | + | pub const fn new(len: usize) -> Self { | |
| 58 | + | Self { | |
| 59 | + | index: 0, | |
| 60 | + | len, | |
| 61 | + | furthest: 0, | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | pub const fn current(&self) -> usize { | |
| 66 | + | self.index | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | pub const fn len(&self) -> usize { | |
| 70 | + | self.len | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// The furthest step the user has reached, current or behind them. | |
| 74 | + | /// | |
| 75 | + | /// For the step indicator: steps at or below this are answered, the rest | |
| 76 | + | /// are not yet visited. | |
| 77 | + | pub const fn furthest(&self) -> usize { | |
| 78 | + | self.furthest | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// On the first step, so Esc leaves the installer rather than stepping | |
| 82 | + | /// back. Also true of an empty sequence, which is nowhere. | |
| 83 | + | pub const fn is_first(&self) -> bool { | |
| 84 | + | self.index == 0 | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// On the last step, so Enter runs the install rather than advancing. | |
| 88 | + | /// | |
| 89 | + | /// The `len == 0` guard matters: without it an empty sequence reports | |
| 90 | + | /// "not on the last step" while having no steps to advance through, and a | |
| 91 | + | /// view driving off that would offer a next that never arrives. | |
| 92 | + | pub const fn is_last(&self) -> bool { | |
| 93 | + | self.len == 0 || self.index + 1 == self.len | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// Move to the next step, reporting whether there was one. | |
| 97 | + | /// | |
| 98 | + | /// Returning `bool` rather than nothing is what lets a view spell the last | |
| 99 | + | /// step's Enter as "advance, and if it did not move, run the install" | |
| 100 | + | /// without asking [`is_last`](Self::is_last) separately and racing its own | |
| 101 | + | /// state. | |
| 102 | + | pub const fn advance(&mut self) -> bool { | |
| 103 | + | if self.is_last() { | |
| 104 | + | return false; | |
| 105 | + | } | |
| 106 | + | self.index += 1; | |
| 107 | + | if self.index > self.furthest { | |
| 108 | + | self.furthest = self.index; | |
| 109 | + | } | |
| 110 | + | true | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// Move to the previous step, reporting whether there was one. | |
| 114 | + | /// | |
| 115 | + | /// `false` on the first step is the shell's cue to close the view: Esc | |
| 116 | + | /// backs out until there is nothing left to back out of, then it leaves. | |
| 117 | + | pub const fn back(&mut self) -> bool { | |
| 118 | + | if self.is_first() { | |
| 119 | + | return false; | |
| 120 | + | } | |
| 121 | + | self.index -= 1; | |
| 122 | + | true | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | #[cfg(test)] | |
| 127 | + | mod tests { | |
| 128 | + | use super::*; | |
| 129 | + | ||
| 130 | + | #[test] | |
| 131 | + | fn starts_on_the_first_step() { | |
| 132 | + | let steps = Steps::new(4); | |
| 133 | + | assert_eq!(steps.current(), 0); | |
| 134 | + | assert!(steps.is_first()); | |
| 135 | + | assert!(!steps.is_last()); | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // The property that separates this from a focus ring. Wrapping from the | |
| 139 | + | // last step to the first would make one Enter too many restart the | |
| 140 | + | // installer instead of running it. | |
| 141 | + | #[test] | |
| 142 | + | fn neither_end_wraps() { | |
| 143 | + | let mut steps = Steps::new(3); | |
| 144 | + | assert!(!steps.back(), "no step before the first"); | |
| 145 | + | assert_eq!(steps.current(), 0); | |
| 146 | + | ||
| 147 | + | while steps.advance() {} | |
| 148 | + | assert_eq!(steps.current(), 2); | |
| 149 | + | assert!(!steps.advance(), "no step after the last"); | |
| 150 | + | assert_eq!(steps.current(), 2); | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // Esc backs out until there is nothing left to back out of; the `false` is | |
| 154 | + | // what the shell turns into "close the view". | |
| 155 | + | #[test] | |
| 156 | + | fn back_from_the_first_step_reports_nothing_to_go_back_to() { | |
| 157 | + | let mut steps = Steps::new(2); | |
| 158 | + | steps.advance(); | |
| 159 | + | assert!(steps.back()); | |
| 160 | + | assert!(steps.is_first()); | |
| 161 | + | assert!(!steps.back()); | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // The case `furthest` exists for: reading the summary, stepping back to | |
| 165 | + | // fix an answer, and finding the steps ahead still marked answered. | |
| 166 | + | #[test] | |
| 167 | + | fn stepping_back_leaves_the_furthest_step_reached_alone() { | |
| 168 | + | let mut steps = Steps::new(4); | |
| 169 | + | steps.advance(); | |
| 170 | + | steps.advance(); | |
| 171 | + | steps.advance(); | |
| 172 | + | assert_eq!(steps.furthest(), 3); | |
| 173 | + | ||
| 174 | + | steps.back(); | |
| 175 | + | steps.back(); | |
| 176 | + | assert_eq!(steps.current(), 1); | |
| 177 | + | assert_eq!(steps.furthest(), 3, "answered steps stay answered"); | |
| 178 | + | ||
| 179 | + | steps.advance(); | |
| 180 | + | assert_eq!(steps.furthest(), 3, "re-advancing does not double-count"); | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // A single-step wizard is on the last step from the start: its Enter runs | |
| 184 | + | // the install rather than looking for a step that is not there. | |
| 185 | + | #[test] | |
| 186 | + | fn one_step_is_both_ends_at_once() { | |
| 187 | + | let mut steps = Steps::new(1); | |
| 188 | + | assert!(steps.is_first()); | |
| 189 | + | assert!(steps.is_last()); | |
| 190 | + | assert!(!steps.advance()); | |
| 191 | + | assert!(!steps.back()); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // A programming error, but not one worth a panic. Every mover no-ops and | |
| 195 | + | // `is_last` is true, so a view driving off it offers no next step rather | |
| 196 | + | // than one that never arrives. | |
| 197 | + | #[test] | |
| 198 | + | fn an_empty_sequence_is_inert() { | |
| 199 | + | let mut steps = Steps::new(0); | |
| 200 | + | assert!(steps.is_first()); | |
| 201 | + | assert!(steps.is_last()); | |
| 202 | + | assert!(!steps.advance()); | |
| 203 | + | assert!(!steps.back()); | |
| 204 | + | assert_eq!(steps.current(), 0); | |
| 205 | + | } | |
| 206 | + | } |