Skip to main content

qcode/ui/settings/
mod.rs

1//! The settings screen: what the person can change about QCode, and what the shell has already
2//! decided for them.
3//!
4//! Everything the framework keeps for every Quvyta application — language, theme, icons and
5//! reduced motion — is read straight from the environment the screen draws in, which is the one
6//! place that knows what is actually in force. QCode's own settings, the container engine and
7//! the store folder, come from [`Config`], which repairs the file it reads; the repairs are
8//! shown here rather than swallowed.
9//!
10//! The screen changes nothing outside itself. A choice is applied at once, so it can be seen,
11//! and handed to the application as a [`Request`] to be stored or carried out. That keeps the
12//! settings file, the setup wizard and the container engine in the hands that own them.
13
14pub 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
36/// Width of the drop-downs in the settings list. Wide enough for the longest theme and language
37/// name — `Português (Brasil)`, the longest of the nine — narrow enough to leave the labels their
38/// column. A drop-down keeps seven of these cells for its arrow and its padding.
39const CONTROL_WIDTH: u16 = 25;
40
41/// Rows between the blocks of the screen.
42const BLOCK_GAP: u16 = 1;
43
44/// The left margin a settings list keeps for its pillar. Everything drawn under the list keeps
45/// it too, so nothing stands further left than the settings themselves.
46const LEAD: u16 = 2;
47
48/// The engines offered, in the order the setup wizard offers them.
49const ENGINES: [EngineKind; 2] = [EngineKind::Podman, EngineKind::Docker];
50
51/// What the settings screen asks the application to do, because it reaches past the screen.
52///
53/// The first five, the editor, the sound choice and asking before messages between tabs are
54/// choices already applied to the running application and only waiting to be written down. The
55/// rest are work only the layers that own it can do: the setup wizard's steps and the engine's
56/// volumes.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum Request {
59    /// Store the language. The application already speaks it.
60    Language(String),
61    /// Store the theme. The application already wears it.
62    Theme(String),
63    /// Store the icon mode. The application already draws in it.
64    Icons(IconMode),
65    /// Store reduced motion. The application already moves that way.
66    ReducedMotion(bool),
67    /// Store the container engine and look for it again.
68    Engine(EngineKind),
69    /// Store the editor a file opens in, and open files in it from now on.
70    Editor(Editor),
71    /// Store what opening a sound does, and do that from now on.
72    Sound(Sound),
73    /// Store whether the first message from one tab to another asks the person, and do that
74    /// from now on.
75    AskFirst(bool),
76    /// Store whether a profile whose container is doing nothing is frozen in the background, and
77    /// do that from now on.
78    FreezeIdle(bool),
79    /// Run the setup wizard's engine step on its own. This is what the repair strip asks for.
80    OpenEngineStep,
81    /// Run the setup wizard's location step on its own, to move the store somewhere else.
82    OpenLocationStep,
83    /// Write the profile's stored login into every workspace that uses it. Confirmed.
84    RefreshIdentity(SafeName),
85    /// Delete the profile's credentials volume. Confirmed.
86    SignOut(SafeName),
87    /// Store what happens to the containers once no QCode is open.
88    OnClose(OnClose),
89    /// Store how often the open workspaces are backed up, and back them up that often from now on.
90    BackupEvery(BackupEvery),
91    /// Turn the family's update notice on or off, for every Quvyta application.
92    UpdateNotice(bool),
93    /// Install the background service.
94    InstallService,
95    /// Remove the background service.
96    RemoveService,
97}
98
99/// What the settings screen shows about the background service.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum ServiceRow {
102    /// This system gets no service, and the row says why: Windows gives QCode no lock to tell
103    /// when the last QCode closes, so there containers are not stopped at all.
104    Unsupported,
105    /// The service can be installed or removed.
106    Ready {
107        /// Whether its files are in place.
108        installed: bool,
109        /// Whether installing or removing it is under way.
110        busy: bool,
111    },
112}
113
114impl ServiceRow {
115    /// The row for a machine of `platform`: `installed` is whether the service's files are
116    /// there, or `None` where there is nowhere to put them — a machine with no home, which gets
117    /// no row at all.
118    #[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/// What can happen on the settings screen.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum Msg {
131    /// The engine check answered.
132    Checked(Health),
133    /// The profiles and their logins were read.
134    Profiles(Vec<ProfileIdentity>),
135    /// A language was chosen.
136    Language(String),
137    /// A theme was chosen.
138    Theme(String),
139    /// An icon mode was chosen.
140    Icons(IconMode),
141    /// The reduce-motion switch was moved.
142    ReduceMotion(bool),
143    /// The switch of the family's update notice was moved.
144    UpdateNotice(bool),
145    /// An engine was chosen.
146    Engine(EngineKind),
147    /// An editor was chosen.
148    Editor(Editor),
149    /// What opening a sound does was chosen.
150    Sound(Sound),
151    /// The switch that asks before the first message between two tabs was moved.
152    AskFirst(bool),
153    /// The switch that freezes a profile's container in the background was moved.
154    FreezeIdle(bool),
155    /// A profile was chosen.
156    Profile(usize),
157    /// Refreshing the chosen profile's login was asked for; the question follows.
158    AskRefresh,
159    /// Signing the chosen profile out was asked for; the question follows.
160    AskSignOut,
161    /// Something the application does, past every question it needed.
162    Request(Request),
163    /// The repair report was read and can go.
164    ReadRepairs,
165    /// The report of the old settings files that were not moved was read and can go.
166    ReadLeftBehind,
167    /// The application stored the last change, or could not.
168    Stored(Result<(), String>),
169    /// What happens to the containers once no QCode is open was chosen.
170    OnClose(OnClose),
171    /// How often the open workspaces are backed up was chosen.
172    BackupEvery(BackupEvery),
173    /// Installing or removing the background service finished: which of the two it was, whether
174    /// the service's files are there now, and the words of what failed.
175    ServiceDone {
176        /// Whether it was installing rather than removing.
177        installing: bool,
178        /// Whether the service's files are in place now.
179        installed: bool,
180        /// What failed, in the words of the command that failed.
181        result: Result<(), String>,
182    },
183}
184
185/// The settings screen's state.
186#[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    /// The family's update notice, or `None` where there is no family folder to keep it in.
204    update_notice: Option<bool>,
205}
206
207impl Settings {
208    /// A settings screen showing what `config` holds, including every repair its file needed,
209    /// with `engine` as the last check left it. The profiles are not read yet.
210    #[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    /// The same screen, showing the family's update notice as `on`; `None` shows no row, which is
233    /// a machine with no family folder to keep the switch in.
234    #[must_use]
235    pub fn with_update_notice(mut self, on: Option<bool>) -> Self {
236        self.update_notice = on;
237        self
238    }
239
240    /// The family's update notice as the screen shows it.
241    #[must_use]
242    pub fn update_notice(&self) -> Option<bool> {
243        self.update_notice
244    }
245
246    /// The same screen, showing the background service as `row` says; `None` shows no row.
247    #[must_use]
248    pub fn with_service(mut self, row: Option<ServiceRow>) -> Self {
249        self.service = row;
250        self
251    }
252
253    /// What the screen shows about the background service.
254    #[must_use]
255    pub fn service(&self) -> Option<ServiceRow> {
256        self.service
257    }
258
259    /// The same screen, also reporting `files`: what an earlier version left behind — a settings
260    /// file that stayed where it was when the settings moved to the family's folder, a folder of
261    /// the store that could not be given the name it has today — each with the reason.
262    ///
263    /// They add up rather than replace one another: the two are found at different moments of the
264    /// start, and the person has read neither.
265    #[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    /// The old settings files the screen still has to report, so a screen made again from new
272    /// settings keeps the news the person has not read yet.
273    #[must_use]
274    pub fn left_behind(&self) -> &[Diagnostic] {
275        &self.left_behind
276    }
277
278    /// The engine as the last check left it, for the repair strip.
279    #[must_use]
280    pub fn engine(&self) -> &EngineState {
281        &self.engine
282    }
283
284    /// Whether the actions that reach into a container may be used, and why not when they may
285    /// not. Screens outside settings use the same gate, so a dead action means the same thing
286    /// everywhere.
287    #[must_use]
288    pub fn gate(&self) -> Gate {
289        Gate::of(&self.engine)
290    }
291
292    /// The profile the identity actions work on.
293    #[must_use]
294    pub fn chosen_profile(&self) -> Option<&ProfileIdentity> {
295        self.profiles.as_ref()?.get(self.chosen)
296    }
297}
298
299/// The line under the reduce-motion row while the shell decides it.
300///
301/// `QUVYTA_REDUCED_MOTION` wins over the saved setting and over anything the application asks
302/// for, so the row says which value is in force and where it comes from. Without that the switch
303/// would simply refuse to move, and refusing without a reason is how settings screens lose
304/// people's trust.
305#[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
310/// Applies a settings message, answering with the command to run and what the application has
311/// to do about it.
312pub 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            // The engine is unknown again until the application has looked for the new one.
336            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                    // A second press while the first is under way asks for nothing.
367                    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
413/// The question that stands before an identity action, saying what it does rather than asking
414/// whether the person is sure.
415fn 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
435/// Draws the settings screen.
436pub fn view(screen: &Settings, ui: &mut View<'_, Msg>) {
437    // What is in force right now, not what was saved: the environment has the last word on some
438    // of it, and a settings screen that shows the saved value would show a value nobody has.
439    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    // The scroll view is as tall as the page when the page fits, so the column around it can hold
450    // the page in the middle of the screen; a page taller than the screen fills it and scrolls.
451    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                        // Down on the last setting goes on to the first, as on every list of QCode.
466                        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                        // The family's switch, in the family's own words: it is one setting for every
516                        // Quvyta application, and each says the same thing about it.
517                        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                        // Where nothing can tell that the last QCode closed, the choice is kept for
543                        // the file's sake but the row says plainly that it changes nothing here.
544                        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                        // With the engine, what the containers do while QCode is open: a profile
559                        // nothing of is on screen and that has done nothing for a while hands its
560                        // memory back to the machine, and wakes the moment it is needed.
561                        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                        // The program's own name is the label: it is what the person knows it by, in
583                        // every language.
584                        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                    // Everything under the list keeps the list's own left margin, so the screen reads as
625                    // one column rather than as a list with loose text beside it.
626                    ui.column(|ui| {
627                        // What to do about a missing engine, and whatever the engine said, follow the
628                        // list rather than the strip: here there is a screen to read them on.
629                        if let Health::Missing(trouble) = screen.engine.health() {
630                            let kind = screen.engine.kind();
631                            // A refusal QCode recognises is said plainly, with the line that puts it
632                            // right, in place of the general advice to repair.
633                            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                        // The logins kept for the profiles, and the one thing only this place does with
645                        // them: giving a login again to every workspace that uses its profile. A store
646                        // without profiles has no logins, and the section is not drawn to say so.
647                        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
670/// The row of the background service: what it does and whether it is there, with the button
671/// that installs or removes it.
672fn 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
694/// A report the settings screen stands at its top, one line per diagnostic.
695struct Report {
696    title: &'static str,
697    text: &'static str,
698    dismiss: Msg,
699    id: &'static str,
700}
701
702impl Report {
703    /// What reading the settings file had to put right.
704    const REPAIRS: Self = Self {
705        title: "settings.repaired",
706        text: "settings.repaired-text",
707        dismiss: Msg::ReadRepairs,
708        id: "repairs-read",
709    };
710
711    /// The settings files of an earlier version that were not moved into the family's folder.
712    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
720/// A report over `lines`. It stands until it is read: a repair or a file left behind is news,
721/// and news that disappears on its own is news nobody got.
722fn 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/// The control that takes the keyboard when the screen opens: the list of settings, so the
743/// arrow keys walk it from the first key the person presses.
744///
745/// A report that stands above the list comes first: it is news the person has not read, and the
746/// list is taller than a small terminal, so focusing the list would scroll the report out of
747/// sight. Its dismiss button takes the keyboard instead, and dismissing it hands the keyboard on.
748#[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/// The keys of the settings screen that are not in the keymap, for the key list.
760#[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    /// Runs `body` with QCode's own text loaded, for the functions that translate outside a
784    /// running application.
785    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    /// A settings screen over a clean settings file.
795    pub fn screen(kind: EngineKind, health: Health) -> Settings {
796        from_config("", kind, health)
797    }
798
799    /// A settings screen over the settings file `text`, repairs and all.
800    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    /// A profile with or without a stored login.
805    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    /// The application message a settings message becomes in the tests.
810    #[must_use]
811    pub fn wrap(message: Msg) -> HostMsg {
812        HostMsg::Screen(message)
813    }
814
815    /// What reaches the test application.
816    #[derive(Debug, Clone, PartialEq, Eq)]
817    pub enum HostMsg {
818        /// A settings message.
819        Screen(Msg),
820    }
821
822    /// The application the screen tests drive: the settings screen in a shell, with the repair
823    /// strip in the header where the real application puts it.
824    pub struct Host {
825        /// The screen under test.
826        pub screen: Settings,
827        /// Everything the screen asked the application to do, in order.
828        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    /// A harness showing `screen` in English with Unicode glyphs.
861    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        // The control at the end of a row ends where the column does.
906        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        // A terminal narrower than the column gives it all of its width.
912        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    /// The rows of `screen` between the first and the last one that say anything.
919    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        // The page is centred by its measure, so a measure one row short of what is drawn cut the
947        // Logins buttons off the end of it: the page's width in cells has to be measured as it is
948        // drawn.
949        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        // The rows and the drop-downs are sized in cells, so a language whose words are longer
985        // than English's is where a label or a chosen value would lose its end.
986        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        // The switch cannot move while the variable is set, so the row explains rather than
1008        // simply refusing. The environment is only readable through a real process environment,
1009        // which a test may not change, so the wording is checked where it is decided.
1010        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        // The unknown key is removed by `Config`; the screen's work is to say that it was.
1080        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        // The scrolling body takes the first stop, the list of settings the second.
1139        harness.press("tab").press("tab");
1140        assert!(harness.is_focused("settings"), "{}", harness.screen());
1141        // Language, theme, icons, then the switch, which the space bar moves.
1142        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        // Up on the language, the first row, goes round to asking first, the last, which the space
1152        // bar switches.
1153        harness.press("up").press("space");
1154        assert_eq!(harness.app().asked, [Request::AskFirst(true)], "{}", harness.screen());
1155        // Down from there goes round to the language again: three rows on is the motion switch.
1156        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        // A settings file that chose vim opens the screen on vim.
1186        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        // A settings file that chose the details opens the screen on them.
1205        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        // A settings file that turned it off opens the screen with it off.
1249        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    /// A settings screen showing the background service as `row` says, tall enough to show it.
1282    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}