max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+206 insertions,
-7 deletions
| @@ -2948,6 +2948,19 @@ | |||
| 2948 | 2948 | /// Zero until the page has been drawn once, which is why every reader | |
| 2949 | 2949 | /// treats zero as "one screen" rather than as a real measurement. | |
| 2950 | 2950 | credits_viewport: std::cell::Cell<usize>, | |
| 2951 | + | /// How far back from the newest line the run screen is scrolled. | |
| 2952 | + | /// | |
| 2953 | + | /// Counted from the bottom rather than the top, because the tail is where | |
| 2954 | + | /// this screen lives: zero is pinned to the newest output, which is the | |
| 2955 | + | /// behaviour it has always had and the right default while a command is | |
| 2956 | + | /// still producing lines. The cost is that scrolling back during a live run | |
| 2957 | + | /// keeps your distance from the end rather than your place in the text, so | |
| 2958 | + | /// the lines drift while new ones arrive. Accepted: the case this exists | |
| 2959 | + | /// for is reading a failure, and a failed run produces nothing further. | |
| 2960 | + | run_scroll: usize, | |
| 2961 | + | /// How many lines of run output last fitted on screen, for the same reason | |
| 2962 | + | /// [`credits_viewport`](Self::credits_viewport) is recorded. | |
| 2963 | + | run_viewport: std::cell::Cell<usize>, | |
| 2951 | 2964 | answers: Answers, | |
| 2952 | 2965 | error: Option<String>, | |
| 2953 | 2966 | /// The install, once it has been confirmed and started. | |
| @@ -2993,6 +3006,8 @@ | |||
| 2993 | 3006 | disk_intact: Arc::new(AtomicBool::new(false)), | |
| 2994 | 3007 | encrypt: true, | |
| 2995 | 3008 | credits_scroll: 0, | |
| 3009 | + | run_scroll: 0, | |
| 3010 | + | run_viewport: std::cell::Cell::new(0), | |
| 2996 | 3011 | credits_viewport: std::cell::Cell::new(0), | |
| 2997 | 3012 | answers: Answers::default(), | |
| 2998 | 3013 | error: None, | |
| @@ -3703,6 +3718,37 @@ | |||
| 3703 | 3718 | ); | |
| 3704 | 3719 | } | |
| 3705 | 3720 | ||
| 3721 | + | /// Keys for the run screen: scrolling back through the output, and nothing | |
| 3722 | + | /// else. | |
| 3723 | + | /// | |
| 3724 | + | /// The offset counts lines back from the newest, so `j` walks toward the | |
| 3725 | + | /// end and `k` away from it, and zero is the tail. Clamped against the | |
| 3726 | + | /// output that exists rather than against a remembered maximum: the buffer | |
| 3727 | + | /// grows under this while a command runs. | |
| 3728 | + | fn scroll_run(&mut self, key: KeyEvent) -> Flow { | |
| 3729 | + | let Some(sequence) = self.running.as_ref() else { | |
| 3730 | + | return Flow::Continue; | |
| 3731 | + | }; | |
| 3732 | + | let viewport = self.run_viewport.get().max(1); | |
| 3733 | + | let max = sequence.output().len().saturating_sub(viewport); | |
| 3734 | + | let page = viewport.max(2) - 1; | |
| 3735 | + | ||
| 3736 | + | self.run_scroll = match key.code { | |
| 3737 | + | KeyCode::Char('j') | KeyCode::Down => self.run_scroll.saturating_sub(1), | |
| 3738 | + | KeyCode::Char('k') | KeyCode::Up => self.run_scroll.saturating_add(1), | |
| 3739 | + | KeyCode::PageDown => self.run_scroll.saturating_sub(page), | |
| 3740 | + | KeyCode::PageUp => self.run_scroll.saturating_add(page), | |
| 3741 | + | // Home is the oldest line and End the newest, which is the way | |
| 3742 | + | // round a pager does it. The tail being zero here is an | |
| 3743 | + | // implementation detail and should not leak into the keys. | |
| 3744 | + | KeyCode::Home => max, | |
| 3745 | + | KeyCode::End => 0, | |
| 3746 | + | _ => self.run_scroll, | |
| 3747 | + | } | |
| 3748 | + | .min(max); | |
| 3749 | + | Flow::Continue | |
| 3750 | + | } | |
| 3751 | + | ||
| 3706 | 3752 | /// Keys for the credits page: scrolling, and nothing else. | |
| 3707 | 3753 | /// | |
| 3708 | 3754 | /// Enter is not here. It starts the install, which is [`handle`](Self::handle)'s | |
| @@ -3908,11 +3954,19 @@ | |||
| 3908 | 3954 | ||
| 3909 | 3955 | // The tail rather than the head. A long-running command's interesting | |
| 3910 | 3956 | // output is always its most recent, and a failure's explanation is its | |
| 3911 | - | // last line. | |
| 3957 | + | // last line. [`scroll_run`](Self::scroll_run) moves the window back | |
| 3958 | + | // from there; at rest it sits on the newest line. | |
| 3912 | 3959 | let body = area.height.saturating_sub(lines.len() as u16) as usize; | |
| 3960 | + | self.run_viewport.set(body); | |
| 3913 | 3961 | let output = sequence.output(); | |
| 3914 | - | let from = output.len().saturating_sub(body); | |
| 3915 | - | for line in &output[from..] { | |
| 3962 | + | // Clamped here as well as in the key path, because the buffer can grow | |
| 3963 | + | // between a keypress and this frame and a stale offset would slice past | |
| 3964 | + | // the end. | |
| 3965 | + | let end = output | |
| 3966 | + | .len() | |
| 3967 | + | .saturating_sub(self.run_scroll.min(output.len())); | |
| 3968 | + | let from = end.saturating_sub(body); | |
| 3969 | + | for line in &output[from..end] { | |
| 3916 | 3970 | lines.push(Line::from(text::secondary(theme, line.clone()))); | |
| 3917 | 3971 | } | |
| 3918 | 3972 | ||
| @@ -4137,6 +4191,20 @@ | |||
| 4137 | 4191 | return vec![hint("enter", "confirm")]; | |
| 4138 | 4192 | } | |
| 4139 | 4193 | ||
| 4194 | + | // A run on screen owns the footer, whatever step raised it. Otherwise | |
| 4195 | + | // the credits step's hints stay up through the install and past the end | |
| 4196 | + | // of it, promising an `enter install` that is refused and an `esc back` | |
| 4197 | + | // that goes nowhere, next to the `j/k scroll` that is the one thing | |
| 4198 | + | // here that does work. | |
| 4199 | + | if let Some(sequence) = self.running.as_ref() { | |
| 4200 | + | let mut hints = vec![hint("j/k", "scroll")]; | |
| 4201 | + | // Esc leaves, but only once there is nothing left to interrupt. | |
| 4202 | + | if sequence.is_done() { | |
| 4203 | + | hints.push(hint("esc", "done")); | |
| 4204 | + | } | |
| 4205 | + | return hints; | |
| 4206 | + | } | |
| 4207 | + | ||
| 4140 | 4208 | // Esc is listed from the second step on, where it means "back". On the | |
| 4141 | 4209 | // first it closes the installer, which is the shell's own `q`. | |
| 4142 | 4210 | let mut hints = match self.step() { | |
| @@ -4235,11 +4303,15 @@ | |||
| 4235 | 4303 | return self.edit_recovery(key); | |
| 4236 | 4304 | } | |
| 4237 | 4305 | ||
| 4238 | - | // A running install answers no questions. The only key that means | |
| 4239 | - | // anything is the one that leaves once it has finished, and the shell's | |
| 4240 | - | // own `q` already does that. | |
| 4306 | + | // A running install answers no questions, but it does have a screenful | |
| 4307 | + | // of output that is longer than the screen, and reading it is the whole | |
| 4308 | + | // job when one has just failed. So the movement keys work and nothing | |
| 4309 | + | // else does. They were advertised in the footer long before they did | |
| 4310 | + | // anything, which is how this was found: an install failed on fw12, | |
| 4311 | + | // bootc's error had scrolled off, and the keys the screen named were | |
| 4312 | + | // inert. | |
| 4241 | 4313 | if self.running.is_some() { | |
| 4242 | - | return Flow::Continue; | |
| 4314 | + | return self.scroll_run(key); | |
| 4243 | 4315 | } | |
| 4244 | 4316 | ||
| 4245 | 4317 | match self.step() { | |
| @@ -4634,6 +4706,8 @@ | |||
| 4634 | 4706 | recovery_ack: false, | |
| 4635 | 4707 | encrypt: true, | |
| 4636 | 4708 | credits_scroll: 0, | |
| 4709 | + | run_scroll: 0, | |
| 4710 | + | run_viewport: std::cell::Cell::new(0), | |
| 4637 | 4711 | credits_viewport: std::cell::Cell::new(0), | |
| 4638 | 4712 | answers: Answers::default(), | |
| 4639 | 4713 | error: None, | |
| @@ -5315,6 +5389,131 @@ | |||
| 5315 | 5389 | assert_eq!(view.step(), Step::Encryption); | |
| 5316 | 5390 | } | |
| 5317 | 5391 | ||
| 5392 | + | // ---- scrolling the run screen ---- | |
| 5393 | + | ||
| 5394 | + | /// A view sitting on a run whose output is longer than the screen. | |
| 5395 | + | /// | |
| 5396 | + | /// `seq` prints its lines and exits, so the sequence reaches a real | |
| 5397 | + | /// finished state with real output rather than one poked into place. | |
| 5398 | + | fn at_run_output(lines: usize, viewport: usize) -> (InstallView, CommandLog) { | |
| 5399 | + | let (mut view, mut log) = at_summary(); | |
| 5400 | + | view.running = Some(Sequence::new(vec![Stage::Run( | |
| 5401 | + | Invocation::new("seq").arg(lines.to_string()), | |
| 5402 | + | )])); | |
| 5403 | + | // Ticked until the child has exited and every line has been drained, | |
| 5404 | + | // rather than a fixed count: how many polls that takes is the | |
| 5405 | + | // scheduler's business and a fixed number is how this goes flaky. | |
| 5406 | + | for _ in 0..2000 { | |
| 5407 | + | if view.running.as_ref().is_some_and(Sequence::is_done) { | |
| 5408 | + | break; | |
| 5409 | + | } | |
| 5410 | + | view.tick(&mut log); | |
| 5411 | + | std::thread::sleep(std::time::Duration::from_millis(1)); | |
| 5412 | + | } | |
| 5413 | + | assert!( | |
| 5414 | + | view.running.as_ref().is_some_and(Sequence::is_done), | |
| 5415 | + | "the fixture did not finish" | |
| 5416 | + | ); | |
| 5417 | + | assert!( | |
| 5418 | + | view.running | |
| 5419 | + | .as_ref() | |
| 5420 | + | .is_some_and(|s| s.output().len() >= lines), | |
| 5421 | + | "the fixture produced no output" | |
| 5422 | + | ); | |
| 5423 | + | view.run_viewport.set(viewport); | |
| 5424 | + | (view, log) | |
| 5425 | + | } | |
| 5426 | + | ||
| 5427 | + | fn press(view: &mut InstallView, code: KeyCode) { | |
| 5428 | + | let mut log = CommandLog::new(); | |
| 5429 | + | view.handle(KeyEvent::from(code), &mut log); | |
| 5430 | + | } | |
| 5431 | + | ||
| 5432 | + | // The keys the footer has always named. They did nothing at all until | |
| 5433 | + | // 2026-08-09: `handle` returned early on any run, so an install that failed | |
| 5434 | + | // with its explanation scrolled off the top could not be read at the | |
| 5435 | + | // machine it failed on. | |
| 5436 | + | #[test] | |
| 5437 | + | fn the_run_screen_scrolls_back_through_its_output() { | |
| 5438 | + | let (mut view, _log) = at_run_output(100, 10); | |
| 5439 | + | ||
| 5440 | + | assert_eq!(view.run_scroll, 0, "a fresh run is not pinned to the tail"); | |
| 5441 | + | ||
| 5442 | + | press(&mut view, KeyCode::Char('k')); | |
| 5443 | + | assert_eq!( | |
| 5444 | + | view.run_scroll, 1, | |
| 5445 | + | "k did not move away from the newest line" | |
| 5446 | + | ); | |
| 5447 | + | ||
| 5448 | + | press(&mut view, KeyCode::Char('j')); | |
| 5449 | + | assert_eq!(view.run_scroll, 0, "j did not walk back toward the newest"); | |
| 5450 | + | ||
| 5451 | + | // Down at the tail stays at the tail rather than wrapping. | |
| 5452 | + | press(&mut view, KeyCode::Char('j')); | |
| 5453 | + | assert_eq!(view.run_scroll, 0); | |
| 5454 | + | } | |
| 5455 | + | ||
| 5456 | + | // Home is the oldest line and End the newest, whichever way the offset | |
| 5457 | + | // happens to be counted underneath. | |
| 5458 | + | #[test] | |
| 5459 | + | fn home_and_end_reach_both_ends_of_the_run_output() { | |
| 5460 | + | let (mut view, _log) = at_run_output(100, 10); | |
| 5461 | + | let len = view.running.as_ref().unwrap().output().len(); | |
| 5462 | + | ||
| 5463 | + | press(&mut view, KeyCode::Home); | |
| 5464 | + | assert_eq!( | |
| 5465 | + | view.run_scroll, | |
| 5466 | + | len - 10, | |
| 5467 | + | "Home did not reach the oldest line" | |
| 5468 | + | ); | |
| 5469 | + | ||
| 5470 | + | press(&mut view, KeyCode::End); | |
| 5471 | + | assert_eq!(view.run_scroll, 0, "End did not return to the newest"); | |
| 5472 | + | } | |
| 5473 | + | ||
| 5474 | + | // Scrolling stops at the oldest line. Running off the top would leave the | |
| 5475 | + | // stored offset past the end, and the first few presses back would move | |
| 5476 | + | // nothing while the number came down. | |
| 5477 | + | #[test] | |
| 5478 | + | fn the_run_screen_does_not_scroll_past_its_oldest_line() { | |
| 5479 | + | let (mut view, _log) = at_run_output(100, 10); | |
| 5480 | + | let len = view.running.as_ref().unwrap().output().len(); | |
| 5481 | + | ||
| 5482 | + | for _ in 0..(len + 50) { | |
| 5483 | + | press(&mut view, KeyCode::Char('k')); | |
| 5484 | + | } | |
| 5485 | + | ||
| 5486 | + | assert_eq!(view.run_scroll, len - 10); | |
| 5487 | + | } | |
| 5488 | + | ||
| 5489 | + | // Output shorter than the screen cannot be scrolled: there is nowhere to | |
| 5490 | + | // go, and an offset would scroll the text off into blank space. | |
| 5491 | + | #[test] | |
| 5492 | + | fn a_run_shorter_than_the_screen_does_not_scroll() { | |
| 5493 | + | let (mut view, _log) = at_run_output(3, 40); | |
| 5494 | + | ||
| 5495 | + | press(&mut view, KeyCode::Char('k')); | |
| 5496 | + | press(&mut view, KeyCode::Home); | |
| 5497 | + | ||
| 5498 | + | assert_eq!(view.run_scroll, 0); | |
| 5499 | + | } | |
| 5500 | + | ||
| 5501 | + | // The footer stops promising what a run refuses. `enter install` and | |
| 5502 | + | // `esc back` both belong to the credits step, which is the step still | |
| 5503 | + | // underneath when the run screen is drawn. | |
| 5504 | + | #[test] | |
| 5505 | + | fn a_running_install_advertises_only_the_keys_that_work() { | |
| 5506 | + | let (view, _log) = at_run_output(100, 10); | |
| 5507 | + | let hints: Vec<String> = view | |
| 5508 | + | .hints() | |
| 5509 | + | .iter() | |
| 5510 | + | .map(|hint| hint.key.to_string()) | |
| 5511 | + | .collect(); | |
| 5512 | + | ||
| 5513 | + | assert!(hints.iter().any(|key| key == "j/k"), "{hints:?}"); | |
| 5514 | + | assert!(!hints.iter().any(|key| key == "enter"), "{hints:?}"); | |
| 5515 | + | } | |
| 5516 | + | ||
| 5318 | 5517 | // ---- the recovery phrase pane ---- | |
| 5319 | 5518 | ||
| 5320 | 5519 | /// A view sitting on a finished install with a phrase waiting to be copied. |