1pub mod gates;
20pub mod install;
21pub mod languages;
22
23use std::path::{Path, PathBuf};
24
25use qframe::prelude::*;
26use qframe::widgets::{
27 Button, CopyValue, Field, FileBrowser, FilePicker, FilePickerMsg, Form, FormErrors, LogBuffer, LogLevel, LogLine,
28 LogView, PickMode, RadioGroup, ScrollView, Spinner, Terminal, TerminalEvent, TerminalSession, Toast, Wizard,
29};
30
31use crate::engine::EngineKind;
32use crate::store::{Config, HostDirs, SetupStep};
33
34use gates::{EngineCheck, EngineProblem, Gates, LocationCheck, LocationProblem};
35use install::{InstallHost, Installer, Remedy};
36use languages::Languages;
37
38const ENGINES: [EngineKind; 2] = [EngineKind::Podman, EngineKind::Docker];
41
42const DEFAULT_PLACE: usize = 0;
44const CUSTOM_PLACE: usize = 1;
46
47const PAGE_ROWS: u16 = 15;
55
56const OUTPUT_ROWS: u16 = 4;
59
60const INSTALL_ROWS: u16 = 6;
64
65const LABEL_WIDTH: u16 = 18;
67
68const PAGE_WIDTH: u16 = 76;
71
72const WIZARD_ROWS: u16 = PAGE_ROWS + 4;
76
77const CHROME_ROWS: u16 = 1;
80
81#[derive(Debug, Clone, PartialEq)]
83pub enum Msg {
84 Language(usize),
86 Engine(usize),
88 Recheck,
90 InstallHere,
92 ShowCommand,
94 InstallEvent(TerminalEvent),
96 EngineChecked(EngineKind, EngineCheck),
99 Place(usize),
101 Picker(FilePickerMsg),
103 LocationChecked(PathBuf, LocationCheck),
105 Back,
107 Next,
109 Finish,
111 Step(usize),
113}
114
115#[derive(Debug)]
120enum Install {
121 Idle,
123 Shown,
125 Running(TerminalSession),
127 Ended(TerminalSession),
129 Failed(String),
131}
132
133impl Install {
134 fn session(&self) -> Option<&TerminalSession> {
136 match self {
137 Self::Running(session) | Self::Ended(session) => Some(session),
138 Self::Idle | Self::Shown | Self::Failed(_) => None,
139 }
140 }
141}
142
143#[derive(Debug)]
149pub struct Setup {
150 config: Config,
151 host: InstallHost,
152 step: usize,
153 languages: Languages,
154 language: usize,
155 engine: usize,
156 engine_check: EngineCheck,
157 installer: Installer,
158 install: Install,
159 output: LogBuffer,
160 place: usize,
161 default_path: Option<PathBuf>,
162 custom_path: Option<PathBuf>,
163 browser: FileBrowser,
164 location_check: LocationCheck,
165 errors: FormErrors,
166 alone: bool,
167 finished: bool,
168}
169
170impl Setup {
171 #[must_use]
181 pub fn new(
182 config: Config,
183 dirs: &HostDirs,
184 gates: &Gates,
185 host: InstallHost,
186 installer: Installer,
187 system: Option<&str>,
188 ) -> Self {
189 let stored = config.settings().language().filter(|code| Config::LANGUAGES.contains(&code.as_str()));
193 let languages = Languages::shown_in(system, stored.as_deref().or(system).unwrap_or("en"));
194 let language = languages.row_of(stored.as_deref().or(system).unwrap_or("en"));
195 let engine = config
199 .engine_kind()
200 .and_then(EngineKind::from_name)
201 .or_else(|| host.engines.first().copied())
202 .and_then(|kind| ENGINES.iter().position(|known| *known == kind))
203 .unwrap_or(0);
204 let default_path = dirs.store.clone();
205 let stored = config.folder_path().filter(|path| Some(path) != default_path.as_ref());
208 let start =
209 dirs.documents.clone().or_else(|| std::env::current_dir().ok()).unwrap_or_else(|| PathBuf::from("."));
210 let mut setup = Self {
211 config,
212 host,
213 step: 0,
214 languages,
215 language,
216 engine,
217 engine_check: gates.engine.clone(),
218 installer,
219 install: Install::Idle,
220 output: LogBuffer::new(usize::from(OUTPUT_ROWS) * 8),
221 place: if stored.is_some() || default_path.is_none() { CUSTOM_PLACE } else { DEFAULT_PLACE },
222 default_path,
223 custom_path: stored,
224 browser: FileBrowser::new(start, PickMode::Folders),
225 location_check: gates.location.clone(),
226 errors: FormErrors::new(),
227 alone: false,
228 finished: false,
229 };
230 setup.remember(&gates.engine);
231 setup.step = gates.entry().map_or(SetupStep::ALL.len() - 1, step_index);
232 setup
233 }
234
235 #[must_use]
242 pub fn for_step(
243 config: Config,
244 dirs: &HostDirs,
245 gates: &Gates,
246 host: InstallHost,
247 installer: Installer,
248 system: Option<&str>,
249 step: SetupStep,
250 ) -> Self {
251 let mut setup = Self::new(config, dirs, gates, host, installer, system);
252 setup.step = step_index(step);
253 setup.alone = true;
254 setup
255 }
256
257 #[must_use]
259 pub fn is_alone(&self) -> bool {
260 self.alone
261 }
262
263 #[must_use]
265 pub fn step(&self) -> SetupStep {
266 SetupStep::ALL[self.step.min(SetupStep::ALL.len() - 1)]
267 }
268
269 #[must_use]
274 pub fn language(&self) -> &'static str {
275 self.languages.on_row(self.language)
276 }
277
278 #[must_use]
280 pub fn languages(&self) -> &Languages {
281 &self.languages
282 }
283
284 #[must_use]
286 pub fn engine_name(&self) -> &'static str {
287 ENGINES[self.engine].name()
288 }
289
290 #[must_use]
292 pub fn engine_check(&self) -> &EngineCheck {
293 &self.engine_check
294 }
295
296 #[must_use]
298 pub fn folder_path(&self) -> Option<&Path> {
299 match self.place {
300 DEFAULT_PLACE => self.default_path.as_deref(),
301 _ => self.custom_path.as_deref(),
302 }
303 }
304
305 #[must_use]
307 pub fn is_finished(&self) -> bool {
308 self.finished
309 }
310
311 #[must_use]
313 pub fn config(&self) -> &Config {
314 &self.config
315 }
316
317 #[must_use]
319 pub fn into_config(self) -> Config {
320 self.config
321 }
322
323 fn kind(&self) -> EngineKind {
325 ENGINES[self.engine]
326 }
327
328 fn remember(&mut self, check: &EngineCheck) {
330 self.output.clear();
331 let EngineCheck::Broken(problem) = check else { return };
332 let Some(output) = problem.output() else { return };
333 for line in output.lines().filter(|line| !line.trim().is_empty()) {
334 self.output.push(LogLine::new(LogLevel::Error, line.trim_end()));
335 }
336 }
337
338 fn is_busy(&self) -> bool {
341 self.engine_check == EngineCheck::Running || self.location_check == LocationCheck::Running
342 }
343
344 fn validate(&self) -> FormErrors {
347 let mut errors = FormErrors::new();
348 match self.step() {
349 SetupStep::Language => {}
351 SetupStep::Engine => {
352 errors.check("setup-engine", self.engine_check == EngineCheck::Working, t!("setup.engine-not-ready"));
353 }
354 SetupStep::Location => {
355 errors.check(
356 "setup-location",
357 self.location_check == LocationCheck::Usable,
358 t!("setup.location-not-ready"),
359 );
360 }
361 }
362 errors
363 }
364}
365
366fn step_index(step: SetupStep) -> usize {
368 SetupStep::ALL.iter().position(|known| *known == step).unwrap_or(0)
369}
370
371fn ask_engine(kind: EngineKind) -> Command<Msg> {
373 Command::perform(move || Msg::EngineChecked(kind, gates::check_engine_for_setup(kind)))
374}
375
376fn ask_folder(path: PathBuf) -> Command<Msg> {
378 Command::perform(move || {
379 let check = gates::check_location(&path);
380 Msg::LocationChecked(path, check)
381 })
382}
383
384fn first_control(step: usize) -> &'static str {
386 ["setup-language", "setup-engine", "setup-location"][step.min(SetupStep::ALL.len() - 1)]
387}
388
389fn enter(setup: &mut Setup) -> Command<Msg> {
391 let focus = Command::focus(first_control(setup.step));
392 let ask = match setup.step() {
393 SetupStep::Language => Command::none(),
394 SetupStep::Engine if setup.engine_check == EngineCheck::Unknown => {
395 setup.engine_check = EngineCheck::Running;
396 ask_engine(setup.kind())
397 }
398 SetupStep::Location if setup.location_check == LocationCheck::Unknown => match setup.folder_path() {
399 Some(path) => {
400 let path = path.to_path_buf();
401 setup.location_check = LocationCheck::Running;
402 ask_folder(path)
403 }
404 None => {
405 setup.location_check = LocationCheck::Broken(LocationProblem::Unset);
406 Command::none()
407 }
408 },
409 SetupStep::Engine | SetupStep::Location => Command::none(),
410 };
411 Command::batch([focus, ask])
412}
413
414pub fn opened(setup: &mut Setup) -> Command<Msg> {
417 enter(setup)
418}
419
420pub fn update(setup: &mut Setup, message: Msg) -> Command<Msg> {
422 let command = match message {
423 Msg::Language(index) => {
424 setup.language = index.min(setup.languages.codes().len() - 1);
425 Command::set_locale(setup.language())
427 }
428 Msg::Engine(index) => {
429 setup.engine = index.min(ENGINES.len() - 1);
430 setup.engine_check = EngineCheck::Running;
431 setup.output.clear();
432 setup.install = Install::Idle;
435 ask_engine(setup.kind())
436 }
437 Msg::Recheck => {
438 setup.engine_check = EngineCheck::Running;
439 setup.output.clear();
440 ask_engine(setup.kind())
441 }
442 Msg::InstallHere => start_install(setup),
443 Msg::ShowCommand => {
444 setup.install = Install::Shown;
445 Command::none()
446 }
447 Msg::InstallEvent(event) => install_event(setup, event),
448 Msg::EngineChecked(kind, check) => {
449 if kind == setup.kind() {
451 if check == EngineCheck::Working {
455 setup.install = Install::Idle;
456 }
457 setup.remember(&check);
458 setup.engine_check = check;
459 }
460 Command::none()
461 }
462 Msg::Place(index) => {
463 setup.place = index.min(CUSTOM_PLACE);
464 let open = if setup.place == CUSTOM_PLACE && setup.browser.loading().is_none() {
465 let folder = setup.browser.folder().to_path_buf();
466 setup.browser.open(folder, Msg::Picker)
467 } else {
468 Command::none()
469 };
470 Command::batch([open, check_place(setup)])
471 }
472 Msg::Picker(FilePickerMsg::Chosen(path)) => {
473 setup.custom_path = Some(path);
474 setup.place = CUSTOM_PLACE;
475 check_place(setup)
476 }
477 Msg::Picker(message) => setup.browser.update(message, Msg::Picker),
478 Msg::LocationChecked(path, check) => {
479 if Some(path.as_path()) == setup.folder_path() {
480 setup.location_check = check;
481 }
482 Command::none()
483 }
484 Msg::Back => {
487 setup.errors.clear();
488 setup.step = setup.step.saturating_sub(1);
489 setup.config.set_setup_step(setup.step());
490 enter(setup)
491 }
492 Msg::Step(index) => {
493 setup.errors.clear();
494 setup.step = index.min(SetupStep::ALL.len() - 1);
495 setup.config.set_setup_step(setup.step());
496 enter(setup)
497 }
498 Msg::Next => {
499 setup.errors = setup.validate();
500 if !setup.errors.is_empty() {
501 return setup.errors.focus_first();
502 }
503 setup.step = (setup.step + 1).min(SetupStep::ALL.len() - 1);
504 setup.config.set_setup_step(setup.step());
505 enter(setup)
506 }
507 Msg::Finish => {
508 setup.errors = setup.validate();
509 if !setup.errors.is_empty() {
510 return setup.errors.focus_first();
511 }
512 finish(setup)
513 }
514 };
515 if !setup.errors.is_empty() {
518 setup.errors = setup.validate();
519 }
520 command
521}
522
523fn start_install(setup: &mut Setup) -> Command<Msg> {
528 if matches!(setup.install, Install::Running(_)) {
529 return Command::none();
530 }
531 let EngineCheck::Broken(problem) = &setup.engine_check else { return Command::none() };
532 let remedy = Remedy::for_problem(problem, setup.kind(), &setup.host);
533 let Some(command) = remedy.runnable() else { return Command::none() };
534 match setup.installer.start(command) {
535 Ok(session) => {
536 let watch = session.watch();
537 setup.install = Install::Running(session);
538 Command::batch([Command::perform(move || Msg::InstallEvent(watch.next())), Command::focus("setup-install")])
539 }
540 Err(error) => {
543 setup.install = Install::Failed(error.to_string());
544 Command::none()
545 }
546 }
547}
548
549fn install_event(setup: &mut Setup, event: TerminalEvent) -> Command<Msg> {
554 let Install::Running(session) = &setup.install else { return Command::none() };
555 match event {
556 TerminalEvent::Output => {
557 let watch = session.watch();
558 Command::perform(move || Msg::InstallEvent(watch.next()))
559 }
560 TerminalEvent::Exited(_) => {
561 let Install::Running(session) = std::mem::replace(&mut setup.install, Install::Idle) else {
562 return Command::none();
563 };
564 setup.install = Install::Ended(session);
565 setup.engine_check = EngineCheck::Running;
566 setup.output.clear();
567 ask_engine(setup.kind())
568 }
569 }
570}
571
572fn check_place(setup: &mut Setup) -> Command<Msg> {
574 match setup.folder_path() {
575 Some(path) => {
576 let path = path.to_path_buf();
577 setup.location_check = LocationCheck::Running;
578 ask_folder(path)
579 }
580 None => {
581 setup.location_check = LocationCheck::Broken(LocationProblem::Unset);
582 Command::none()
583 }
584 }
585}
586
587fn finish(setup: &mut Setup) -> Command<Msg> {
593 let Some(path) = setup.folder_path().map(Path::to_path_buf) else {
594 return Command::none();
595 };
596 setup.config.set_engine_kind(setup.engine_name());
597 setup.config.set_folder_path(&path);
598 setup.config.set_setup_step(SetupStep::Location);
599 setup.config.set_setup_completed(true);
600 if let Err(error) = setup.config.save() {
601 setup.config.set_setup_completed(false);
602 return Command::toast(Toast::danger(t!("setup.save-failed")).body(error.to_string()));
603 }
604 setup.finished = true;
605 Command::none()
606}
607
608pub fn view(setup: &Setup, ui: &mut View<'_, Msg>) {
617 let top = ui.size().height.saturating_sub(WIZARD_ROWS + CHROME_ROWS) / 2;
618 ui.column(|ui| {
619 ui.spacer().height(Length::Cells(top));
620 pages(setup, ui);
621 })
622 .fill()
623 .align(Align::Center);
624}
625
626fn pages(setup: &Setup, ui: &mut View<'_, Msg>) {
628 let all = [t!("setup.step-language"), t!("setup.step-engine"), t!("setup.step-location")];
629 let labels: Vec<String> = if setup.alone { vec![all[setup.step.min(all.len() - 1)].clone()] } else { all.to_vec() };
632 let mut wizard = Wizard::new(labels)
633 .current(if setup.alone { 0 } else { setup.step })
634 .on_finish(Msg::Finish)
635 .busy(setup.is_busy())
636 .page_height(PAGE_ROWS);
637 if !setup.alone {
638 wizard = wizard.on_back(Msg::Back).on_next(Msg::Next).on_step(Msg::Step);
639 }
640 wizard
641 .show(ui, |ui| match setup.step() {
642 SetupStep::Language => language_page(setup, ui),
643 SetupStep::Engine => engine_page(setup, ui),
644 SetupStep::Location => location_page(setup, ui),
645 })
646 .width(Length::Cells(PAGE_WIDTH));
647}
648
649fn language_page(setup: &Setup, ui: &mut View<'_, Msg>) {
651 Form::new().label_width(LABEL_WIDTH).show(ui, |form| {
654 form.field(Field::new(t!("setup.language")), |ui| {
655 let options: Vec<String> =
656 setup.languages.codes().iter().map(|code| t!(&format!("setup.language-{code}"))).collect();
657 let group = RadioGroup::new(options).selected(Some(setup.language)).on_select(Msg::Language);
658 ui.add(group.wrap(true)).id("setup-language");
659 });
660 });
661}
662
663fn engine_page(setup: &Setup, ui: &mut View<'_, Msg>) {
665 ui.add_with(ScrollView::new(), |ui| {
668 ui.add(Text::new(t!("setup.engine-intro")).role("secondary")).fill_width();
669 ui.spacer().height(Length::Cells(1));
670 Form::new().label_width(LABEL_WIDTH).show(ui, |form| {
671 let field = Field::new(t!("setup.engine")).error(setup.errors.get("setup-engine"));
673 form.field(field, |ui| {
674 let options = ENGINES.map(|kind| t!(&format!("setup.engine-{}", kind.name())));
675 let group = RadioGroup::new(options).selected(Some(setup.engine)).on_select(Msg::Engine);
676 ui.add(group.wrap(true)).id("setup-engine");
677 });
678 });
679 ui.add(Text::new(t!(&format!("setup.{}-note", setup.kind().name()))).role("faint")).fill_width();
680 ui.spacer().height(Length::Cells(1));
681 engine_state(setup, ui);
682 })
683 .fill();
684}
685
686fn engine_state(setup: &Setup, ui: &mut View<'_, Msg>) {
688 let name = t!(&format!("setup.engine-{}", setup.kind().name()));
689 install_state(setup, ui);
692 match &setup.engine_check {
693 EngineCheck::Unknown | EngineCheck::Running => {
696 ui.add(Spinner::new().label(t!("setup.checking", engine = name)));
697 }
698 EngineCheck::Working => {
699 let mark = ui.env().icons().glyph("success").into_owned();
700 ui.add(Text::rich([
701 Span::new(format!("{mark} ")).color("success"),
702 Span::new(t!("setup.ready", engine = name)),
703 ]));
704 }
705 EngineCheck::Broken(problem) => engine_problem(setup, problem, &name, ui),
706 }
707}
708
709fn install_state(setup: &Setup, ui: &mut View<'_, Msg>) {
713 if let Install::Failed(reason) = &setup.install {
714 let mark = ui.env().icons().glyph("warning").into_owned();
715 ui.add(Text::rich([
716 Span::new(format!("{mark} ")).color("danger"),
717 Span::new(t!("setup.install-failed", reason = reason.clone())),
718 ]))
719 .fill_width();
720 ui.spacer().height(Length::Cells(1));
721 }
722 let Some(session) = setup.install.session() else { return };
723 ui.add(Text::new(t!("setup.installing")).role("faint")).fill_width();
724 ui.add(Terminal::new(session)).id("setup-install").fill_width().height(Length::Cells(INSTALL_ROWS));
725 ui.spacer().height(Length::Cells(1));
726}
727
728fn engine_problem(setup: &Setup, problem: &EngineProblem, name: &str, ui: &mut View<'_, Msg>) {
730 let mark = ui.env().icons().glyph("warning").into_owned();
731 let said = match problem {
732 EngineProblem::NotInstalled => t!("setup.not-installed", engine = name.to_owned()),
733 EngineProblem::DaemonStopped { .. } => t!("setup.daemon-stopped"),
734 EngineProblem::MachineStopped { .. } => t!("setup.machine-stopped"),
735 EngineProblem::Refused { .. } => t!("setup.refused", engine = name.to_owned()),
736 EngineProblem::NoPermission { in_group: false, .. } => t!("known.no-permission", engine = name.to_owned()),
737 EngineProblem::NoPermission { in_group: true, .. } => t!("setup.host.relogin-now", engine = name.to_owned()),
738 EngineProblem::NoIdRanges { .. } => t!("known.no-id-ranges", engine = name.to_owned()),
739 EngineProblem::NotRunnable { bin, .. } => {
740 t!("setup.unreachable", engine = name.to_owned(), path = bin.display().to_string())
741 }
742 };
743 ui.add(Text::rich([Span::new(format!("{mark} ")).color("warning"), Span::new(said)])).fill_width();
744 ui.spacer().height(Length::Cells(1));
745
746 match Remedy::for_problem(problem, setup.kind(), &setup.host) {
747 Remedy::Install { command: Some(command), relogin, .. } => {
748 install_choice(setup, &command, Choice::Install, ui);
749 if relogin {
750 ui.add(Text::new(t!("setup.host.relogin-after")).role("secondary")).fill_width();
751 }
752 }
753 Remedy::Install { command: None, docs, .. } => {
754 ui.add(Text::new(t!("setup.no-manager", url = docs.to_owned())).role("secondary")).fill_width();
755 }
756 Remedy::Start { command: Some(command) } => install_choice(setup, &command, Choice::Start, ui),
759 Remedy::Start { command: None } => {
760 ui.add(Text::new(t!("setup.start-desktop")).role("secondary")).fill_width();
761 }
762 Remedy::Grant { command, relogin } => {
763 install_choice(setup, &command, Choice::Run, ui);
764 if relogin {
765 ui.add(Text::new(t!("setup.host.relogin-after")).role("secondary")).fill_width();
766 }
767 }
768 Remedy::Relogin => {}
770 Remedy::Unknown => {}
773 }
774
775 ui.spacer().height(Length::Cells(1));
776 ui.add(Button::new(t!("setup.recheck")).on_press(Msg::Recheck)).id("setup-recheck");
777 ui.add(Text::new(t!("setup.never-installs")).role("faint")).fill_width();
779
780 if !setup.output.is_empty() {
782 ui.spacer().height(Length::Cells(1));
783 ui.add(Text::new(t!("setup.engine-said")).role("faint"));
784 ui.add(LogView::new(&setup.output)).fill_width().height(Length::Cells(OUTPUT_ROWS));
785 }
786}
787
788fn install_choice(setup: &Setup, command: &str, choice: Choice, ui: &mut View<'_, Msg>) {
793 if matches!(setup.install, Install::Running(_)) {
794 return;
795 }
796 let (here, myself, with) = match choice {
797 Choice::Install => (t!("setup.install-here"), t!("setup.install-myself"), t!("setup.install-with")),
798 Choice::Start => (t!("setup.host.start-here"), t!("setup.host.run-myself"), t!("setup.start-with")),
799 Choice::Run => (t!("setup.host.run-here"), t!("setup.host.run-myself"), t!("setup.host.run-with")),
800 };
801 ui.row(|ui| {
802 ui.add(Button::new(here).variant("primary").on_press(Msg::InstallHere)).id("setup-install-here");
803 ui.add(Button::new(myself).on_press(Msg::ShowCommand)).id("setup-install-myself");
804 ui.spacer();
805 })
806 .gap(2)
807 .fill_width();
808 if matches!(setup.install, Install::Shown) {
811 line(ui, &with, command);
812 }
813}
814
815#[derive(Debug, Clone, Copy)]
817enum Choice {
818 Install,
820 Start,
822 Run,
824}
825
826fn line(ui: &mut View<'_, Msg>, label: &str, command: &str) {
829 ui.row(|ui| {
830 ui.add(Text::new(label.to_owned()).role("faint"));
831 ui.add(CopyValue::new(command.to_owned())).id("setup-command").fill_width();
832 })
833 .gap(2)
834 .fill_width();
835}
836
837fn location_page(setup: &Setup, ui: &mut View<'_, Msg>) {
839 ui.add(Text::new(t!("setup.location-intro")).role("secondary")).fill_width();
840 ui.spacer().height(Length::Cells(1));
841 Form::new().label_width(LABEL_WIDTH).show(ui, |form| {
842 let field = Field::new(t!("setup.location")).error(setup.errors.get("setup-location"));
844 form.field(field, |ui| {
845 let default = match &setup.default_path {
846 Some(path) => format!("{} {}", t!("setup.location-default"), path.display()),
847 None => t!("setup.location-default"),
848 };
849 let options = [default, t!("setup.location-custom")];
850 let group = RadioGroup::new(options)
851 .selected(Some(setup.place))
852 .disabled(setup.default_path.is_none() && setup.place == DEFAULT_PLACE)
853 .on_select(Msg::Place);
854 ui.add(group.wrap(true)).id("setup-location");
855 });
856 });
857 if setup.default_path.is_none() {
858 ui.add(Text::new(t!("setup.no-default")).role("secondary")).fill_width();
859 }
860 ui.spacer().height(Length::Cells(1));
861 location_state(setup, ui);
864 if setup.place == CUSTOM_PLACE {
865 FilePicker::new(&setup.browser, Msg::Picker).show(ui).fill();
866 }
867}
868
869fn location_state(setup: &Setup, ui: &mut View<'_, Msg>) {
871 match &setup.location_check {
872 LocationCheck::Unknown => {}
875 LocationCheck::Running => {
876 ui.add(Spinner::new().label(t!("setup.location-checking")));
877 }
878 LocationCheck::Usable => {
879 let mark = ui.env().icons().glyph("success").into_owned();
880 ui.add(Text::rich([
881 Span::new(format!("{mark} ")).color("success"),
882 Span::new(t!("setup.location-usable")),
883 ]));
884 }
885 LocationCheck::Broken(problem) => {
886 let mark = ui.env().icons().glyph("warning").into_owned();
887 let said = match problem {
888 LocationProblem::Unset => t!("setup.location-unset"),
889 LocationProblem::NotAFolder => t!("setup.not-a-folder"),
890 LocationProblem::Blocked(reason) => t!("setup.blocked", reason = reason.clone()),
891 };
892 ui.add(Text::rich([Span::new(format!("{mark} ")).color("warning"), Span::new(said)])).fill_width();
893 }
894 }
895}
896
897#[must_use]
899pub fn hints(icons: &qframe::icons::Icons) -> Vec<(String, String)> {
900 let move_keys = format!("{}{}", icons.glyph("arrow-up"), icons.glyph("arrow-down"));
901 vec![(move_keys, t!("setup.hint-move")), (icons.glyph("enter").into_owned(), t!("setup.hint-choose"))]
902}
903
904#[cfg(test)]
905mod tests {
906 use std::path::{Path, PathBuf};
907 use std::time::Duration;
908
909 use qframe::env::{AssetDirs, Env};
910 use qframe::event::MouseKind;
911 use qframe::icons::GlyphMode;
912 use qframe::prelude::*;
913 use qframe::runtime::Harness;
914 use qframe::widgets::AppShell;
915
916 use qframe::widgets::TerminalSession;
917
918 use super::gates::{EngineCheck, EngineProblem, Gates, LocationCheck, LocationProblem};
919 use super::install::{InstallHost, Installer};
920 use super::{Install, Msg, Setup, update, view};
921 use crate::engine::EngineKind;
922 use crate::store::{Config, HostDirs, Platform, SetupStep};
923
924 const SIZE: (u16, u16) = (88, 28);
926
927 const TALL: (u16, u16) = (88, 36);
930
931 struct Wizard {
933 setup: Setup,
934 }
935
936 impl App for Wizard {
937 type Msg = Msg;
938
939 fn update(&mut self, message: Msg) -> Command<Msg> {
940 update(&mut self.setup, message)
941 }
942
943 fn view(&self, ui: &mut View<'_, Msg>) {
944 AppShell::new().body(|ui| view(&self.setup, ui)).show(ui);
945 }
946 }
947
948 fn env() -> Env {
949 Env::load(&AssetDirs { locale_sources: crate::locales(), ..AssetDirs::default() })
950 .expect("the built-in files load")
951 }
952
953 fn temporary(what: &str) -> PathBuf {
956 std::env::temp_dir().join(format!("qcode-setup-{what}-{}", std::process::id()))
957 }
958
959 fn dirs(home: Option<PathBuf>) -> HostDirs {
961 let documents = home.map(|home| home.join("Documents"));
962 HostDirs { store: documents.as_ref().map(|documents| documents.join("Quvyta").join("Code")), documents }
963 }
964
965 fn host() -> InstallHost {
968 InstallHost::read(Platform::Linux, Some("ID=arch\n"), |tool| tool == "paru")
969 }
970
971 fn host_with(have: &[&str]) -> InstallHost {
973 InstallHost::read(Platform::Linux, Some("ID=arch\n"), |tool| tool == "paru" || have.contains(&tool))
974 }
975
976 fn harmless(script: &'static str) -> Installer {
979 Installer::new(move |_| TerminalSession::spawn("/bin/sh".as_ref(), &["-c", script], Path::new("/")))
980 }
981
982 fn unstartable() -> Installer {
984 Installer::new(|_| Err(std::io::Error::other("no terminal here")))
985 }
986
987 fn gates(engine: EngineCheck, location: LocationCheck) -> Gates {
988 Gates { language: true, engine, location }
989 }
990
991 fn wizard(config: &str, gates: &Gates, home: Option<PathBuf>) -> Harness<Wizard> {
992 sized(config, gates, home, SIZE)
993 }
994
995 fn sized(config: &str, gates: &Gates, home: Option<PathBuf>, size: (u16, u16)) -> Harness<Wizard> {
996 on(config, gates, home, size, host(), Installer::shell())
997 }
998
999 fn on(
1001 config: &str,
1002 gates: &Gates,
1003 home: Option<PathBuf>,
1004 size: (u16, u16),
1005 host: InstallHost,
1006 installer: Installer,
1007 ) -> Harness<Wizard> {
1008 let setup = Setup::new(Config::parse_str("code.conf", config), &dirs(home), gates, host, installer, None);
1009 let mut harness = Harness::with_env(Wizard { setup }, env(), size.0, size.1);
1010 harness.set_locale("en").set_glyph_mode(GlyphMode::Unicode);
1011 harness.render();
1012 harness
1013 }
1014
1015 fn installing(installer: Installer) -> Harness<Wizard> {
1017 let gates = gates(EngineCheck::Broken(EngineProblem::NotInstalled), LocationCheck::Unknown);
1018 on("language = \"en\"\n", &gates, Some(temporary("home")), TALL, host(), installer)
1019 }
1020
1021 fn until(harness: &mut Harness<Wizard>, what: &str, done: impl Fn(&Harness<Wizard>) -> bool) {
1024 for _ in 0..600 {
1025 if done(harness) {
1026 return;
1027 }
1028 harness.advance(Duration::from_millis(20));
1029 }
1030 panic!("{what} never happened:\n{}", harness.screen());
1031 }
1032
1033 fn on_engine(config: &str, problem: EngineProblem) -> Harness<Wizard> {
1035 wizard(config, &gates(EngineCheck::Broken(problem), LocationCheck::Unknown), Some(temporary("home")))
1036 }
1037
1038 fn on_engine_tall(config: &str, problem: EngineProblem) -> Harness<Wizard> {
1040 sized(config, &gates(EngineCheck::Broken(problem), LocationCheck::Unknown), Some(temporary("home")), TALL)
1041 }
1042
1043 #[test]
1044 fn a_fresh_machine_opens_on_the_first_step() {
1045 let gates = Gates { language: false, engine: EngineCheck::Unknown, location: LocationCheck::Unknown };
1046 let harness = wizard("", &gates, Some(temporary("home")));
1047 let screen = harness.screen();
1048 assert_eq!(harness.app().setup.step(), SetupStep::Language);
1049 assert!(screen.contains("Chinese (Simplified)"), "{screen}");
1050 assert!(screen.contains("English"), "{screen}");
1051 }
1052
1053 #[test]
1054 fn a_gate_that_fell_later_is_where_the_wizard_opens() {
1055 let config = "language = \"en\"\n\n[setup]\nstep = \"location\"\n";
1058 let harness = on_engine(config, EngineProblem::NotInstalled);
1059 assert_eq!(harness.app().setup.step(), SetupStep::Engine);
1060 let screen = harness.screen();
1061 assert!(screen.contains("Container engine"), "{screen}");
1062 }
1063
1064 #[test]
1065 fn every_language_qcode_speaks_is_offered_whole_on_the_first_step() {
1066 let gates = Gates { language: false, engine: EngineCheck::Unknown, location: LocationCheck::Unknown };
1069 let mut catalog = qframe::i18n::I18n::builtin();
1070 for (file, text) in crate::locales() {
1071 catalog.add_source(&file, &text);
1072 }
1073 for code in Config::LANGUAGES {
1074 let mut harness = wizard("", &gates, Some(temporary("home")));
1075 harness.set_locale(code).render();
1076 catalog.set_active(code);
1077 let screen = harness.screen();
1078 assert!(!screen.contains('…'), "{code} is cut somewhere:\n{screen}");
1079 for offered in Config::LANGUAGES {
1080 let name = catalog.translate(&format!("setup.language-{offered}"), &[]);
1081 assert!(screen.contains(&name), "{code} does not show {offered} as `{name}`:\n{screen}");
1082 }
1083 }
1084 }
1085
1086 #[test]
1087 fn the_language_is_spoken_the_moment_it_is_chosen() {
1088 let gates = Gates { language: false, engine: EngineCheck::Unknown, location: LocationCheck::Unknown };
1089 let mut harness = wizard("", &gates, Some(temporary("home")));
1090 harness.click_text("Turkish").render();
1091 let screen = harness.screen();
1092 assert!(screen.contains("Çince (Basitleştirilmiş)"), "the whole screen turns at once:\n{screen}");
1093 assert_eq!(harness.app().setup.language(), "tr");
1094 }
1095
1096 #[test]
1097 fn podman_is_recommended_and_docker_is_fully_supported() {
1098 let harness = on_engine("language = \"en\"\n", EngineProblem::NotInstalled);
1099 let screen = harness.screen();
1100 assert!(screen.contains("Podman") && screen.contains("Docker"), "{screen}");
1101 assert!(screen.contains("Recommended"), "the recommendation is said, not implied:\n{screen}");
1102 let (_, podman) = harness.find("Podman").expect("podman is offered");
1103 let (_, docker) = harness.find("Docker").expect("docker is offered");
1104 assert!(podman < docker, "podman is the first and default choice:\n{screen}");
1105 assert_eq!(harness.app().setup.engine_name(), "podman");
1106 }
1107
1108 #[test]
1109 fn docker_says_what_it_is_when_it_is_chosen() {
1110 let mut harness = on_engine("language = \"en\"\n", EngineProblem::NotInstalled);
1111 harness.click_text("Docker").render();
1112 let screen = harness.screen();
1113 assert!(screen.contains("Fully supported"), "{screen}");
1114 assert_eq!(harness.app().setup.engine_name(), "docker");
1115 }
1116
1117 #[test]
1118 fn podman_is_ticked_when_it_is_the_engine_this_machine_has() {
1119 let gates = gates(EngineCheck::Broken(EngineProblem::NotInstalled), LocationCheck::Unknown);
1120 let harness = on("", &gates, Some(temporary("home")), SIZE, host_with(&["podman"]), Installer::shell());
1121 assert_eq!(harness.app().setup.engine_name(), "podman", "{}", harness.screen());
1122 }
1123
1124 #[test]
1125 fn docker_is_ticked_when_docker_is_the_only_engine_this_machine_has() {
1126 let gates = gates(EngineCheck::Broken(EngineProblem::NotInstalled), LocationCheck::Unknown);
1127 let harness = on("", &gates, Some(temporary("home")), SIZE, host_with(&["docker"]), Installer::shell());
1128 assert_eq!(harness.app().setup.engine_name(), "docker", "{}", harness.screen());
1129 assert!(
1130 harness.screen().contains("Fully supported"),
1131 "the ticked row is the one described:\n{}",
1132 harness.screen()
1133 );
1134 }
1135
1136 #[test]
1137 fn a_machine_with_neither_engine_is_offered_the_recommended_one() {
1138 let gates = gates(EngineCheck::Broken(EngineProblem::NotInstalled), LocationCheck::Unknown);
1139 let harness = on("", &gates, Some(temporary("home")), SIZE, host(), Installer::shell());
1140 assert_eq!(harness.app().setup.engine_name(), "podman", "{}", harness.screen());
1141 }
1142
1143 #[test]
1144 fn an_engine_written_down_beats_the_one_the_machine_happens_to_have() {
1145 let gates = gates(EngineCheck::Broken(EngineProblem::NotInstalled), LocationCheck::Unknown);
1147 let config = "language = \"en\"\n\n[engine]\nkind = \"docker\"\n";
1148 let harness = on(config, &gates, Some(temporary("home")), SIZE, host_with(&["podman"]), Installer::shell());
1149 assert_eq!(harness.app().setup.engine_name(), "docker", "{}", harness.screen());
1150 }
1151
1152 #[test]
1153 fn a_missing_engine_offers_both_ways_out_and_takes_neither_by_itself() {
1154 let harness = on_engine_tall("language = \"en\"\n", EngineProblem::NotInstalled);
1155 let screen = harness.screen();
1156 assert!(screen.contains("not installed"), "{screen}");
1157 assert!(screen.contains("Install it here"), "{screen}");
1158 assert!(screen.contains("Show me the command"), "{screen}");
1159 assert!(!screen.contains("paru -S podman"), "nothing is shown until it is asked for:\n{screen}");
1160 assert!(!screen.contains("running here"), "nothing is run until it is asked for:\n{screen}");
1161 assert!(screen.contains("runs the command you chose"), "the promise is said plainly:\n{screen}");
1162 assert!(screen.contains("Check again"), "{screen}");
1163 }
1164
1165 #[test]
1166 fn asking_for_the_command_shows_the_line_this_system_installs_it_with() {
1167 let mut harness = on_engine_tall("language = \"en\"\n", EngineProblem::NotInstalled);
1168 harness.click_text("Show me the command").render();
1169 let screen = harness.screen();
1170 assert!(screen.contains("Install it with"), "{screen}");
1171 assert!(screen.contains("paru -S podman"), "the command belongs to this machine:\n{screen}");
1172 assert!(!screen.contains("running here"), "showing a command runs nothing:\n{screen}");
1173 }
1174
1175 #[test]
1176 fn at_80_by_24_the_shown_command_and_the_whole_promise_under_it_fit_in_every_language() {
1177 for code in crate::store::Config::LANGUAGES {
1181 let gates = gates(EngineCheck::Broken(EngineProblem::NotInstalled), LocationCheck::Unknown);
1182 let config = format!("language = \"{code}\"\n");
1183 let started = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1186 let counted = std::sync::Arc::clone(&started);
1187 let installer = Installer::new(move |_| {
1188 counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1189 Err(std::io::Error::other("nothing is installed in a test"))
1190 });
1191 let mut harness = on(&config, &gates, Some(temporary("home")), (80, 24), host(), installer);
1192 harness.set_locale(code).render();
1193 let shown = harness.env().i18n().translate("setup.install-myself", &[]);
1194 harness.click_text(&shown).render();
1195 let screen = harness.screen();
1196 assert!(screen.contains("paru -S podman"), "{code}: the command is shown:\n{screen}");
1197 let promise = harness.env().i18n().translate("setup.never-installs", &[]);
1198 let bare = |text: &str| text.chars().filter(|c| !c.is_whitespace()).collect::<String>();
1200 assert!(bare(&screen).contains(&bare(&promise)), "{code}: the promise is whole on screen:\n{screen}");
1201 assert_eq!(started.load(std::sync::atomic::Ordering::SeqCst), 0, "{code}: nothing was started");
1202 }
1203 }
1204
1205 #[test]
1206 fn the_install_command_is_copied_with_one_click() {
1207 let mut harness = on_engine_tall("language = \"en\"\n", EngineProblem::NotInstalled);
1208 harness.click_text("Show me the command").render();
1209 harness.click_text("paru -S podman");
1210 assert_eq!(harness.copied(), ["paru -S podman"]);
1211 }
1212
1213 #[test]
1214 fn installing_here_opens_a_terminal_in_the_page_and_shows_what_the_command_printed() {
1215 let mut harness = installing(harmless("echo fetching-podman; exit 0"));
1216 harness.click_text("Install it here").render();
1217 until(&mut harness, "the command printed", |harness| harness.screen().contains("fetching-podman"));
1218 let screen = harness.screen();
1219 assert!(screen.contains("running here"), "the page says what the terminal is:\n{screen}");
1220 assert!(!screen.contains("Install it here"), "the choice is over once it is made:\n{screen}");
1221 }
1222
1223 #[test]
1224 fn the_end_of_the_installation_asks_the_engine_again_by_itself() {
1225 let mut harness = installing(harmless("echo done-here; exit 0"));
1226 harness.click_text("Install it here").render();
1227 until(&mut harness, "the command ended", |harness| matches!(harness.app().setup.install, Install::Ended(_)));
1229 assert_eq!(
1234 harness.app().setup.engine_check(),
1235 &EngineCheck::Running,
1236 "the engine was asked again by itself:\n{}",
1237 harness.screen()
1238 );
1239 until(&mut harness, "the engine answered", |harness| {
1240 harness.app().setup.engine_check() != &EngineCheck::Running
1241 });
1242 let asked = super::gates::check_engine_for_setup(EngineKind::Podman);
1243 assert_eq!(
1244 harness.app().setup.engine_check(),
1245 &asked,
1246 "and what it answered is what stands:\n{}",
1247 harness.screen()
1248 );
1249 }
1250
1251 #[test]
1252 fn an_install_command_that_cannot_start_is_said_instead_of_being_waited_for() {
1253 let mut harness = installing(unstartable());
1254 harness.click_text("Install it here").render();
1255 let screen = harness.screen();
1256 assert!(screen.contains("could not be started"), "{screen}");
1257 assert!(screen.contains("no terminal here"), "the system's own reason is kept:\n{screen}");
1258 assert!(screen.contains("Install it here"), "the way out is still there to try again:\n{screen}");
1259 assert!(!screen.contains("running here"), "nothing is running, so nothing says it is:\n{screen}");
1260 }
1261
1262 #[test]
1263 fn the_wizard_says_each_thing_once_and_briefly() {
1264 let fresh = Gates { language: false, engine: EngineCheck::Unknown, location: LocationCheck::Unknown };
1266 let harness = sized("", &fresh, Some(temporary("home")), (120, 40));
1267 let screen = harness.screen();
1268 assert!(screen.contains("Chinese (Simplified)"), "{screen}");
1269 assert!(!screen.contains("follows your choice"), "{screen}");
1270
1271 let config = "language = \"en\"\n\n[engine]\nkind = \"docker\"\n";
1273 let problem = EngineProblem::DaemonStopped { output: "cannot connect to the docker daemon".to_owned() };
1274 let harness = sized(config, &gates(EngineCheck::Broken(problem), LocationCheck::Unknown), None, (120, 40));
1275 let screen = harness.screen();
1276 assert!(screen.contains("Docker is installed; its daemon is off."), "{screen}");
1277 assert!(screen.contains("Fully supported; its daemon has to be running."), "{screen}");
1278 assert!(!screen.contains("only has to be started"), "{screen}");
1279 assert!(!screen.contains("It talks to a daemon"), "{screen}");
1280 }
1281
1282 #[test]
1283 fn a_stopped_docker_daemon_is_a_different_story_from_a_missing_docker() {
1284 let config = "language = \"en\"\n\n[engine]\nkind = \"docker\"\n";
1285 let problem = EngineProblem::DaemonStopped { output: "cannot connect to the docker daemon".to_owned() };
1286 let mut harness = on_engine_tall(config, problem);
1287 let screen = harness.screen();
1288 assert!(screen.contains("Docker is installed"), "{screen}");
1289 assert!(!screen.contains("not installed"), "nothing is missing, so nothing says so:\n{screen}");
1290 assert!(screen.contains("Start it here"), "{screen}");
1291 harness.click_text("I'll run it myself").render();
1292 assert!(harness.screen().contains("sudo systemctl enable --now docker"), "{}", harness.screen());
1294 }
1295
1296 #[test]
1297 fn a_podman_machine_that_is_not_running_asks_for_the_machine_and_not_an_install() {
1298 let problem = EngineProblem::MachineStopped { output: "podman machine start".to_owned() };
1299 let mut harness = on_engine_tall("language = \"en\"\n", problem);
1300 let screen = harness.screen();
1301 assert!(screen.contains("Podman is installed; its machine is off."), "{screen}");
1302 harness.click_text("I'll run it myself").render();
1303 assert!(harness.screen().contains("podman machine start"), "{}", harness.screen());
1304 }
1305
1306 #[test]
1307 fn a_refusal_qcode_cannot_read_shows_the_engines_own_words() {
1308 let problem = EngineProblem::Refused { code: Some(125), output: "short-name resolution enforced".to_owned() };
1309 let harness = on_engine_tall("language = \"en\"\n", problem);
1310 let screen = harness.screen();
1311 assert!(screen.contains("refused to answer"), "{screen}");
1312 assert!(screen.contains("short-name resolution enforced"), "the engine's own words are readable:\n{screen}");
1313 }
1314
1315 #[test]
1316 fn while_the_engine_is_being_asked_the_screen_says_so_and_the_wizard_waits() {
1317 let harness = wizard(
1318 "language = \"en\"\n",
1319 &gates(EngineCheck::Running, LocationCheck::Unknown),
1320 Some(temporary("home")),
1321 );
1322 let screen = harness.screen();
1323 assert!(screen.contains("Asking Podman whether it works"), "{screen}");
1324 }
1325
1326 #[test]
1327 fn asking_again_puts_the_question_out_again() {
1328 let mut setup = Setup::new(
1330 Config::parse_str("code.conf", "language = \"en\"\n"),
1331 &dirs(Some(temporary("home"))),
1332 &gates(EngineCheck::Broken(EngineProblem::NotInstalled), LocationCheck::Unknown),
1333 host(),
1334 Installer::shell(),
1335 None,
1336 );
1337 let _: Command<Msg> = update(&mut setup, Msg::Recheck);
1338 assert_eq!(setup.engine_check(), &EngineCheck::Running);
1339 }
1340
1341 #[test]
1342 fn the_wizard_does_not_go_on_while_the_engine_does_not_work() {
1343 let mut harness = on_engine("language = \"en\"\n", EngineProblem::NotInstalled);
1344 harness.click_text("Next").render();
1345 let screen = harness.screen();
1346 assert_eq!(harness.app().setup.step(), SetupStep::Engine, "{screen}");
1347 assert!(screen.contains("Pick an engine that works"), "{screen}");
1348 }
1349
1350 #[test]
1351 fn the_location_step_offers_the_default_place_and_one_of_your_own() {
1352 let home = temporary("home");
1353 let harness =
1354 wizard("language = \"en\"\n", &gates(EngineCheck::Working, LocationCheck::Usable), Some(home.clone()));
1355 assert_eq!(harness.app().setup.step(), SetupStep::Location);
1356 let screen = harness.screen();
1357 assert!(screen.contains("Default place") && screen.contains("Somewhere else"), "{screen}");
1358 assert!(screen.contains("QCode"), "the default place is named, not implied:\n{screen}");
1359 assert_eq!(
1360 harness.app().setup.folder_path(),
1361 Some(home.join("Documents").join("Quvyta").join("Code")).as_deref()
1362 );
1363 }
1364
1365 #[test]
1366 fn a_machine_with_no_documents_folder_asks_for_a_place_instead_of_inventing_one() {
1367 let harness = wizard("language = \"en\"\n", &gates(EngineCheck::Working, LocationCheck::Usable), None);
1368 let screen = harness.screen();
1369 assert!(screen.contains("No documents folder found"), "{screen}");
1370 assert_eq!(harness.app().setup.folder_path(), None);
1371 }
1372
1373 #[test]
1374 fn a_place_of_your_own_is_browsed_for() {
1375 let mut harness =
1376 wizard("language = \"en\"\n", &gates(EngineCheck::Working, LocationCheck::Usable), Some(temporary("home")));
1377 harness.click_text("Somewhere else").render();
1378 let screen = harness.screen();
1379 assert!(screen.contains("Choose"), "the folder picker is on screen:\n{screen}");
1380 }
1381
1382 #[test]
1383 fn a_folder_that_cannot_be_used_says_why_and_holds_the_wizard() {
1384 let problem = LocationProblem::Blocked("permission denied".to_owned());
1385 let mut harness = wizard(
1386 "language = \"en\"\n",
1387 &gates(EngineCheck::Working, LocationCheck::Broken(problem)),
1388 Some(temporary("home")),
1389 );
1390 let screen = harness.screen();
1391 assert!(screen.contains("permission denied"), "the system's own reason is shown:\n{screen}");
1392 harness.click_text("Finish").render();
1393 assert!(!harness.app().setup.is_finished(), "a folder that cannot be used finishes nothing");
1394 assert!(harness.screen().contains("Pick a folder QCode can write in"), "{}", harness.screen());
1395 }
1396
1397 #[test]
1398 fn finishing_writes_the_setup_down() {
1399 let home = temporary("finish-home");
1400 let mut harness =
1401 wizard("language = \"en\"\n", &gates(EngineCheck::Working, LocationCheck::Usable), Some(home.clone()));
1402 harness.click_text("Finish").render();
1403 let setup = &harness.app().setup;
1404 assert!(setup.is_finished(), "{}", harness.screen());
1405 let config = setup.config();
1406 assert!(config.setup_completed(), "the wizard never opens again");
1407 assert_eq!(config.engine_kind(), Some("podman"));
1408 assert_eq!(config.folder_path(), Some(home.join("Documents").join("Quvyta").join("Code")));
1409 }
1410
1411 #[test]
1412 fn the_keyboard_walks_the_wizard_from_end_to_end() {
1413 let mut harness = on_engine("language = \"en\"\n", EngineProblem::NotInstalled);
1414 harness.press("tab");
1417 assert!(harness.is_focused("wizard-steps"), "{}", harness.screen());
1418 let reached = (0..3).any(|_| {
1419 harness.press("tab");
1420 harness.is_focused("setup-engine")
1421 });
1422 assert!(reached, "the choice is a few stops away, never out of reach:\n{}", harness.screen());
1423 harness.press("down").render();
1424 assert_eq!(harness.app().setup.engine_name(), "docker", "{}", harness.screen());
1425 harness.press("up").render();
1426 assert_eq!(harness.app().setup.engine_name(), "podman");
1427 }
1428
1429 #[test]
1430 fn while_an_answer_is_awaited_the_wizard_cannot_be_hurried() {
1431 let mut harness = wizard(
1432 "language = \"en\"\n",
1433 &gates(EngineCheck::Running, LocationCheck::Unknown),
1434 Some(temporary("home")),
1435 );
1436 harness.click_text("Next").render();
1437 assert_eq!(harness.app().setup.step(), SetupStep::Engine, "{}", harness.screen());
1438 }
1439
1440 #[test]
1441 fn going_back_returns_to_a_step_with_its_answer_still_on_it() {
1442 let mut harness =
1443 wizard("language = \"en\"\n", &gates(EngineCheck::Working, LocationCheck::Usable), Some(temporary("home")));
1444 harness.click_text("Back").render();
1445 assert_eq!(harness.app().setup.step(), SetupStep::Engine);
1446 assert!(harness.screen().contains("It is ready"), "{}", harness.screen());
1447 }
1448
1449 #[test]
1450 fn the_pointer_lights_the_option_it_is_over() {
1451 let mut harness = on_engine("language = \"en\"\n", EngineProblem::NotInstalled);
1452 let (x, y) = harness.find("Docker").expect("docker is offered");
1453 let (x, y) = (u16::try_from(x).expect("on screen"), u16::try_from(y).expect("on screen"));
1454 let row = |harness: &Harness<Wizard>| {
1455 (0..SIZE.0).map(|cell| (harness.fg(cell, y), harness.bg(cell, y))).collect::<Vec<_>>()
1456 };
1457 let quiet = row(&harness);
1458 harness.hover(i32::from(x), i32::from(y)).render();
1459 assert_ne!(row(&harness), quiet, "the row under the pointer answers:\n{}", harness.screen());
1460 }
1461
1462 #[test]
1463 fn a_narrow_screen_keeps_the_wizard_usable() {
1464 let setup = Setup::new(
1465 Config::parse_str("code.conf", "language = \"en\"\n"),
1466 &dirs(Some(temporary("home"))),
1467 &gates(EngineCheck::Broken(EngineProblem::NotInstalled), LocationCheck::Unknown),
1468 host(),
1469 Installer::shell(),
1470 None,
1471 );
1472 let mut harness = Harness::with_env(Wizard { setup }, env(), 34, 20);
1473 harness.set_locale("en").set_glyph_mode(GlyphMode::Unicode).render();
1474 let screen = harness.screen();
1475 assert!(screen.contains("Podman"), "the choice survives a narrow terminal:\n{screen}");
1476 assert!(screen.contains("Next"), "the way on is never pushed off the screen:\n{screen}");
1477 assert!(screen.contains("Install it here"), "the way out is never below the fold:\n{screen}");
1478 assert!(!screen.contains("Check again"), "there is no room for the rest of it yet:\n{screen}");
1479 for _ in 0..4 {
1480 harness.mouse(MouseKind::ScrollDown, 10, 8);
1481 }
1482 let screen = harness.screen();
1483 assert!(screen.contains("Check again"), "what is below the fold is scrolled to:\n{screen}");
1484 assert!(screen.contains("Next"), "the buttons stay where they were:\n{screen}");
1485 }
1486
1487 #[test]
1488 fn ascii_mode_draws_nothing_but_ascii() {
1489 let mut harness = on_engine_tall("language = \"en\"\n", EngineProblem::NotInstalled);
1490 harness.set_glyph_mode(GlyphMode::Ascii).click_text("Show me the command").render();
1491 let screen = harness.screen();
1492 assert!(screen.is_ascii(), "{screen}");
1493 assert!(screen.contains("paru -S podman"), "{screen}");
1494 }
1495
1496 #[test]
1497 fn every_choice_carries_the_small_square_and_the_chosen_one_its_tone() {
1498 let mut harness = on_engine("language = \"en\"\n", EngineProblem::NotInstalled);
1499 harness.set_reduced_motion(true).render();
1500 let square = harness.env().icons().glyph("radio-mark-small").into_owned();
1501 let screen = harness.screen();
1502 let mark = |label: &str| {
1505 let (y, line) = screen
1506 .lines()
1507 .enumerate()
1508 .find(|(_, line)| line.contains(&format!("{square} {label}")))
1509 .unwrap_or_else(|| panic!("`{label}` is offered with the small square:\n{screen}"));
1510 let x = line[..line.find(label).expect("the label is on the row")].chars().count();
1511 let (x, y) = (u16::try_from(x).expect("on screen"), u16::try_from(y).expect("on screen"));
1512 harness.fg(x - 4, y)
1513 };
1514 let (chosen, other) = (mark("Podman"), mark("Docker"));
1515 assert_ne!(chosen, other, "the chosen square is told apart by its tone:\n{screen}");
1516 }
1517
1518 #[test]
1519 fn nothing_is_bracketed_lined_or_framed() {
1520 let mut harness = on_engine("language = \"en\"\n", EngineProblem::NotInstalled);
1521 for mode in [GlyphMode::Nerd, GlyphMode::Unicode, GlyphMode::Ascii] {
1522 harness.set_glyph_mode(mode).render();
1523 let screen = harness.screen();
1524 for forbidden in ['[', ']', '(', ')', '{', '}', '|', '┌', '─', '│', '+'] {
1525 assert!(!screen.contains(forbidden), "`{forbidden}` in {mode:?}:\n{screen}");
1526 }
1527 assert!(!screen.contains("=="), "{screen}");
1528 assert!(!screen.contains("->"), "{screen}");
1529 }
1530 }
1531
1532 #[test]
1533 fn reduced_motion_keeps_the_wizard_working() {
1534 let mut harness = on_engine("language = \"en\"\n", EngineProblem::NotInstalled);
1535 harness.set_reduced_motion(true).click_text("Docker").render();
1536 assert_eq!(harness.app().setup.engine_name(), "docker", "{}", harness.screen());
1537 }
1538
1539 #[test]
1540 fn turkish_reads_as_turkish() {
1541 let mut harness = on_engine_tall("language = \"tr\"\n", EngineProblem::NotInstalled);
1542 harness.set_locale("tr").render();
1543 let screen = harness.screen();
1544 for text in ["Kapsayıcı motoru", "kurulu değil", "Burada kur", "Komutu göster", "yetkisini yükseltmez"] {
1545 assert!(screen.contains(text), "`{text}` is missing:\n{screen}");
1546 }
1547 }
1548
1549 fn recording(seen: std::sync::Arc<std::sync::Mutex<Vec<String>>>) -> Installer {
1552 Installer::new(move |command| {
1553 seen.lock().expect("the list").push(command.to_owned());
1554 TerminalSession::spawn("/bin/sh".as_ref(), &["-c", "echo done-here"], Path::new("/"))
1555 })
1556 }
1557
1558 fn recorded(
1561 config: &str,
1562 problem: EngineProblem,
1563 ) -> (Harness<Wizard>, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
1564 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1565 let gates = gates(EngineCheck::Broken(problem), LocationCheck::Unknown);
1566 let harness = on(config, &gates, Some(temporary("home")), TALL, host(), recording(seen.clone()));
1567 (harness, seen)
1568 }
1569
1570 const DOCKER: &str = "language = \"en\"\n\n[engine]\nkind = \"docker\"\n";
1571
1572 #[test]
1573 fn installing_docker_here_also_starts_its_service_and_lets_the_account_in() {
1574 let (mut harness, seen) = recorded(DOCKER, EngineProblem::NotInstalled);
1575 let screen = harness.screen();
1576 assert!(screen.contains("log out and back in"), "what is left after it is said before:\n{screen}");
1577 harness.click_text("Install it here").render();
1578 assert_eq!(
1579 *seen.lock().expect("the list"),
1580 ["paru -S docker && sudo systemctl enable --now docker && sudo usermod -aG docker $USER"]
1581 );
1582 }
1583
1584 #[test]
1585 fn a_stopped_docker_is_started_here_through_the_same_terminal() {
1586 let (mut harness, seen) = recorded(DOCKER, EngineProblem::DaemonStopped { output: String::new() });
1587 harness.click_text("Start it here").render();
1588 assert_eq!(*seen.lock().expect("the list"), ["sudo systemctl enable --now docker"]);
1589 }
1590
1591 #[test]
1592 fn an_account_outside_the_docker_group_is_added_to_it_here() {
1593 let problem = EngineProblem::NoPermission { output: "permission denied".to_owned(), in_group: false };
1594 let (mut harness, seen) = recorded(DOCKER, problem);
1595 let screen = harness.screen();
1596 assert!(screen.contains("Your account may not use Docker yet"), "{screen}");
1597 harness.click_text("Run it here").render();
1598 assert_eq!(*seen.lock().expect("the list"), ["sudo usermod -aG docker $USER"]);
1599 }
1600
1601 #[test]
1602 fn an_account_already_in_the_group_is_only_asked_to_log_in_again() {
1603 let problem = EngineProblem::NoPermission { output: "permission denied".to_owned(), in_group: true };
1604 let (harness, seen) = recorded(DOCKER, problem);
1605 let screen = harness.screen();
1606 assert!(screen.contains("this login began before it"), "{screen}");
1607 assert!(!screen.contains("Run it here"), "no command logs anyone out:\n{screen}");
1608 assert!(seen.lock().expect("the list").is_empty());
1609 }
1610
1611 #[test]
1612 fn podman_without_id_ranges_offers_the_line_that_adds_them_and_runs_it_here() {
1613 let line =
1614 "sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER && podman system migrate";
1615 let problem = EngineProblem::NoIdRanges { line: line.to_owned(), output: String::new() };
1616 let (mut harness, seen) = recorded("language = \"en\"\n", problem);
1617 let screen = harness.screen();
1618 assert!(screen.contains("no user id ranges for your account"), "{screen}");
1619 harness.click_text("I'll run it myself").render();
1620 assert!(harness.screen().contains("--add-subuids 100000-165535"), "the line is shown:\n{}", harness.screen());
1621 harness.click_text("Run it here").render();
1622 assert_eq!(*seen.lock().expect("the list"), [line]);
1623 }
1624}