| 5 |
5 |
|
//! command, not a reimplementation of Anaconda. See docs/CONSOLE.md and the
|
| 6 |
6 |
|
//! wiki note `alloy-console`.
|
| 7 |
7 |
|
//!
|
| 8 |
|
- |
//! Four questions: which disk, what to call the machine, who logs in, and a
|
| 9 |
|
- |
//! summary that shows the exact commands before running any of them.
|
|
8 |
+ |
//! Four questions: which disk, what to call the machine, who logs in, and
|
|
9 |
+ |
//! whether to encrypt. Then a summary that shows the exact commands before
|
|
10 |
+ |
//! running any of them, and a credits page naming what is about to be
|
|
11 |
+ |
//! installed and under what terms. The install starts from the credits, not
|
|
12 |
+ |
//! from the summary: the last screen is the one that acts, and this is the
|
|
13 |
+ |
//! only screen of the six that asks nothing.
|
| 10 |
14 |
|
//!
|
| 11 |
15 |
|
//! The install runs through [`Sequence`], so the frame keeps drawing for the
|
| 12 |
16 |
|
//! minutes `bootc` takes.
|
| 82 |
86 |
|
/// the list should find that out before typing anything.
|
| 83 |
87 |
|
/// Encryption sits after the account rather than beside the disk it applies to.
|
| 84 |
88 |
|
/// Both screens that take a secret are then adjacent, so the passphrases are
|
| 85 |
|
- |
/// typed in one stretch, and the review screen stays last.
|
| 86 |
|
- |
const STEPS: [Step; 5] = [
|
|
89 |
+ |
/// typed in one stretch, and the review comes last of the questions.
|
|
90 |
+ |
///
|
|
91 |
+ |
/// Credits sits after the review, which puts a screen between the summary and
|
|
92 |
+ |
/// the disk being erased. That is a side effect rather than the reason: the
|
|
93 |
+ |
/// page belongs at the end because it is the one screen that is not a question,
|
|
94 |
+ |
/// and a user who has just read what is about to run is the one most likely to
|
|
95 |
+ |
/// look at what it is made of.
|
|
96 |
+ |
const STEPS: [Step; 6] = [
|
| 87 |
97 |
|
Step::Disk,
|
| 88 |
98 |
|
Step::Hostname,
|
| 89 |
99 |
|
Step::Account,
|
| 90 |
100 |
|
Step::Encryption,
|
| 91 |
101 |
|
Step::Summary,
|
|
102 |
+ |
Step::Credits,
|
| 92 |
103 |
|
];
|
| 93 |
104 |
|
|
| 94 |
105 |
|
/// Which question the wizard is on.
|
| 103 |
114 |
|
Account,
|
| 104 |
115 |
|
Encryption,
|
| 105 |
116 |
|
Summary,
|
|
117 |
+ |
Credits,
|
| 106 |
118 |
|
}
|
| 107 |
119 |
|
|
| 108 |
120 |
|
impl Step {
|
|
121 |
+ |
/// Short enough that the title fits 80 columns on the longest step.
|
|
122 |
+ |
///
|
|
123 |
+ |
/// The title is `install (bootc), step 6 of 6: <label>`, which is 38
|
|
124 |
+ |
/// columns before the label, so the budget is real but not tight. "review
|
|
125 |
+ |
/// and install" was the longest and is now merely the second longest, since
|
|
126 |
+ |
/// it stopped being the step that installs.
|
| 109 |
127 |
|
const fn label(self) -> &'static str {
|
| 110 |
128 |
|
match self {
|
| 111 |
129 |
|
Self::Disk => "select a disk",
|
| 112 |
130 |
|
Self::Hostname => "name this machine",
|
| 113 |
131 |
|
Self::Account => "create your account",
|
| 114 |
132 |
|
Self::Encryption => "encrypt the disk",
|
| 115 |
|
- |
Self::Summary => "review and install",
|
|
133 |
+ |
Self::Summary => "review",
|
|
134 |
+ |
Self::Credits => "credits",
|
| 116 |
135 |
|
}
|
| 117 |
136 |
|
}
|
| 118 |
137 |
|
|
| 123 |
142 |
|
}
|
| 124 |
143 |
|
}
|
| 125 |
144 |
|
|
|
145 |
+ |
/// One line of the credits page, before a theme has been near it.
|
|
146 |
+ |
///
|
|
147 |
+ |
/// Borrowed from the manifest, which is `'static`, so laying the page out costs
|
|
148 |
+ |
/// no copies of strings that are about to be formatted anyway.
|
|
149 |
+ |
#[derive(Debug, Clone, Copy)]
|
|
150 |
+ |
enum Row {
|
|
151 |
+ |
/// The gap between sections.
|
|
152 |
+ |
Blank,
|
|
153 |
+ |
/// A section heading.
|
|
154 |
+ |
Title(&'static str),
|
|
155 |
+ |
/// A project and the license it is under.
|
|
156 |
+ |
Project {
|
|
157 |
+ |
name: &'static str,
|
|
158 |
+ |
license: &'static str,
|
|
159 |
+ |
},
|
|
160 |
+ |
/// Where to find it.
|
|
161 |
+ |
Detail(&'static str),
|
|
162 |
+ |
/// What it is doing here, when the name does not say it.
|
|
163 |
+ |
Note(&'static str),
|
|
164 |
+ |
}
|
|
165 |
+ |
|
| 126 |
166 |
|
/// Which field of the account step has focus.
|
| 127 |
167 |
|
///
|
| 128 |
168 |
|
/// Indices into a [`FocusRing`], named so the render and key paths agree about
|
| 2146 |
2186 |
|
/// The encryption checkbox, until the step is confirmed and it becomes an
|
| 2147 |
2187 |
|
/// answer. Starts ticked: Alloy encrypts unless told not to.
|
| 2148 |
2188 |
|
encrypt: bool,
|
|
2189 |
+ |
/// How far the credits page is scrolled, in lines.
|
|
2190 |
+ |
credits_scroll: usize,
|
|
2191 |
+ |
/// How many lines of the credits page last fitted on screen.
|
|
2192 |
+ |
///
|
|
2193 |
+ |
/// A [`Cell`](std::cell::Cell) because only `render` knows the height and
|
|
2194 |
+ |
/// `render` takes `&self`. Page-down and End cannot be spelled without it,
|
|
2195 |
+ |
/// and neither can an honest bottom stop: clamping in `render` alone would
|
|
2196 |
+ |
/// leave the stored offset running past the end, so the first few Ups after
|
|
2197 |
+ |
/// End would move nothing while the number came back down.
|
|
2198 |
+ |
///
|
|
2199 |
+ |
/// Zero until the page has been drawn once, which is why every reader
|
|
2200 |
+ |
/// treats zero as "one screen" rather than as a real measurement.
|
|
2201 |
+ |
credits_viewport: std::cell::Cell<usize>,
|
| 2149 |
2202 |
|
answers: Answers,
|
| 2150 |
2203 |
|
error: Option<String>,
|
| 2151 |
2204 |
|
/// The install, once it has been confirmed and started.
|
| 2189 |
2242 |
|
recovery_typed: TextField::new(),
|
| 2190 |
2243 |
|
recovery_ack: false,
|
| 2191 |
2244 |
|
encrypt: true,
|
|
2245 |
+ |
credits_scroll: 0,
|
|
2246 |
+ |
credits_viewport: std::cell::Cell::new(0),
|
| 2192 |
2247 |
|
answers: Answers::default(),
|
| 2193 |
2248 |
|
error: None,
|
| 2194 |
2249 |
|
running: None,
|
| 2791 |
2846 |
|
frame.render_widget(Paragraph::new(lines), area);
|
| 2792 |
2847 |
|
}
|
| 2793 |
2848 |
|
|
|
2849 |
+ |
/// The credits page as rows, before anything styles or scrolls them.
|
|
2850 |
+ |
///
|
|
2851 |
+ |
/// Rows rather than [`Line`]s because the key path needs to know how many
|
|
2852 |
+ |
/// there are and has no [`Theme`] to build spans with, and because a second
|
|
2853 |
+ |
/// function counting what a first function draws is a drift waiting to
|
|
2854 |
+ |
/// happen. One list, counted and styled from the same shape.
|
|
2855 |
+ |
fn credits_rows() -> Vec<Row> {
|
|
2856 |
+ |
let mut rows = Vec::new();
|
|
2857 |
+ |
for section in &crate::credits::credits().sections {
|
|
2858 |
+ |
if !rows.is_empty() {
|
|
2859 |
+ |
rows.push(Row::Blank);
|
|
2860 |
+ |
}
|
|
2861 |
+ |
rows.push(Row::Title(§ion.title));
|
|
2862 |
+ |
for project in §ion.projects {
|
|
2863 |
+ |
// One string per row, in the order someone wants them: what it
|
|
2864 |
+ |
// is and what it is under, then where to find it, then why it
|
|
2865 |
+ |
// is here. Three columns would need the widest url to fit and
|
|
2866 |
+ |
// they run past 50 characters; a url with its note appended
|
|
2867 |
+ |
// reached 127, and a `Paragraph` that does not wrap truncates
|
|
2868 |
+ |
// rather than folds, so the longest note would have been the
|
|
2869 |
+ |
// one nobody could read.
|
|
2870 |
+ |
rows.push(Row::Project {
|
|
2871 |
+ |
name: &project.name,
|
|
2872 |
+ |
license: &project.license,
|
|
2873 |
+ |
});
|
|
2874 |
+ |
rows.push(Row::Detail(&project.url));
|
|
2875 |
+ |
if let Some(note) = project.note.as_deref() {
|
|
2876 |
+ |
rows.push(Row::Note(note));
|
|
2877 |
+ |
}
|
|
2878 |
+ |
}
|
|
2879 |
+ |
}
|
|
2880 |
+ |
rows
|
|
2881 |
+ |
}
|
|
2882 |
+ |
|
|
2883 |
+ |
/// The rows, styled.
|
|
2884 |
+ |
///
|
|
2885 |
+ |
/// Built fresh per frame rather than cached on the view: a few hundred
|
|
2886 |
+ |
/// short spans off a manifest that is already parsed and held static, on a
|
|
2887 |
+ |
/// screen that redraws at the shell's idle tick. A cache would have to be
|
|
2888 |
+ |
/// invalidated on the one thing that can change under it, the theme.
|
|
2889 |
+ |
fn credits_lines(theme: &Theme) -> Vec<Line<'static>> {
|
|
2890 |
+ |
Self::credits_rows()
|
|
2891 |
+ |
.into_iter()
|
|
2892 |
+ |
.map(|row| match row {
|
|
2893 |
+ |
Row::Blank => Line::default(),
|
|
2894 |
+ |
Row::Title(title) => Line::from(text::secondary(theme, title.to_string())),
|
|
2895 |
+ |
Row::Project { name, license } => Line::from(vec![
|
|
2896 |
+ |
text::primary(theme, format!(" {name:<24}")),
|
|
2897 |
+ |
text::muted(theme, license.to_string()),
|
|
2898 |
+ |
]),
|
|
2899 |
+ |
Row::Detail(url) => Line::from(text::muted(theme, format!(" {url}"))),
|
|
2900 |
+ |
Row::Note(note) => Line::from(text::muted(theme, format!(" {note}"))),
|
|
2901 |
+ |
})
|
|
2902 |
+ |
.collect()
|
|
2903 |
+ |
}
|
|
2904 |
+ |
|
|
2905 |
+ |
/// How many lines the page has.
|
|
2906 |
+ |
///
|
|
2907 |
+ |
/// One row is one line, so this is the row count. The key path asks for it
|
|
2908 |
+ |
/// without a theme, which is the whole reason rows exist separately.
|
|
2909 |
+ |
fn credits_len() -> usize {
|
|
2910 |
+ |
Self::credits_rows().len()
|
|
2911 |
+ |
}
|
|
2912 |
+ |
|
|
2913 |
+ |
/// The last line the page can be scrolled to, given what fits on screen.
|
|
2914 |
+ |
///
|
|
2915 |
+ |
/// Zero when everything fits, which is what makes every key on this page a
|
|
2916 |
+ |
/// no-op on a tall terminal rather than a scroll into blank space.
|
|
2917 |
+ |
fn credits_max_scroll(&self, lines: usize) -> usize {
|
|
2918 |
+ |
let viewport = self.credits_viewport.get().max(1);
|
|
2919 |
+ |
lines.saturating_sub(viewport)
|
|
2920 |
+ |
}
|
|
2921 |
+ |
|
|
2922 |
+ |
/// The credits page: what Alloy ships, and under what terms.
|
|
2923 |
+ |
///
|
|
2924 |
+ |
/// A [`Paragraph`] with a scroll offset rather than an
|
|
2925 |
+ |
/// [`AlloyList`](alloy_tui::AlloyList). A list's offset derives from its
|
|
2926 |
+ |
/// selection, so it draws a highlighted row that moves as you scroll, and
|
|
2927 |
+ |
/// there is nothing here to select: this is prose, and the highlight would
|
|
2928 |
+ |
/// be an affordance promising an Enter that does something else.
|
|
2929 |
+ |
fn render_credits(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
|
2930 |
+ |
let lines = Self::credits_lines(theme);
|
|
2931 |
+ |
// Recorded for the key path, which cannot ask the frame how tall it is.
|
|
2932 |
+ |
self.credits_viewport.set(area.height as usize);
|
|
2933 |
+ |
|
|
2934 |
+ |
let offset = self
|
|
2935 |
+ |
.credits_scroll
|
|
2936 |
+ |
.min(self.credits_max_scroll(lines.len()));
|
|
2937 |
+ |
frame.render_widget(
|
|
2938 |
+ |
Paragraph::new(lines).scroll((u16::try_from(offset).unwrap_or(u16::MAX), 0)),
|
|
2939 |
+ |
area,
|
|
2940 |
+ |
);
|
|
2941 |
+ |
}
|
|
2942 |
+ |
|
|
2943 |
+ |
/// Keys for the credits page: scrolling, and nothing else.
|
|
2944 |
+ |
///
|
|
2945 |
+ |
/// Enter is not here. It starts the install, which is [`handle`](Self::handle)'s
|
|
2946 |
+ |
/// to route, so this stays the one place that only moves the page.
|
|
2947 |
+ |
fn scroll_credits(&mut self, key: KeyEvent) -> Flow {
|
|
2948 |
+ |
let max = self.credits_max_scroll(Self::credits_len());
|
|
2949 |
+ |
// A page is one screen less a line, so the line you were reading at the
|
|
2950 |
+ |
// bottom is at the top after the jump and nothing crosses the fold
|
|
2951 |
+ |
// unread.
|
|
2952 |
+ |
let page = self.credits_viewport.get().max(2) - 1;
|
|
2953 |
+ |
|
|
2954 |
+ |
self.credits_scroll = match key.code {
|
|
2955 |
+ |
KeyCode::Char('j') | KeyCode::Down => self.credits_scroll.saturating_add(1),
|
|
2956 |
+ |
KeyCode::Char('k') | KeyCode::Up => self.credits_scroll.saturating_sub(1),
|
|
2957 |
+ |
KeyCode::PageDown => self.credits_scroll.saturating_add(page),
|
|
2958 |
+ |
KeyCode::PageUp => self.credits_scroll.saturating_sub(page),
|
|
2959 |
+ |
KeyCode::Home => 0,
|
|
2960 |
+ |
KeyCode::End => max,
|
|
2961 |
+ |
_ => self.credits_scroll,
|
|
2962 |
+ |
}
|
|
2963 |
+ |
// Clamped at both ends, and no wrap. A page that jumped back to the top
|
|
2964 |
+ |
// from the bottom would read as a redraw rather than as a move.
|
|
2965 |
+ |
.min(max);
|
|
2966 |
+ |
Flow::Continue
|
|
2967 |
+ |
}
|
|
2968 |
+ |
|
| 2794 |
2969 |
|
/// Whether the recovery phrase is waiting to be written down.
|
| 2795 |
2970 |
|
///
|
| 2796 |
2971 |
|
/// True only between a successful install and the user typing the phrase
|
| 3156 |
3331 |
|
hint("space", "toggle"),
|
| 3157 |
3332 |
|
hint("enter", "next"),
|
| 3158 |
3333 |
|
],
|
| 3159 |
|
- |
Step::Summary => vec![hint("enter", "install")],
|
|
3334 |
+ |
Step::Summary => vec![hint("enter", "next")],
|
|
3335 |
+ |
Step::Credits => vec![hint("j/k", "scroll"), hint("enter", "install")],
|
| 3160 |
3336 |
|
};
|
| 3161 |
3337 |
|
if !self.steps.is_first() {
|
| 3162 |
3338 |
|
hints.push(hint("esc", "back"));
|
| 3197 |
3373 |
|
Step::Account => return self.render_account(frame, inner, theme),
|
| 3198 |
3374 |
|
Step::Encryption => return self.render_encryption(frame, inner, theme),
|
| 3199 |
3375 |
|
Step::Summary => return self.render_summary(frame, inner, theme),
|
|
3376 |
+ |
Step::Credits => return self.render_credits(frame, inner, theme),
|
| 3200 |
3377 |
|
Step::Disk => {}
|
| 3201 |
3378 |
|
}
|
| 3202 |
3379 |
|
|
| 3244 |
3421 |
|
Step::Hostname => return self.edit_hostname(key),
|
| 3245 |
3422 |
|
Step::Account => return self.edit_account(key),
|
| 3246 |
3423 |
|
Step::Encryption => return self.edit_encryption(key),
|
|
3424 |
+ |
// The review no longer installs: it advances to the credits, which
|
|
3425 |
+ |
// is the step that does. Enter meaning two different things one
|
|
3426 |
+ |
// screen apart is the whole of what moved here.
|
| 3247 |
3427 |
|
Step::Summary => {
|
|
3428 |
+ |
if key.code == KeyCode::Enter {
|
|
3429 |
+ |
self.steps.advance();
|
|
3430 |
+ |
}
|
|
3431 |
+ |
return Flow::Continue;
|
|
3432 |
+ |
}
|
|
3433 |
+ |
Step::Credits => {
|
| 3248 |
3434 |
|
if key.code == KeyCode::Enter {
|
| 3249 |
3435 |
|
return self.confirm_install();
|
| 3250 |
3436 |
|
}
|
| 3251 |
|
- |
return Flow::Continue;
|
|
3437 |
+ |
return self.scroll_credits(key);
|
| 3252 |
3438 |
|
}
|
| 3253 |
3439 |
|
Step::Disk => {}
|
| 3254 |
3440 |
|
}
|
| 3594 |
3780 |
|
recovery_typed: TextField::new(),
|
| 3595 |
3781 |
|
recovery_ack: false,
|
| 3596 |
3782 |
|
encrypt: true,
|
|
3783 |
+ |
credits_scroll: 0,
|
|
3784 |
+ |
credits_viewport: std::cell::Cell::new(0),
|
| 3597 |
3785 |
|
answers: Answers::default(),
|
| 3598 |
3786 |
|
error: None,
|
| 3599 |
3787 |
|
running: None,
|
| 4133 |
4321 |
|
(view, log)
|
| 4134 |
4322 |
|
}
|
| 4135 |
4323 |
|
|
| 4136 |
|
- |
// The summary is not the gate. Enter there raises the modal; only answering
|
| 4137 |
|
- |
// that runs anything.
|
|
4324 |
+ |
/// One step past the summary, which is where the install is started from.
|
|
4325 |
+ |
fn at_credits() -> (InstallView, CommandLog) {
|
|
4326 |
+ |
let (mut view, mut log) = at_summary();
|
|
4327 |
+ |
view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
|
|
4328 |
+ |
assert_eq!(view.step(), Step::Credits, "fixture stalled on the summary");
|
|
4329 |
+ |
(view, log)
|
|
4330 |
+ |
}
|
|
4331 |
+ |
|
|
4332 |
+ |
// The summary stopped being the gate when the credits page went in after
|
|
4333 |
+ |
// it. Enter here advances, and the disk is not mentioned yet.
|
| 4138 |
4334 |
|
#[test]
|
| 4139 |
|
- |
fn enter_on_the_summary_raises_a_destructive_confirm() {
|
|
4335 |
+ |
fn enter_on_the_summary_advances_to_the_credits() {
|
| 4140 |
4336 |
|
let (mut view, mut log) = at_summary();
|
| 4141 |
4337 |
|
|
| 4142 |
4338 |
|
let flow = view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
|
| 4143 |
4339 |
|
|
|
4340 |
+ |
assert!(
|
|
4341 |
+ |
matches!(flow, Flow::Continue),
|
|
4342 |
+ |
"the summary asked to install"
|
|
4343 |
+ |
);
|
|
4344 |
+ |
assert_eq!(view.step(), Step::Credits);
|
|
4345 |
+ |
}
|
|
4346 |
+ |
|
|
4347 |
+ |
// The credits page is the gate. Enter there raises the modal; only
|
|
4348 |
+ |
// answering that runs anything.
|
|
4349 |
+ |
#[test]
|
|
4350 |
+ |
fn enter_on_the_credits_raises_a_destructive_confirm() {
|
|
4351 |
+ |
let (mut view, mut log) = at_credits();
|
|
4352 |
+ |
|
|
4353 |
+ |
let flow = view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
|
|
4354 |
+ |
|
| 4144 |
4355 |
|
let Flow::Confirm(confirm) = flow else {
|
| 4145 |
|
- |
panic!("the summary ran without confirming");
|
|
4356 |
+ |
panic!("the credits page ran without confirming");
|
| 4146 |
4357 |
|
};
|
| 4147 |
4358 |
|
assert_eq!(confirm.severity, Severity::Error);
|
| 4148 |
4359 |
|
assert!(confirm.message.contains("/dev/sda"), "{}", confirm.message);
|
| 4149 |
4360 |
|
}
|
| 4150 |
4361 |
|
|
|
4362 |
+ |
// Esc from the credits is a way back to the answers, not a way out. A
|
|
4363 |
+ |
// review screen you cannot return to from the last page would make the
|
|
4364 |
+ |
// credits a trap rather than a step.
|
|
4365 |
+ |
#[test]
|
|
4366 |
+ |
fn back_from_the_credits_returns_to_the_summary_with_the_answers_intact() {
|
|
4367 |
+ |
let (mut view, _log) = at_credits();
|
|
4368 |
+ |
|
|
4369 |
+ |
let flow = view.cancel();
|
|
4370 |
+ |
|
|
4371 |
+ |
assert!(matches!(flow, Flow::Continue), "esc left the installer");
|
|
4372 |
+ |
assert_eq!(view.step(), Step::Summary);
|
|
4373 |
+ |
assert_eq!(view.answers.disk.as_deref(), Some("/dev/sda"));
|
|
4374 |
+ |
assert_eq!(view.answers.hostname.as_deref(), Some(DEFAULT_HOSTNAME));
|
|
4375 |
+ |
assert_eq!(view.answers.username.as_deref(), Some("max"));
|
|
4376 |
+ |
}
|
|
4377 |
+ |
|
|
4378 |
+ |
// The page is longer than any terminal, so it scrolls, and it stops at both
|
|
4379 |
+ |
// ends. Running off the bottom into blank space is how a reader concludes
|
|
4380 |
+ |
// the list ended where the blank started.
|
|
4381 |
+ |
#[test]
|
|
4382 |
+ |
fn the_credits_scroll_clamps_at_both_ends() {
|
|
4383 |
+ |
let (mut view, _log) = at_credits();
|
|
4384 |
+ |
// As if it had been drawn into a short pane once.
|
|
4385 |
+ |
view.credits_viewport.set(10);
|
|
4386 |
+ |
let max = view.credits_max_scroll(InstallView::credits_len());
|
|
4387 |
+ |
assert!(max > 0, "the manifest is shorter than ten lines");
|
|
4388 |
+ |
|
|
4389 |
+ |
view.scroll_credits(KeyEvent::from(KeyCode::Up));
|
|
4390 |
+ |
assert_eq!(view.credits_scroll, 0, "scrolled above the first line");
|
|
4391 |
+ |
|
|
4392 |
+ |
view.scroll_credits(KeyEvent::from(KeyCode::End));
|
|
4393 |
+ |
assert_eq!(view.credits_scroll, max);
|
|
4394 |
+ |
view.scroll_credits(KeyEvent::from(KeyCode::Down));
|
|
4395 |
+ |
assert_eq!(view.credits_scroll, max, "scrolled past the last line");
|
|
4396 |
+ |
|
|
4397 |
+ |
// And back, one press at a time rather than several that do nothing:
|
|
4398 |
+ |
// End stores the real bottom, not an offset the render clamps later.
|
|
4399 |
+ |
view.scroll_credits(KeyEvent::from(KeyCode::Up));
|
|
4400 |
+ |
assert_eq!(view.credits_scroll, max - 1);
|
|
4401 |
+ |
|
|
4402 |
+ |
view.scroll_credits(KeyEvent::from(KeyCode::Home));
|
|
4403 |
+ |
assert_eq!(view.credits_scroll, 0);
|
|
4404 |
+ |
}
|
|
4405 |
+ |
|
|
4406 |
+ |
// A page that fits needs no scrolling, and every key on it should do
|
|
4407 |
+ |
// nothing rather than scroll the text off the top of its own pane.
|
|
4408 |
+ |
#[test]
|
|
4409 |
+ |
fn a_credits_page_that_fits_does_not_scroll() {
|
|
4410 |
+ |
let (mut view, _log) = at_credits();
|
|
4411 |
+ |
view.credits_viewport.set(InstallView::credits_len() + 5);
|
|
4412 |
+ |
|
|
4413 |
+ |
view.scroll_credits(KeyEvent::from(KeyCode::End));
|
|
4414 |
+ |
assert_eq!(view.credits_scroll, 0);
|
|
4415 |
+ |
view.scroll_credits(KeyEvent::from(KeyCode::PageDown));
|
|
4416 |
+ |
assert_eq!(view.credits_scroll, 0);
|
|
4417 |
+ |
}
|
|
4418 |
+ |
|
|
4419 |
+ |
// Every section is a title, every project is two rows and a third if it
|
|
4420 |
+ |
// carries a note, with a blank between sections. An entry that lost a row
|
|
4421 |
+ |
// would leave the page scrolling short of its own last line.
|
|
4422 |
+ |
#[test]
|
|
4423 |
+ |
fn the_rows_are_the_manifest_laid_out() {
|
|
4424 |
+ |
let sections = &crate::credits::credits().sections;
|
|
4425 |
+ |
let titles = sections.len();
|
|
4426 |
+ |
let blanks = titles.saturating_sub(1);
|
|
4427 |
+ |
let projects: usize = sections.iter().map(|section| section.projects.len()).sum();
|
|
4428 |
+ |
let notes = sections
|
|
4429 |
+ |
.iter()
|
|
4430 |
+ |
.flat_map(|section| §ion.projects)
|
|
4431 |
+ |
.filter(|project| project.note.is_some())
|
|
4432 |
+ |
.count();
|
|
4433 |
+ |
assert_eq!(
|
|
4434 |
+ |
InstallView::credits_len(),
|
|
4435 |
+ |
titles + blanks + projects * 2 + notes
|
|
4436 |
+ |
);
|
|
4437 |
+ |
}
|
|
4438 |
+ |
|
|
4439 |
+ |
// The credits page reads; it does not type. Without this the shell stops
|
|
4440 |
+ |
// treating q as quit on the last screen before an install.
|
|
4441 |
+ |
#[test]
|
|
4442 |
+ |
fn the_credits_page_is_not_a_typing_step() {
|
|
4443 |
+ |
let (view, _log) = at_credits();
|
|
4444 |
+ |
assert!(!view.text_entry(), "the shell should claim q again here");
|
|
4445 |
+ |
}
|
|
4446 |
+ |
|
| 4151 |
4447 |
|
#[test]
|
| 4152 |
4448 |
|
fn the_summary_is_not_a_typing_step() {
|
| 4153 |
4449 |
|
let (view, _log) = at_summary();
|
| 5845 |
6141 |
|
let (view, _log) = view();
|
| 5846 |
6142 |
|
let title = view.title();
|
| 5847 |
6143 |
|
assert!(title.contains("mock"), "{title}");
|
| 5848 |
|
- |
assert!(title.contains("step 1 of 5"), "{title}");
|
|
6144 |
+ |
assert!(title.contains("step 1 of 6"), "{title}");
|
| 5849 |
6145 |
|
}
|
| 5850 |
6146 |
|
}
|