1pub mod bar;
15pub mod engine;
16pub mod identity;
17
18use std::path::PathBuf;
19
20use qframe::diagnostics::{Diagnostic, Severity};
21use qframe::icons::IconMode;
22use qframe::prelude::*;
23use qframe::storage::Family;
24use qframe::widgets::{ScrollView, Segmented, Select, SettingRow, SettingsList, SettingsRows, Spinner, Switch, Toast};
25
26use crate::backup::BackupEvery;
27use crate::base::apps::{Editor, Sound};
28use crate::engine::EngineKind;
29use crate::profile::SafeName;
30use crate::store::{Config, OnClose, Platform};
31use crate::ui::page;
32
33use engine::{EngineState, Gate, Health};
34use identity::ProfileIdentity;
35
36const CONTROL_WIDTH: u16 = 25;
40
41const BLOCK_GAP: u16 = 1;
43
44const LEAD: u16 = 2;
47
48const ENGINES: [EngineKind; 2] = [EngineKind::Podman, EngineKind::Docker];
50
51#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum Request {
59 Language(String),
61 Theme(String),
63 Icons(IconMode),
65 ReducedMotion(bool),
67 Engine(EngineKind),
69 Editor(Editor),
71 Sound(Sound),
73 AskFirst(bool),
76 FreezeIdle(bool),
79 OpenEngineStep,
81 OpenLocationStep,
83 RefreshIdentity(SafeName),
85 SignOut(SafeName),
87 OnClose(OnClose),
89 BackupEvery(BackupEvery),
91 UpdateNotice(bool),
93 InstallService,
95 RemoveService,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum ServiceRow {
102 Unsupported,
105 Ready {
107 installed: bool,
109 busy: bool,
111 },
112}
113
114impl ServiceRow {
115 #[must_use]
119 pub fn of(platform: Platform, installed: Option<bool>) -> Option<Self> {
120 match (platform, installed) {
121 (Platform::Windows, _) => Some(Self::Unsupported),
122 (_, None) => None,
123 (_, Some(installed)) => Some(Self::Ready { installed, busy: false }),
124 }
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum Msg {
131 Checked(Health),
133 Profiles(Vec<ProfileIdentity>),
135 Language(String),
137 Theme(String),
139 Icons(IconMode),
141 ReduceMotion(bool),
143 UpdateNotice(bool),
145 Engine(EngineKind),
147 Editor(Editor),
149 Sound(Sound),
151 AskFirst(bool),
153 FreezeIdle(bool),
155 Profile(usize),
157 AskRefresh,
159 AskSignOut,
161 Request(Request),
163 ReadRepairs,
165 ReadLeftBehind,
167 Stored(Result<(), String>),
169 OnClose(OnClose),
171 BackupEvery(BackupEvery),
173 ServiceDone {
176 installing: bool,
178 installed: bool,
180 result: Result<(), String>,
182 },
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct Settings {
188 engine: EngineState,
189 editor: Editor,
190 sound: Sound,
191 ask_first: bool,
192 freeze_idle: bool,
193 store: Option<PathBuf>,
194 repairs: Vec<Diagnostic>,
195 repairs_read: bool,
196 left_behind: Vec<Diagnostic>,
197 profiles: Option<Vec<ProfileIdentity>>,
198 chosen: usize,
199 failure: Option<String>,
200 on_close: OnClose,
201 backup_every: BackupEvery,
202 service: Option<ServiceRow>,
203 update_notice: Option<bool>,
205}
206
207impl Settings {
208 #[must_use]
211 pub fn new(config: &Config, engine: EngineState) -> Self {
212 Self {
213 engine,
214 editor: config.editor(),
215 sound: config.sound(),
216 ask_first: config.ask_first(),
217 freeze_idle: config.freeze_idle(),
218 store: config.folder_path(),
219 repairs: config.diagnostics().to_vec(),
220 repairs_read: false,
221 left_behind: Vec::new(),
222 profiles: None,
223 chosen: 0,
224 failure: None,
225 on_close: config.on_close(),
226 backup_every: config.backup_every(),
227 service: None,
228 update_notice: None,
229 }
230 }
231
232 #[must_use]
235 pub fn with_update_notice(mut self, on: Option<bool>) -> Self {
236 self.update_notice = on;
237 self
238 }
239
240 #[must_use]
242 pub fn update_notice(&self) -> Option<bool> {
243 self.update_notice
244 }
245
246 #[must_use]
248 pub fn with_service(mut self, row: Option<ServiceRow>) -> Self {
249 self.service = row;
250 self
251 }
252
253 #[must_use]
255 pub fn service(&self) -> Option<ServiceRow> {
256 self.service
257 }
258
259 #[must_use]
266 pub fn with_left_behind(mut self, files: Vec<Diagnostic>) -> Self {
267 self.left_behind.extend(files);
268 self
269 }
270
271 #[must_use]
274 pub fn left_behind(&self) -> &[Diagnostic] {
275 &self.left_behind
276 }
277
278 #[must_use]
280 pub fn engine(&self) -> &EngineState {
281 &self.engine
282 }
283
284 #[must_use]
288 pub fn gate(&self) -> Gate {
289 Gate::of(&self.engine)
290 }
291
292 #[must_use]
294 pub fn chosen_profile(&self) -> Option<&ProfileIdentity> {
295 self.profiles.as_ref()?.get(self.chosen)
296 }
297}
298
299#[must_use]
306pub fn motion_note(forced: bool, reduced: bool) -> Option<String> {
307 forced.then(|| if reduced { t!("settings.forced-on") } else { t!("settings.forced-off") })
308}
309
310pub fn update(screen: &mut Settings, message: Msg) -> (Command<Msg>, Option<Request>) {
313 match message {
314 Msg::Checked(health) => {
315 screen.engine = EngineState::new(screen.engine.kind(), health);
316 (Command::none(), None)
317 }
318 Msg::Profiles(profiles) => {
319 screen.chosen = screen.chosen.min(profiles.len().saturating_sub(1));
320 screen.profiles = Some(profiles);
321 (Command::none(), None)
322 }
323 Msg::Language(code) => (Command::set_locale(code.clone()), Some(Request::Language(code))),
324 Msg::Theme(id) => (Command::set_theme(id.clone()), Some(Request::Theme(id))),
325 Msg::Icons(mode) => (Command::set_icon_mode(mode), Some(Request::Icons(mode))),
326 Msg::ReduceMotion(reduced) => (Command::set_reduced_motion(reduced), Some(Request::ReducedMotion(reduced))),
327 Msg::UpdateNotice(on) => {
328 if screen.update_notice.is_none() {
329 return (Command::none(), None);
330 }
331 screen.update_notice = Some(on);
332 (Command::none(), Some(Request::UpdateNotice(on)))
333 }
334 Msg::Engine(kind) => {
335 screen.engine = EngineState::new(kind, Health::Checking);
337 (Command::none(), Some(Request::Engine(kind)))
338 }
339 Msg::Editor(editor) => {
340 screen.editor = editor;
341 (Command::none(), Some(Request::Editor(editor)))
342 }
343 Msg::Sound(sound) => {
344 screen.sound = sound;
345 (Command::none(), Some(Request::Sound(sound)))
346 }
347 Msg::AskFirst(ask) => {
348 screen.ask_first = ask;
349 (Command::none(), Some(Request::AskFirst(ask)))
350 }
351 Msg::FreezeIdle(freeze) => {
352 screen.freeze_idle = freeze;
353 (Command::none(), Some(Request::FreezeIdle(freeze)))
354 }
355 Msg::Profile(index) => {
356 if index < screen.profiles.as_ref().map_or(0, Vec::len) {
357 screen.chosen = index;
358 }
359 (Command::none(), None)
360 }
361 Msg::AskRefresh => (ask(screen, false), None),
362 Msg::AskSignOut => (ask(screen, true), None),
363 Msg::Request(request) => {
364 if matches!(request, Request::InstallService | Request::RemoveService) {
365 match &mut screen.service {
366 Some(ServiceRow::Ready { busy: busy @ false, .. }) => *busy = true,
368 _ => return (Command::none(), None),
369 }
370 }
371 (Command::none(), Some(request))
372 }
373 Msg::OnClose(choice) => {
374 screen.on_close = choice;
375 (Command::none(), Some(Request::OnClose(choice)))
376 }
377 Msg::BackupEvery(choice) => {
378 screen.backup_every = choice;
379 (Command::none(), Some(Request::BackupEvery(choice)))
380 }
381 Msg::ServiceDone { installing, installed, result } => {
382 if let Some(ServiceRow::Ready { .. }) = screen.service {
383 screen.service = Some(ServiceRow::Ready { installed, busy: false });
384 }
385 let told = match result {
386 Ok(()) => Command::none(),
387 Err(reason) => {
388 let title = if installing {
389 t!("settings.service-install-failed")
390 } else {
391 t!("settings.service-remove-failed")
392 };
393 Command::toast(Toast::danger(title).body(reason))
394 }
395 };
396 (told, None)
397 }
398 Msg::ReadRepairs => {
399 screen.repairs_read = true;
400 (Command::focus(entry(screen)), None)
401 }
402 Msg::ReadLeftBehind => {
403 screen.left_behind.clear();
404 (Command::focus(entry(screen)), None)
405 }
406 Msg::Stored(stored) => {
407 screen.failure = stored.err();
408 (Command::none(), None)
409 }
410 }
411}
412
413fn ask(screen: &Settings, sign_out: bool) -> Command<Msg> {
416 let Some(profile) = screen.chosen_profile() else {
417 return Command::none();
418 };
419 let name = profile.name().clone();
420 let shown = name.as_str().to_owned();
421 let question = if sign_out {
422 Confirm::new(t!("settings.sign-out-title", profile = shown.clone()), Msg::Request(Request::SignOut(name)))
423 .message(t!("settings.sign-out-text", profile = shown))
424 .confirm_label(t!("settings.sign-out-do"))
425 .danger()
426 } else {
427 let request = Request::RefreshIdentity(name);
428 Confirm::new(t!("settings.refresh-title", profile = shown.clone()), Msg::Request(request))
429 .message(t!("settings.refresh-text", profile = shown))
430 .confirm_label(t!("settings.refresh-do"))
431 };
432 Command::confirm(question)
433}
434
435pub fn view(screen: &Settings, ui: &mut View<'_, Msg>) {
437 let languages = ui.env().i18n().list();
440 let language = ui.env().i18n().active().to_owned();
441 let themes = ui.env().themes();
442 let theme = ui.env().theme().id().to_owned();
443 let icons = ui.env().icon_mode();
444 let reduced = ui.env().reduced_motion();
445 let forced = ui.env().reduced_motion_forced();
446 let gate = screen.gate();
447
448 let width = page::width(page::WIDTH, ui.size().width);
449 ui.column(|ui| {
452 ui.add_with(ScrollView::new(), |ui| {
453 ui.row(|ui| {
454 ui.column(|ui| {
455 report(&Report::LEFT_BEHIND, &screen.left_behind, ui);
456 if !screen.repairs_read {
457 report(&Report::REPAIRS, &screen.repairs, ui);
458 }
459 if let Some(reason) = &screen.failure {
460 ui.add(Text::new(t!("settings.store-failed", reason = reason.clone())).color("danger"))
461 .fill_width();
462 }
463
464 let list = SettingsList::show(ui, |list| {
465 list.wrap(true);
467 list.heading(t!("settings.appearance"));
468
469 let codes: Vec<String> = languages.iter().map(|(code, _)| code.clone()).collect();
470 let names: Vec<String> = languages.iter().map(|(_, name)| name.clone()).collect();
471 let chosen = codes.iter().position(|code| *code == language);
472 list.row(SettingRow::new(t!("settings.language")), |ui| {
473 ui.add(
474 Select::new(names)
475 .selected(chosen)
476 .on_select(move |index| Msg::Language(codes[index].clone())),
477 )
478 .id("language")
479 .width(Length::Cells(CONTROL_WIDTH));
480 });
481
482 let ids: Vec<String> = themes.iter().map(|(id, _)| id.clone()).collect();
483 let titles: Vec<String> = themes.iter().map(|(_, name)| name.clone()).collect();
484 let chosen = ids.iter().position(|id| *id == theme);
485 list.row(SettingRow::new(t!("settings.theme")), |ui| {
486 ui.add(
487 Select::new(titles)
488 .selected(chosen)
489 .on_select(move |index| Msg::Theme(ids[index].clone())),
490 )
491 .id("theme")
492 .width(Length::Cells(CONTROL_WIDTH));
493 });
494
495 let modes = IconMode::ALL.map(|mode| t!(&format!("settings.icons-{}", mode.name())));
496 let chosen = IconMode::ALL.iter().position(|mode| *mode == icons);
497 list.row(SettingRow::new(t!("settings.icons")), |ui| {
498 ui.add(
499 Select::new(modes)
500 .selected(chosen)
501 .on_select(move |index| Msg::Icons(IconMode::ALL[index])),
502 )
503 .id("icons")
504 .width(Length::Cells(CONTROL_WIDTH));
505 });
506
507 let note = motion_note(forced, reduced).unwrap_or_else(|| t!("settings.reduce-motion-text"));
508 list.row(
509 SettingRow::new(t!("settings.reduce-motion")).description(note).disabled(forced),
510 |ui| {
511 ui.add(Switch::new(reduced).disabled(forced).on_toggle(Msg::ReduceMotion));
512 },
513 );
514
515 if let Some(on) = screen.update_notice {
518 let about = t!("quvyta.appearance.updates-text", family = Family::QUVYTA.title());
519 list.row(SettingRow::new(t!("quvyta.appearance.updates")).description(about), |ui| {
520 ui.add(Switch::new(on).on_toggle(Msg::UpdateNotice)).id("update-notice");
521 });
522 }
523
524 list.heading(t!("settings.containers"));
525 let row = SettingRow::new(t!("settings.engine")).description(screen.engine.summary());
526 list.row(row, |ui| match screen.engine.health() {
527 Health::Checking => {
528 ui.add(Spinner::new());
529 }
530 Health::Working | Health::Missing(_) => {
531 let names = ENGINES.map(engine::name);
532 let chosen = ENGINES.iter().position(|kind| *kind == screen.engine.kind()).unwrap_or(0);
533 let choice = Segmented::new(names)
534 .selected(chosen)
535 .on_select(move |index| Msg::Engine(ENGINES[index]));
536 ui.add(choice.wrap(true)).id("engine");
537 }
538 });
539
540 let choices = OnClose::ALL.map(|choice| t!(&format!("settings.on-close-{}", choice.key())));
541 let chosen = OnClose::ALL.iter().position(|choice| *choice == screen.on_close).unwrap_or(0);
542 let about = if screen.service == Some(ServiceRow::Unsupported) {
545 t!("settings.on-close-windows")
546 } else {
547 t!("settings.on-close-text")
548 };
549 list.row(SettingRow::new(t!("settings.on-close")).description(about), |ui| {
550 let choice = Segmented::new(choices)
551 .selected(chosen)
552 .on_select(|index| Msg::OnClose(OnClose::ALL[index]));
553 ui.add(choice.wrap(true)).id("on-close");
554 });
555 if let Some(row) = screen.service {
556 service_row(list, row);
557 }
558 let row =
562 SettingRow::new(t!("settings.freeze-idle")).description(t!("settings.freeze-idle-text"));
563 list.row(row, |ui| {
564 ui.add(Switch::new(screen.freeze_idle).on_toggle(Msg::FreezeIdle)).id("freeze-idle");
565 });
566
567 list.heading(t!("settings.backup"));
568 let choices =
569 BackupEvery::ALL.map(|choice| t!(&format!("settings.backup-every-{}", choice.key())));
570 let chosen = BackupEvery::ALL.iter().position(|choice| *choice == screen.backup_every);
571 let row =
572 SettingRow::new(t!("settings.backup-every")).description(t!("settings.backup-every-text"));
573 list.row(row, |ui| {
574 let at = chosen.unwrap_or(0);
575 let choice = Segmented::new(choices)
576 .selected(at)
577 .on_select(|index| Msg::BackupEvery(BackupEvery::ALL[index]));
578 ui.add(choice.wrap(true)).id("backup-every");
579 });
580
581 list.heading(t!("settings.apps"));
582 let names = Editor::ALL.map(Editor::key);
585 let chosen = Editor::ALL.iter().position(|editor| *editor == screen.editor).unwrap_or(0);
586 list.row(
587 SettingRow::new(t!("settings.editor")).description(t!("settings.editor-text")),
588 |ui| {
589 let choice = Segmented::new(names)
590 .selected(chosen)
591 .on_select(move |index| Msg::Editor(Editor::ALL[index]));
592 ui.add(choice.wrap(true)).id("editor");
593 },
594 );
595 let choices = Sound::ALL.map(|choice| t!(&format!("settings.sound-{}", choice.key())));
596 let chosen = Sound::ALL.iter().position(|sound| *sound == screen.sound).unwrap_or(0);
597 list.row(SettingRow::new(t!("settings.sound")).description(t!("settings.sound-text")), |ui| {
598 let choice = Segmented::new(choices)
599 .selected(chosen)
600 .on_select(|index| Msg::Sound(Sound::ALL[index]));
601 ui.add(choice.wrap(true)).id("sound");
602 });
603
604 list.heading(t!("settings.folder"));
605 let folder = screen
606 .store
607 .as_ref()
608 .map_or_else(|| t!("settings.folder-unset"), |path| path.display().to_string());
609 let row = SettingRow::new(t!("settings.folder-path"))
610 .description(folder)
611 .on_activate(Msg::Request(Request::OpenLocationStep));
612 list.row(row, |ui| {
613 ui.add(Text::new(t!("settings.folder-change")).role("secondary"));
614 });
615
616 list.heading(t!("settings.bridge"));
617 let row =
618 SettingRow::new(t!("settings.bridge-ask")).description(t!("settings.bridge-ask-text"));
619 list.row(row, |ui| {
620 ui.add(Switch::new(screen.ask_first).on_toggle(Msg::AskFirst)).id("ask-first");
621 });
622 });
623 list.id("settings");
624 ui.column(|ui| {
627 if let Health::Missing(trouble) = screen.engine.health() {
630 let kind = screen.engine.kind();
631 match trouble.said().and_then(|said| engine::help(kind, said)) {
634 Some(help) => help.show(ui),
635 None => {
636 ui.add(Text::new(trouble.remedy(kind)).role("secondary")).fill_width();
637 }
638 }
639 if let Some(said) = trouble.said() {
640 let words = t!("settings.engine-said", engine = engine::name(kind), output = said);
641 ui.add(Text::new(words).role("faint")).fill_width();
642 }
643 }
644 if screen.profiles.as_ref().is_none_or(|profiles| !profiles.is_empty()) {
648 ui.spacer().height(Length::Cells(BLOCK_GAP));
649 ui.add(Text::new(t!("settings.logins.title")).role("secondary").bold());
650 ui.add(Text::new(t!("settings.logins.text")).role("secondary")).fill_width();
651 identity::view(screen.profiles.as_deref(), screen.chosen, &gate, ui);
652 }
653 })
654 .fill_width()
655 .padding(Padding { left: LEAD, ..Padding::default() });
656 })
657 .width(Length::Cells(width))
658 .gap(BLOCK_GAP)
659 .id("settings-page");
660 })
661 .fill_width()
662 .justify(Align::Center);
663 })
664 .fill_width();
665 })
666 .fill()
667 .justify(Align::Center);
668}
669
670fn service_row(list: &mut SettingsRows<'_, Msg>, row: ServiceRow) {
673 let description = match row {
674 ServiceRow::Unsupported => t!("settings.service-windows"),
675 ServiceRow::Ready { installed: true, .. } => t!("settings.service-installed"),
676 ServiceRow::Ready { installed: false, .. } => t!("settings.service-not-installed"),
677 };
678 list.row(SettingRow::new(t!("settings.service")).description(description), |ui| match row {
679 ServiceRow::Unsupported => {}
680 ServiceRow::Ready { busy: true, .. } => {
681 ui.add(Spinner::new());
682 }
683 ServiceRow::Ready { installed, busy: false } => {
684 let (label, request) = if installed {
685 (t!("settings.service-remove"), Request::RemoveService)
686 } else {
687 (t!("settings.service-install"), Request::InstallService)
688 };
689 ui.add(Button::new(label).on_press(Msg::Request(request))).id("service");
690 }
691 });
692}
693
694struct Report {
696 title: &'static str,
697 text: &'static str,
698 dismiss: Msg,
699 id: &'static str,
700}
701
702impl Report {
703 const REPAIRS: Self = Self {
705 title: "settings.repaired",
706 text: "settings.repaired-text",
707 dismiss: Msg::ReadRepairs,
708 id: "repairs-read",
709 };
710
711 const LEFT_BEHIND: Self = Self {
713 title: "settings.left-behind",
714 text: "settings.left-behind-text",
715 dismiss: Msg::ReadLeftBehind,
716 id: "left-behind-read",
717 };
718}
719
720fn report(which: &Report, lines: &[Diagnostic], ui: &mut View<'_, Msg>) {
723 if lines.is_empty() {
724 return;
725 }
726 ui.add_with(Panel::new().title(t!(which.title)), |ui| {
727 ui.add(Text::new(t!(which.text)).role("secondary"));
728 for repair in lines {
729 let place = repair.location.as_ref().map(ToString::to_string);
730 let line = match place {
731 Some(place) => format!("{place} {}", repair.message),
732 None => repair.message.clone(),
733 };
734 let colour = if repair.severity == Severity::Error { "danger" } else { "warning" };
735 ui.add(Text::new(line).color(colour));
736 }
737 ui.add(Button::new(t!("settings.repaired-dismiss")).on_press(which.dismiss.clone())).id(which.id);
738 })
739 .fill_width();
740}
741
742#[must_use]
749pub fn entry(screen: &Settings) -> &'static str {
750 if !screen.left_behind.is_empty() {
751 Report::LEFT_BEHIND.id
752 } else if !screen.repairs_read && !screen.repairs.is_empty() {
753 Report::REPAIRS.id
754 } else {
755 "settings"
756 }
757}
758
759#[must_use]
761pub fn hints(icons: &qframe::icons::Icons) -> Vec<(String, String)> {
762 let move_keys = format!("{}{}", icons.glyph("arrow-up"), icons.glyph("arrow-down"));
763 vec![(move_keys, t!("hints.move")), (icons.glyph("enter").into_owned(), t!("hints.open"))]
764}
765
766#[cfg(test)]
767pub(crate) mod testing {
768 use std::sync::Arc;
769
770 use qframe::env::{AssetDirs, Env};
771 use qframe::i18n::I18n;
772 use qframe::icons::GlyphMode;
773 use qframe::prelude::*;
774 use qframe::runtime::Harness;
775
776 use super::engine::{EngineState, Health};
777 use super::identity::ProfileIdentity;
778 use super::{Msg, Request, Settings};
779 use crate::engine::EngineKind;
780 use crate::profile::SafeName;
781 use crate::store::Config;
782
783 pub fn translated<R>(locale: &str, body: impl FnOnce() -> R) -> R {
786 let mut catalog = I18n::builtin();
787 for (file, text) in crate::locales() {
788 catalog.add_source(&file, &text);
789 }
790 catalog.set_active(locale);
791 qframe::i18n::scope(Arc::new(catalog), body)
792 }
793
794 pub fn screen(kind: EngineKind, health: Health) -> Settings {
796 from_config("", kind, health)
797 }
798
799 pub fn from_config(text: &str, kind: EngineKind, health: Health) -> Settings {
801 Settings::new(&Config::parse_str("code.conf", text), EngineState::new(kind, health))
802 }
803
804 pub fn profile(name: &str, stored: bool) -> ProfileIdentity {
806 ProfileIdentity::new(SafeName::parse(name).expect("the test names are already safe"), stored)
807 }
808
809 #[must_use]
811 pub fn wrap(message: Msg) -> HostMsg {
812 HostMsg::Screen(message)
813 }
814
815 #[derive(Debug, Clone, PartialEq, Eq)]
817 pub enum HostMsg {
818 Screen(Msg),
820 }
821
822 pub struct Host {
825 pub screen: Settings,
827 pub asked: Vec<Request>,
829 }
830
831 impl App for Host {
832 type Msg = HostMsg;
833
834 fn update(&mut self, message: HostMsg) -> Command<HostMsg> {
835 let HostMsg::Screen(message) = message;
836 let (command, request) = super::update(&mut self.screen, message);
837 if let Some(request) = request {
838 self.asked.push(request);
839 }
840 command.map(HostMsg::Screen)
841 }
842
843 fn view(&self, ui: &mut View<'_, HostMsg>) {
844 AppShell::new()
845 .header(|ui| {
846 ui.map(HostMsg::Screen, |ui| super::bar::view(self.screen.engine(), ui)).fill_width();
847 })
848 .body(|ui| {
849 ui.map(HostMsg::Screen, |ui| super::view(&self.screen, ui)).fill();
850 })
851 .show(ui);
852 }
853 }
854
855 fn env() -> Env {
856 Env::load(&AssetDirs { locale_sources: crate::locales(), ..AssetDirs::default() })
857 .expect("the built-in files load")
858 }
859
860 pub fn host(screen: Settings, width: u16, height: u16) -> Harness<Host> {
862 let mut harness = Harness::with_env(Host { screen, asked: Vec::new() }, env(), width, height);
863 harness.set_locale("en").set_glyph_mode(GlyphMode::Unicode);
864 harness.render();
865 harness
866 }
867}
868
869#[cfg(test)]
870mod tests {
871 use qframe::icons::GlyphMode;
872
873 use super::testing;
874 use crate::backup::BackupEvery;
875 use crate::base::apps::{Editor, Sound};
876 use crate::engine::EngineKind;
877 use crate::store::{OnClose, Platform};
878 use crate::ui::settings::Request;
879 use crate::ui::settings::engine::{Health, Trouble};
880
881 const SIZE: (u16, u16) = (100, 40);
882
883 #[test]
884 fn the_common_settings_are_the_frameworks_own() {
885 let harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), SIZE.0, SIZE.1);
886 let screen = harness.screen();
887 for label in ["Language", "Theme", "Icons", "Reduce motion", "Container engine", "Folder"] {
888 assert!(screen.contains(label), "`{label}` is missing:\n{screen}");
889 }
890 assert!(screen.contains("English"), "the language in force is the one shown:\n{screen}");
891 }
892
893 #[test]
894 fn the_settings_stand_in_the_middle_at_a_readable_width() {
895 let mut harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), 160, SIZE.1);
896 harness.render();
897 let (label, _) = harness.find("Language").expect("the language row is shown");
898 let (folder, _) = harness.find("Folder").expect("the folder row is shown");
899 let left = (160 - i32::from(crate::ui::page::WIDTH)) / 2;
900 assert!(
901 label >= left && folder >= left,
902 "the column starts in the middle, not at the edge:\n{}",
903 harness.screen()
904 );
905 let (change, _) = harness.find("Change").expect("the folder row offers a change");
907 let right = left + i32::from(crate::ui::page::WIDTH);
908 assert!(change + 6 <= right, "nothing reaches past the column:\n{}", harness.screen());
909 assert!(change + 6 >= right - 4, "the column is as wide as it may be:\n{}", harness.screen());
910
911 harness.resize(70, SIZE.1).render();
913 assert!(harness.screen().contains("Language"), "{}", harness.screen());
914 let (label, _) = harness.find("Language").expect("the language row is shown");
915 assert!(label < 8, "a narrow terminal keeps the column at its edge:\n{}", harness.screen());
916 }
917
918 fn written_rows(screen: &str) -> (usize, usize) {
920 let rows: Vec<&str> = screen.lines().collect();
921 let first = rows.iter().position(|row| !row.trim().is_empty()).unwrap_or_default();
922 let last = rows.iter().rposition(|row| !row.trim().is_empty()).unwrap_or_default();
923 (first, last)
924 }
925
926 #[test]
927 fn a_page_that_fits_stands_in_the_middle_from_top_to_bottom_and_a_taller_one_starts_at_the_top() {
928 let height = 90;
929 let mut screen = testing::screen(EngineKind::Podman, Health::Working);
930 super::update(&mut screen, super::Msg::Profiles(vec![testing::profile("claude-sub", true)]));
931 let mut harness = testing::host(screen, 120, height);
932 harness.render();
933 let text = harness.screen();
934 let (first, last) = written_rows(&text);
935 let below = usize::from(height) - 1 - last;
936 assert!(first > 5, "the page does not stick to the top:\n{text}");
937 assert!(first.abs_diff(below) <= 1, "as much room over it as under it ({first}, {below}):\n{text}");
938
939 harness.resize(120, 30).render();
940 let (first, _) = written_rows(&harness.screen());
941 assert!(first <= 1, "a page taller than the screen starts at its top:\n{}", harness.screen());
942 }
943
944 #[test]
945 fn the_last_row_of_a_centred_page_is_drawn_whatever_the_width() {
946 for width in [80, 120] {
950 let mut screen = testing::screen(EngineKind::Podman, Health::Working);
951 super::update(&mut screen, super::Msg::Profiles(vec![testing::profile("claude-sub", true)]));
952 let mut harness = testing::host(screen, width, 90);
953 harness.render();
954 assert!(harness.screen().contains("Refresh identity"), "at {width} columns:\n{}", harness.screen());
955 harness.click_text("Refresh identity").render();
956 assert!(
957 harness.screen().contains("Refresh claude-sub"),
958 "and it answers at {width}:\n{}",
959 harness.screen()
960 );
961 }
962 }
963
964 #[test]
965 fn the_logins_are_said_to_be_what_they_are_and_are_not_drawn_without_a_profile() {
966 let mut screen = testing::screen(EngineKind::Podman, Health::Working);
967 super::update(&mut screen, super::Msg::Profiles(Vec::new()));
968 let harness = testing::host(screen, 120, 90);
969 let text = harness.screen();
970 assert!(!text.contains("LOGINS"), "no empty section:\n{text}");
971 assert!(!text.contains("PROFILES"), "{text}");
972
973 let mut screen = testing::screen(EngineKind::Podman, Health::Working);
974 super::update(&mut screen, super::Msg::Profiles(vec![testing::profile("claude-sub", true)]));
975 let harness = testing::host(screen, 120, 90);
976 let text = harness.screen();
977 for words in ["Refresh identity", "LOGINS", "given again", "claude-sub", "login kept"] {
978 assert!(text.contains(words), "`{words}`:\n{text}");
979 }
980 }
981
982 #[test]
983 fn every_language_fits_the_settings_rows_and_their_drop_downs() {
984 for (code, name) in [
987 ("en", "English"),
988 ("tr", "Türkçe"),
989 ("de", "Deutsch"),
990 ("es", "Español"),
991 ("fr", "Français"),
992 ("pt-BR", "Português (Brasil)"),
993 ("ru", "Русский"),
994 ("zh-Hans", "简体中文"),
995 ("ja", "日本語"),
996 ] {
997 let mut harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), SIZE.0, SIZE.1);
998 harness.set_locale(code).render();
999 let screen = harness.screen();
1000 assert!(!screen.contains('…'), "{code} is cut somewhere:\n{screen}");
1001 assert!(screen.contains(name), "{code} does not name itself in the drop-down:\n{screen}");
1002 }
1003 }
1004
1005 #[test]
1006 fn a_shell_that_decides_reduced_motion_says_so_in_place_of_the_description() {
1007 testing::translated("en", || {
1011 let forced_on = super::motion_note(true, true).expect("a forced setting says so");
1012 assert!(forced_on.contains("QUVYTA_REDUCED_MOTION"), "{forced_on}");
1013 assert!(forced_on.contains("and decides"), "{forced_on}");
1014 assert!(!forced_on.contains("cannot be changed here"), "said in fewer words: {forced_on}");
1015 let forced_off = super::motion_note(true, false).expect("a forced setting says so");
1016 assert!(forced_off.contains("QUVYTA_REDUCED_MOTION"), "{forced_off}");
1017 assert_ne!(forced_on, forced_off, "the value in force is named, not just the variable");
1018 assert_eq!(super::motion_note(false, true), None, "a saved setting explains nothing");
1019 });
1020 }
1021
1022 #[test]
1023 fn the_turkish_note_is_turkish() {
1024 testing::translated("tr", || {
1025 let note = super::motion_note(true, true).expect("a forced setting says so");
1026 assert!(note.contains("Kabuğunda"), "{note}");
1027 assert!(note.contains("kararı o verir"), "{note}");
1028 });
1029 }
1030
1031 #[test]
1032 fn choosing_a_language_changes_the_application_and_asks_for_it_to_be_stored() {
1033 let mut harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), SIZE.0, SIZE.1);
1034 harness.send(testing::wrap(super::Msg::Language("tr".to_owned()))).render();
1035 assert_eq!(harness.app().asked, [Request::Language("tr".to_owned())]);
1036 let screen = harness.screen();
1037 assert!(screen.contains("Kapsayıcı motoru"), "the screen speaks it at once:\n{screen}");
1038 }
1039
1040 #[test]
1041 fn an_engine_refusing_this_account_says_so_with_the_line_that_lets_it_in() {
1042 let said = "permission denied while trying to connect to the docker API at unix:///var/run/docker.sock";
1043 let health = Health::Missing(Trouble::Refused(said.to_owned()));
1044 let harness = testing::host(testing::screen(EngineKind::Docker, health), 150, 44);
1045 let screen = harness.screen();
1046 assert!(screen.contains("not in the docker group"), "{screen}");
1047 assert!(screen.contains("usermod -aG docker"), "{screen}");
1048 assert!(screen.contains("permission denied while"), "what docker said stays:\n{screen}");
1049 }
1050
1051 #[test]
1052 fn choosing_an_engine_stops_claiming_the_old_one_works() {
1053 let mut harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), SIZE.0, SIZE.1);
1054 harness.send(testing::wrap(super::Msg::Engine(EngineKind::Docker))).render();
1055 assert_eq!(harness.app().asked, [Request::Engine(EngineKind::Docker)]);
1056 let screen = harness.screen();
1057 assert!(screen.contains("Looking for the engine"), "{screen}");
1058 assert!(!screen.contains("Podman answers"), "{screen}");
1059 }
1060
1061 #[test]
1062 fn the_store_row_shows_the_folder_and_opens_the_step_that_moves_it() {
1063 let text = "[folder]\npath = \"/home/ada/Documents/QCode\"\n";
1064 let screen = testing::from_config(text, EngineKind::Podman, Health::Working);
1065 let mut harness = testing::host(screen, SIZE.0, SIZE.1);
1066 assert!(harness.screen().contains("/home/ada/Documents/QCode"), "{}", harness.screen());
1067 harness.click_text("Change");
1068 assert_eq!(harness.app().asked, [Request::OpenLocationStep]);
1069 }
1070
1071 #[test]
1072 fn a_store_that_was_never_chosen_says_so_instead_of_showing_nothing() {
1073 let harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), SIZE.0, SIZE.1);
1074 assert!(harness.screen().contains("Not chosen yet"), "{}", harness.screen());
1075 }
1076
1077 #[test]
1078 fn a_repaired_settings_file_is_reported_with_the_place_of_every_repair() {
1079 let screen = testing::from_config(
1081 "[engine]\nkind = \"podman\"\nspeed = \"fast\"\n",
1082 EngineKind::Podman,
1083 Health::Working,
1084 );
1085 let mut harness = testing::host(screen, SIZE.0, SIZE.1);
1086 let shown = harness.screen();
1087 assert!(shown.contains("The settings file was repaired"), "{shown}");
1088 assert!(shown.contains("code.conf:3:1"), "the repair is pinned to its place:\n{shown}");
1089 harness.click_text("Got it");
1090 assert!(!harness.screen().contains("The settings file was repaired"), "{}", harness.screen());
1091 }
1092
1093 #[test]
1094 fn old_settings_files_that_were_not_moved_are_reported_until_read() {
1095 let left = qframe::diagnostics::Diagnostic::warning(
1096 None,
1097 "/home/ada/.config/quvyta/code/settings.toml: code.conf is already there; it stays where it is",
1098 );
1099 let screen = testing::screen(EngineKind::Podman, Health::Working).with_left_behind(vec![left]);
1100 let mut harness = testing::host(screen, SIZE.0, SIZE.1);
1101 let shown = harness.screen();
1102 assert!(shown.contains("Some old settings files stayed where they were"), "{shown}");
1103 assert!(shown.contains("code/settings.toml"), "the file is named:\n{shown}");
1104 assert!(!shown.contains("was repaired"), "a clean file has nothing repaired:\n{shown}");
1105 harness.click_text("Got it");
1106 assert!(!harness.screen().contains("stayed where they were"), "{}", harness.screen());
1107 }
1108
1109 #[test]
1110 fn a_clean_settings_file_reports_nothing() {
1111 let harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), SIZE.0, SIZE.1);
1112 assert!(!harness.screen().contains("was repaired"), "{}", harness.screen());
1113 }
1114
1115 #[test]
1116 fn a_change_that_could_not_be_written_is_said_out_loud() {
1117 let mut harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), SIZE.0, SIZE.1);
1118 let failed = super::Msg::Stored(Err("read-only file system".to_owned()));
1119 harness.send(testing::wrap(failed)).render();
1120 let screen = harness.screen();
1121 assert!(screen.contains("read-only file system"), "{screen}");
1122 harness.send(testing::wrap(super::Msg::Stored(Ok(())))).render();
1123 assert!(!harness.screen().contains("read-only file system"), "{}", harness.screen());
1124 }
1125
1126 #[test]
1127 fn a_narrow_terminal_keeps_every_setting() {
1128 let harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), 46, 20);
1129 let screen = harness.screen();
1130 for label in ["Language", "Theme", "Reduce motion"] {
1131 assert!(screen.contains(label), "`{label}` is missing:\n{screen}");
1132 }
1133 }
1134
1135 #[test]
1136 fn the_keyboard_reaches_the_settings_and_changes_one() {
1137 let mut harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), SIZE.0, SIZE.1);
1138 harness.press("tab").press("tab");
1140 assert!(harness.is_focused("settings"), "{}", harness.screen());
1141 harness.press("down").press("down").press("down").press("space");
1143 assert_eq!(harness.app().asked, [Request::ReducedMotion(true)]);
1144 }
1145
1146 #[test]
1147 fn the_keys_go_round_the_ends_of_the_settings() {
1148 let mut harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), SIZE.0, SIZE.1);
1149 harness.press("tab").press("tab");
1150 assert!(harness.is_focused("settings"), "{}", harness.screen());
1151 harness.press("up").press("space");
1154 assert_eq!(harness.app().asked, [Request::AskFirst(true)], "{}", harness.screen());
1155 harness.press("down").press("down").press("down").press("down").press("space");
1157 assert_eq!(
1158 harness.app().asked,
1159 [Request::AskFirst(true), Request::ReducedMotion(true)],
1160 "{}",
1161 harness.screen()
1162 );
1163 }
1164
1165 #[test]
1166 fn what_to_do_about_a_missing_engine_is_said_where_there_is_room_to_read_it() {
1167 let trouble = Trouble::Refused("short-name resolution enforced".to_owned());
1168 let harness = testing::host(testing::screen(EngineKind::Podman, Health::Missing(trouble)), SIZE.0, SIZE.1);
1169 let screen = harness.screen();
1170 assert!(screen.contains("Repair looks again"), "{screen}");
1171 assert!(screen.contains("short-name resolution enforced"), "the engine's own words are kept:\n{screen}");
1172 }
1173
1174 #[test]
1175 fn the_editor_is_chosen_among_the_built_in_apps_and_nano_comes_first() {
1176 let mut harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), SIZE.0, SIZE.1);
1177 let screen = harness.screen();
1178 for label in ["BUILT-IN APPS", "Editor", "nano", "vim"] {
1179 assert!(screen.contains(label), "`{label}` is missing:\n{screen}");
1180 }
1181 harness.send(testing::wrap(super::Msg::Editor(Editor::Vim))).render();
1182 assert_eq!(harness.app().asked, [Request::Editor(Editor::Vim)]);
1183 assert_eq!(harness.app().screen.editor, Editor::Vim);
1184
1185 let screen = testing::from_config("[apps]\neditor = \"vim\"\n", EngineKind::Podman, Health::Working);
1187 assert_eq!(screen.editor, Editor::Vim);
1188 }
1189
1190 #[test]
1191 fn sounds_play_or_show_their_details_and_the_choice_sits_under_the_editor() {
1192 let mut harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), SIZE.0, 60);
1193 let screen = harness.screen();
1194 for label in ["Sounds", "Play", "Details only"] {
1195 assert!(screen.contains(label), "`{label}` is missing:\n{screen}");
1196 }
1197 let editor = harness.find("Editor").expect("the editor row is on screen");
1198 let sounds = harness.find("Sounds").expect("the sound row is on screen");
1199 assert!(sounds.1 > editor.1, "the sound row comes after the editor's:\n{screen}");
1200 harness.send(testing::wrap(super::Msg::Sound(Sound::Details))).render();
1201 assert_eq!(harness.app().asked, [Request::Sound(Sound::Details)]);
1202 assert_eq!(harness.app().screen.sound, Sound::Details);
1203
1204 let screen = testing::from_config("[apps]\nsound = \"details\"\n", EngineKind::Podman, Health::Working);
1206 assert_eq!(screen.sound, Sound::Details);
1207 }
1208
1209 #[test]
1210 fn asking_before_messages_between_tabs_is_off_until_the_person_turns_it_on() {
1211 let mut harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), SIZE.0, 70);
1212 let screen = harness.screen();
1213 for label in [
1214 "MESSAGES BETWEEN TABS",
1215 "Ask before the first message",
1216 "Off, agents in different tabs send each other messages",
1217 ] {
1218 assert!(screen.contains(label), "`{label}` is missing:\n{screen}");
1219 }
1220 assert!(!harness.app().screen.ask_first, "a file without the key does not ask");
1221 harness.send(testing::wrap(super::Msg::AskFirst(true))).render();
1222 assert_eq!(harness.app().asked, [Request::AskFirst(true)]);
1223 assert!(harness.app().screen.ask_first);
1224
1225 let screen = testing::from_config("[bridge]\nask-first = true\n", EngineKind::Podman, Health::Working);
1226 assert!(screen.ask_first, "a file that turned it on opens the screen with it on");
1227 }
1228
1229 #[test]
1230 fn a_quiet_profile_is_frozen_in_the_background_until_the_person_turns_that_off() {
1231 let mut harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), SIZE.0, 60);
1232 let screen = harness.screen();
1233 for label in [
1234 "Freeze quiet profiles in the background",
1235 "A profile none of whose tabs is on screen",
1236 "rootless podman and swap",
1237 ] {
1238 assert!(screen.contains(label), "`{label}` is missing:\n{screen}");
1239 }
1240 assert!(harness.app().screen.freeze_idle, "a file without the key freezes");
1241
1242 let switch = harness.find("Freeze quiet profiles in the background").expect("the row is on screen");
1243 let (edge, _) = harness.find("▾").expect("the language drop-down");
1244 harness.click(edge - 1, switch.1).render();
1245 assert_eq!(harness.app().asked, [Request::FreezeIdle(false)]);
1246 assert!(!harness.app().screen.freeze_idle, "and the switch shows it is off");
1247
1248 let screen = testing::from_config("freeze_idle = false\n", EngineKind::Podman, Health::Working);
1250 assert!(!screen.freeze_idle, "a file that turned it off opens the screen with it off");
1251 assert!(testing::from_config("", EngineKind::Podman, Health::Working).freeze_idle);
1252 }
1253
1254 #[test]
1255 fn the_built_in_apps_are_named_in_turkish() {
1256 let mut harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), SIZE.0, 40);
1257 harness.send(testing::wrap(super::Msg::Language("tr".to_owned()))).render();
1258 let screen = harness.screen();
1259 assert!(screen.contains("YERLEŞİK UYGULAMALAR"), "{screen}");
1260 assert!(screen.contains("Düzenleyici"), "{screen}");
1261 assert!(screen.contains("Sesler"), "{screen}");
1262 assert!(screen.contains("Yalnızca ayrıntılar"), "{screen}");
1263 }
1264
1265 #[test]
1266 fn reduced_motion_leaves_the_screen_working() {
1267 let mut harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), SIZE.0, SIZE.1);
1268 harness.set_reduced_motion(true).render();
1269 assert!(harness.screen().contains("Container engine"), "{}", harness.screen());
1270 }
1271
1272 #[test]
1273 fn ascii_mode_draws_nothing_but_ascii() {
1274 let mut harness = testing::host(testing::screen(EngineKind::Podman, Health::Working), SIZE.0, SIZE.1);
1275 harness.set_glyph_mode(GlyphMode::Ascii).render();
1276 let screen = harness.screen();
1277 assert!(screen.is_ascii(), "{screen}");
1278 assert!(screen.contains("Container engine"), "{screen}");
1279 }
1280
1281 fn with_service(row: Option<super::ServiceRow>) -> qframe::runtime::Harness<testing::Host> {
1283 testing::host(testing::screen(EngineKind::Podman, Health::Working).with_service(row), SIZE.0, 44)
1284 }
1285
1286 #[test]
1287 fn the_service_is_offered_where_it_can_be_installed_and_fits_in_both_languages() {
1288 for platform in [Platform::Linux, Platform::MacOs] {
1289 let row = super::ServiceRow::of(platform, Some(false));
1290 assert_eq!(row, Some(super::ServiceRow::Ready { installed: false, busy: false }));
1291 }
1292 let mut harness = with_service(super::ServiceRow::of(Platform::Linux, Some(false)));
1293 let screen = harness.screen();
1294 assert!(screen.contains("Background service"), "{screen}");
1295 assert!(screen.contains("Install"), "{screen}");
1296 harness.set_locale("tr").render();
1297 let screen = harness.screen();
1298 assert!(!screen.contains('…'), "every word fits:\n{screen}");
1299 assert!(screen.contains("Arka plan servisi"), "{screen}");
1300 assert!(screen.contains("Kur"), "{screen}");
1301 }
1302
1303 #[test]
1304 fn windows_is_told_that_it_has_no_service_and_that_nothing_is_stopped_there() {
1305 assert_eq!(super::ServiceRow::of(Platform::Windows, None), Some(super::ServiceRow::Unsupported));
1306 let mut harness = with_service(Some(super::ServiceRow::Unsupported));
1307 let screen = harness.screen();
1308 assert!(screen.contains("Not available on Windows"), "{screen}");
1309 assert!(screen.contains("containers keep running"), "the choice says it has no effect:\n{screen}");
1310 assert!(!screen.contains("Install"), "{screen}");
1311 harness.set_locale("tr").render();
1312 let screen = harness.screen();
1313 assert!(!screen.contains('…'), "every word fits:\n{screen}");
1314 assert!(screen.contains("Windows'ta yok"), "{screen}");
1315 assert!(screen.contains("açık kalır"), "{screen}");
1316 }
1317
1318 #[test]
1319 fn a_machine_with_nowhere_to_put_the_service_shows_no_row() {
1320 assert_eq!(super::ServiceRow::of(Platform::Linux, None), None);
1321 let screen = with_service(None).screen();
1322 assert!(!screen.contains("Background service"), "{screen}");
1323 }
1324
1325 #[test]
1326 fn installing_asks_once_waits_and_says_what_failed_in_the_commands_words() {
1327 let row = super::ServiceRow::of(Platform::Linux, Some(false));
1328 let mut harness = with_service(row);
1329 assert!(harness.screen().contains("Not installed"), "{}", harness.screen());
1330 harness.click_text("Install");
1331 harness.send(testing::wrap(super::Msg::Request(Request::InstallService)));
1332 assert_eq!(harness.app().asked, [Request::InstallService], "a second press while it works asks for nothing");
1333 assert_eq!(harness.app().screen.service(), Some(super::ServiceRow::Ready { installed: false, busy: true }));
1334
1335 let failed = super::Msg::ServiceDone {
1336 installing: true,
1337 installed: false,
1338 result: Err("systemctl --user daemon-reload: Failed to connect to bus".to_owned()),
1339 };
1340 harness.send(testing::wrap(failed));
1341 harness.advance(std::time::Duration::from_millis(300));
1342 let screen = harness.screen();
1343 assert!(screen.contains("could not be installed"), "{screen}");
1344 assert!(
1345 screen.contains("daemon-reload: Failed to") && screen.contains("connect to bus"),
1346 "the command's own words:\n{screen}"
1347 );
1348 assert!(screen.contains("Install"), "the button is back:\n{screen}");
1349 }
1350
1351 #[test]
1352 fn an_installed_service_offers_to_be_removed() {
1353 let mut harness = with_service(super::ServiceRow::of(Platform::Linux, Some(true)));
1354 let screen = harness.screen();
1355 assert!(screen.contains("Installed;"), "{screen}");
1356 harness.click_text("Remove");
1357 assert_eq!(harness.app().asked, [Request::RemoveService]);
1358 let done = super::Msg::ServiceDone { installing: false, installed: false, result: Ok(()) };
1359 harness.send(testing::wrap(done)).render();
1360 assert!(harness.screen().contains("Not installed"), "{}", harness.screen());
1361 }
1362
1363 #[test]
1364 fn what_happens_to_the_containers_on_close_is_a_choice_of_two() {
1365 let mut harness = with_service(None);
1366 let screen = harness.screen();
1367 for words in ["When QCode closes", "Stop", "Keep running"] {
1368 assert!(screen.contains(words), "`{words}` is missing:\n{screen}");
1369 }
1370 harness.click_text("Keep running");
1371 assert_eq!(harness.app().asked, [Request::OnClose(OnClose::Keep)]);
1372 harness.set_locale("tr").render();
1373 let screen = harness.screen();
1374 for words in ["QCode kapanınca", "Durdurulsun", "Açık kalsın"] {
1375 assert!(screen.contains(words), "`{words}` is missing:\n{screen}");
1376 }
1377 }
1378
1379 #[test]
1380 fn how_often_open_workspaces_are_backed_up_is_a_choice_of_four() {
1381 let mut harness = with_service(None);
1382 let screen = harness.screen();
1383 for words in ["BACKUP", "Back up open workspaces", "Off", "5 min", "15 min", "1 hour"] {
1384 assert!(screen.contains(words), "`{words}` is missing:\n{screen}");
1385 }
1386 harness.click_text("5 min");
1387 harness.click_text("Off");
1388 assert_eq!(
1389 harness.app().asked,
1390 [Request::BackupEvery(BackupEvery::Five), Request::BackupEvery(BackupEvery::Off)]
1391 );
1392 harness.set_locale("tr").render();
1393 let screen = harness.screen();
1394 assert!(!screen.contains('…'), "every word fits:\n{screen}");
1395 for words in ["YEDEK", "Açık çalışma alanlarını yedekle", "Kapalı", "5 dk", "15 dk", "1 saat"] {
1396 assert!(screen.contains(words), "`{words}` is missing:\n{screen}");
1397 }
1398 }
1399
1400 #[test]
1401 fn a_stored_interval_is_the_one_shown() {
1402 let screen = testing::from_config("[backup]\nevery = \"1h\"\n", EngineKind::Podman, Health::Working);
1403 assert_eq!(screen.backup_every, BackupEvery::Hour);
1404 let screen = testing::from_config("", EngineKind::Podman, Health::Working);
1405 assert_eq!(screen.backup_every, BackupEvery::Fifteen, "fifteen minutes until the person chooses");
1406 }
1407
1408 #[test]
1409 fn a_stored_choice_to_keep_them_running_is_the_one_shown() {
1410 let screen = testing::from_config("[containers]\non-close = \"keep\"\n", EngineKind::Podman, Health::Working);
1411 assert_eq!(screen.on_close, OnClose::Keep);
1412 }
1413
1414 #[test]
1415 fn nothing_is_bracketed_lined_or_framed() {
1416 let profiles = vec![testing::profile("claude-sub", true)];
1417 let mut harness =
1418 testing::host(testing::screen(EngineKind::Podman, Health::Missing(Trouble::NotInstalled)), SIZE.0, SIZE.1);
1419 harness.send(testing::wrap(super::Msg::Profiles(profiles)));
1420 for mode in [GlyphMode::Nerd, GlyphMode::Unicode, GlyphMode::Ascii] {
1421 harness.set_glyph_mode(mode).render();
1422 let screen = harness.screen();
1423 for forbidden in ['[', ']', '(', ')', '{', '}', '|', '┌', '─', '│'] {
1424 assert!(!screen.contains(forbidden), "`{forbidden}` in {mode:?}:\n{screen}");
1425 }
1426 }
1427 }
1428}