Skip to main content

max / alloy

shell: let a view answer q, and let a confirm be the end of the view Two hooks the settings form needs, both parallel to ones that already exist. `q` now reaches the view the way Esc does. It was the shell's outright, so a view holding unsaved edits had no way to ask before the answer to `q` became "yes, discard them". The default is unchanged and is what every shipped view takes. `confirmed` returns a Flow, because the answer to a confirm can be to leave. "Discard your edits and quit?" is one question, and a view that could only carry on would have to ask it and then wait to be asked again. `alloy pkg` forced a Cancel that views see before the shell claims it, for the same reason and one key over.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 18:38 UTC
Signed with PGP, not checked
Commit: 6dc494524102322f736ea1034fdf3fbfa8b9f3c2
Parent: 337ee46
3 files changed, +47 insertions, -10 deletions
@@ -2028,9 +2028,12 @@
2028 2028 /// next tick, so the run screen is on screen before anything touches the
2029 2029 /// disk. Nothing here blocks, which is the whole point — `bootc install
2030 2030 /// to-disk` takes minutes and the frame has to keep drawing for all of them.
2031 - fn confirmed(&mut self, _log: &mut CommandLog) {
2031 + fn confirmed(&mut self, _log: &mut CommandLog) -> Flow {
2032 2032 self.error = None;
2033 2033 self.running = Some(Sequence::new(self.plan(self.password.value())));
2034 + // Stays on the view: the run screen it just switched to is the whole
2035 + // point of answering yes.
2036 + Flow::Continue
2034 2037 }
2035 2038
2036 2039 /// Drive the running install and advance the activity light.
@@ -1816,15 +1816,17 @@
1816 1816 Flow::Continue
1817 1817 }
1818 1818
1819 - fn confirmed(&mut self, log: &mut CommandLog) {
1819 + fn confirmed(&mut self, log: &mut CommandLog) -> Flow {
1820 1820 let Some(pending) = self.pending.take() else {
1821 - return;
1821 + return Flow::Continue;
1822 1822 };
1823 1823 let result = self.backends[pending.backend]
1824 1824 .remove(&pending.boxed)
1825 1825 .run(log)
1826 1826 .map(drop);
1827 1827 self.finish(result, log);
1828 + // Removing a box leaves the user on the list they removed it from.
1829 + Flow::Continue
1828 1830 }
1829 1831
1830 1832 fn cancelled(&mut self) {
@@ -113,11 +113,28 @@
113 113 false
114 114 }
115 115
116 + /// The user pressed `q` with no text field open.
117 + ///
118 + /// Default is to close the console, which is what `q` has always meant.
119 + /// The hook exists for the same reason [`cancel`](View::cancel) does, one
120 + /// step further out: a view holding unsaved edits has a question to ask
121 + /// before the answer to `q` is "yes, discard them". Before this the shell
122 + /// returned from the loop itself and the view never saw the key.
123 + fn quit(&mut self) -> Flow {
124 + Flow::Exit
125 + }
126 +
116 127 /// The user confirmed the modal this view raised with [`Flow::Confirm`].
117 128 ///
118 - /// Default is nothing, so a view with no destructive actions ignores the
119 - /// whole mechanism.
120 - fn confirmed(&mut self, _log: &mut CommandLog) {}
129 + /// Returns a [`Flow`] because the answer to a confirm can be to leave:
130 + /// "discard your edits and quit?" is one question, and a view that could
131 + /// only ever continue would have to ask it and then wait to be asked again.
132 + ///
133 + /// Default is to carry on, so a view with no destructive actions ignores
134 + /// the whole mechanism.
135 + fn confirmed(&mut self, _log: &mut CommandLog) -> Flow {
136 + Flow::Continue
137 + }
121 138
122 139 /// The user dismissed the modal this view raised.
123 140 ///
@@ -205,7 +222,12 @@
205 222 match modal_key(action) {
206 223 ModalOutcome::Confirmed => {
207 224 modal = None;
208 - view.confirmed(log);
225 + match view.confirmed(log) {
226 + Flow::Exit => return Ok(()),
227 + Flow::Confirm(raised) => modal = Some(raised),
228 + Flow::Suspend(command) => suspend(terminal, view, log, command)?,
229 + Flow::Continue => {}
230 + }
209 231 }
210 232 ModalOutcome::Cancelled => {
211 233 modal = None;
@@ -220,7 +242,7 @@
220 242 // decided first and the flow it produces is handled in one place.
221 243 let flow = match reserved(action, view.text_entry()) {
222 244 Reserved::Cancel => view.cancel(),
223 - Reserved::Quit => Flow::Exit,
245 + Reserved::Quit => view.quit(),
224 246 Reserved::Pass => view.handle(key, log),
225 247 };
226 248
@@ -405,14 +427,24 @@
405 427 fn handle(&mut self, _key: KeyEvent, _log: &mut CommandLog) -> Flow {
406 428 Flow::Continue
407 429 }
408 - fn confirmed(&mut self, _log: &mut CommandLog) {
430 + fn confirmed(&mut self, _log: &mut CommandLog) -> Flow {
409 431 self.confirmed += 1;
432 + Flow::Continue
410 433 }
411 434 fn cancelled(&mut self) {
412 435 self.cancelled += 1;
413 436 }
414 437 }
415 438
439 + // `q` reaches the view for the same reason Esc does. A view with unsaved
440 + // edits answers it with a question; one without keeps the old meaning,
441 + // which is what the default is.
442 + #[test]
443 + fn the_default_answer_to_q_is_still_to_close() {
444 + let mut view = StubView::default();
445 + assert!(matches!(view.quit(), Flow::Exit));
446 + }
447 +
416 448 #[test]
417 449 fn enter_confirms_and_esc_cancels() {
418 450 assert_eq!(modal_key(Action::Activate), ModalOutcome::Confirmed);
@@ -456,7 +488,7 @@
456 488 let mut view = StubView::default();
457 489 let mut log = CommandLog::new();
458 490
459 - view.confirmed(&mut log);
491 + let _ = view.confirmed(&mut log);
460 492 view.cancelled();
461 493
462 494 assert_eq!(view.confirmed, 1);