Skip to main content

qframe/widgets/
setup.rs

1//! The first-run setup wizard: the appearance step the framework draws and drives, the steps the
2//! application adds, and the two files that are written only when it finishes.
3
4use std::io;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use crate::i18n::I18n;
9use crate::icons::nerd_font::{self, Install, Progress};
10use crate::icons::{GlyphMode, GlyphSample};
11use crate::runtime::Command;
12use crate::storage::{Ecosystem, Preferences, Scope, Settings, Shared, Source};
13use crate::widget::{Length, NodeMut, View};
14
15use super::{Appearance, AppearanceChange, Button, ProgressBar, SettingsList, Text, Wizard};
16
17/// The shared keys the appearance step asks: a shared file that holds these answers it.
18const ASKED: [Shared; 3] = [Shared::Language, Shared::Theme, Shared::Icons];
19
20/// One step an application adds: its name and the page it builds.
21type AppStep<'a, Msg> = (String, Box<dyn FnOnce(&mut View<'_, Msg>) + 'a>);
22
23/// Cells the name of a glyph mode takes beside its sample icons.
24const SAMPLE_LABEL: u16 = 12;
25
26/// Something that happened on the first step of a [`SetupWizard`], or on its buttons. Every one of
27/// them goes to [`Setup::update`]; the application never answers one itself.
28#[derive(Debug, Clone, PartialEq)]
29pub enum SetupMsg {
30    /// A row of the appearance step was changed.
31    Appearance(AppearanceChange),
32    /// The Nerd Font symbols were asked for.
33    Install,
34    /// A step of the running install.
35    Installing(Progress),
36    /// Back: the step before this one.
37    Back,
38    /// Next: the step after this one.
39    Next,
40    /// A finished step was chosen from the steps on top.
41    Step(usize),
42    /// The wizard is over: what was chosen is written, and the application is told with the
43    /// message of [`Setup::on_finish`]. "Start with the defaults" sends it from the first step.
44    Finish,
45}
46
47/// The application-owned state of a [`SetupWizard`]: which step it is on, what has been chosen on
48/// the appearance step and how the Nerd Font install is going.
49///
50/// An application makes one at start, whether or not the wizard is needed, and asks
51/// [`Setup::needed`] before drawing its own screen: the wizard opens while the application has no
52/// settings of its own. Nothing is written until it finishes, so closing the application half-way
53/// leaves the settings folder as it was and the wizard comes again next start.
54///
55/// When the ecosystem's shared file already holds a language, a theme and icons, the question has
56/// been answered in another member: the appearance step is left out, the wizard opens on the
57/// application's first step of its own and Finish makes the application follow the shared values.
58/// An application without steps of its own says so with [`Setup::appearance_only`], and then the
59/// wizard is not needed at all. Without a whole shared file the appearance step comes first, filled
60/// in from what there is.
61///
62/// ```
63/// use qframe::i18n::I18n;
64/// use qframe::prelude::*;
65/// use qframe::storage::{Ecosystem, Settings};
66/// use qframe::widgets::{Select, Setup, SetupMsg, SetupWizard};
67///
68/// struct Code {
69///     setup: Setup<Msg>,
70///     settings: Settings,
71///     engine: usize,
72/// }
73///
74/// #[derive(Debug, Clone, PartialEq)]
75/// enum Msg {
76///     Setup(SetupMsg),
77///     Engine(usize),
78///     Ready,
79/// }
80///
81/// impl App for Code {
82///     type Msg = Msg;
83///     fn update(&mut self, msg: Msg) -> Command<Msg> {
84///         match msg {
85///             Msg::Setup(message) => self.setup.update(message, &mut self.settings),
86///             Msg::Engine(engine) => {
87///                 self.engine = engine;
88///                 Command::none()
89///             }
90///             // The wizard wrote the shared keys and made the file; the application's own keys
91///             // are its own to write.
92///             Msg::Ready => {
93///                 self.settings.set("engine", self.engine as i64);
94///                 let _ = self.settings.save();
95///                 Command::none()
96///             }
97///         }
98///     }
99///     fn view(&self, ui: &mut View<'_, Msg>) {
100///         if self.setup.needed() {
101///             SetupWizard::new(&self.setup)
102///                 .step("Containers", |ui| {
103///                     let engines = Select::new(["podman", "docker"]).selected(Some(self.engine));
104///                     ui.add(engines.on_select(Msg::Engine));
105///                 })
106///                 .show(ui);
107///         }
108///     }
109/// }
110///
111/// # let folder = std::env::temp_dir().join(format!("quvyta-setup-doc-{}", std::process::id()));
112/// let ecosystem = Ecosystem::QUVYTA;
113/// // An application calls `Setup::new(ecosystem, "code", &i18n, Msg::Setup)`; the example keeps to a
114/// // folder of its own.
115/// let setup = Setup::new_in(&folder, ecosystem, "code", &I18n::builtin(), Msg::Setup).on_finish(Msg::Ready);
116/// let settings = Settings::open(folder.join("code.conf")).member_of(&ecosystem);
117/// let mut app = Harness::new(Code { setup, settings, engine: 0 }, 60, 24);
118/// assert!(app.screen().contains("In every Quvyta application"));
119/// assert!(!folder.exists(), "nothing is written before the wizard finishes");
120/// # std::fs::remove_dir_all(&folder).ok();
121/// ```
122pub struct Setup<Msg> {
123    ecosystem: Ecosystem,
124    app: String,
125    /// The ecosystem's folder when it is not this platform's own, for a test or a demo.
126    folder: Option<PathBuf>,
127    appearance: Appearance,
128    step: usize,
129    /// The first step shown: 1 when the shared file answers the appearance step, else 0.
130    first: usize,
131    /// Whether the wizard is still wanted: the application had no file of its own and the wizard
132    /// has not finished.
133    needed: bool,
134    install: Install,
135    /// The folders searched for a Nerd Font, and whether one was found there.
136    font_dirs: Vec<PathBuf>,
137    installed: bool,
138    /// The last step of the running or finished install.
139    progress: Option<Progress>,
140    wrap: Arc<dyn Fn(SetupMsg) -> Msg + Send + Sync>,
141    on_finish: Option<Msg>,
142    /// Why finishing could not be saved, until the next try.
143    failure: Option<String>,
144}
145
146impl<Msg> std::fmt::Debug for Setup<Msg> {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        f.debug_struct("Setup")
149            .field("app", &self.app)
150            .field("folder", &self.folder)
151            .field("step", &self.step)
152            .field("needed", &self.needed)
153            .field("installed", &self.installed)
154            .field("progress", &self.progress)
155            .field("failure", &self.failure)
156            .finish_non_exhaustive()
157    }
158}
159
160impl<Msg: Clone + Send + 'static> Setup<Msg> {
161    /// The setup of application `app` of `ecosystem`, on its first step, with every message of the
162    /// first step wrapped as `wrap`.
163    ///
164    /// The shared preferences are resolved without writing anything
165    /// ([`Ecosystem::preferences_without_saving`]), so the appearance step comes filled with what the
166    /// ecosystem already shares, or with what this machine detects, and the user's settings folder
167    /// stays as it is until the wizard finishes. An application that has taken over an older
168    /// settings file migrates it ([`Ecosystem::adopt`](Ecosystem::adopt)) before making this, so a
169    /// migrated application is not asked again.
170    #[must_use]
171    pub fn new(
172        ecosystem: Ecosystem,
173        app: impl Into<String>,
174        i18n: &I18n,
175        wrap: impl Fn(SetupMsg) -> Msg + Send + Sync + 'static,
176    ) -> Self {
177        let app = app.into();
178        let preferences = ecosystem.preferences_without_saving(&app, i18n);
179        let own_file = ecosystem.config_dir().map(|dir| dir.join(format!("{app}.conf")));
180        let needed = own_file.is_none_or(|file| !set_up(&file));
181        Self::build(ecosystem, app, None, preferences, needed, wrap)
182    }
183
184    /// [`new`](Self::new) with `config_dir` as the ecosystem's folder instead of this platform's, for
185    /// a test or a demo that must leave the user's own files alone.
186    #[must_use]
187    pub fn new_in(
188        config_dir: &Path,
189        ecosystem: Ecosystem,
190        app: impl Into<String>,
191        i18n: &I18n,
192        wrap: impl Fn(SetupMsg) -> Msg + Send + Sync + 'static,
193    ) -> Self {
194        let app = app.into();
195        let preferences = ecosystem.preferences_without_saving_in(config_dir, &app, i18n);
196        let needed = !set_up(&config_dir.join(format!("{app}.conf")));
197        Self::build(ecosystem, app, Some(config_dir.to_path_buf()), preferences, needed, wrap)
198    }
199
200    fn build(
201        ecosystem: Ecosystem,
202        app: String,
203        folder: Option<PathBuf>,
204        preferences: Preferences,
205        needed: bool,
206        wrap: impl Fn(SetupMsg) -> Msg + Send + Sync + 'static,
207    ) -> Self {
208        let mut appearance = Appearance::new(ecosystem, app.clone(), preferences).without_saving();
209        if let Some(folder) = &folder {
210            appearance = appearance.in_folder(folder);
211        }
212        let font_dirs = crate::icons::default_font_dirs(|name| std::env::var(name).ok());
213        let installed = nerd_font::installed_in(&font_dirs);
214        // Without a file of its own the application's keys can only follow the shared file or be
215        // detected; all three following means the shared file answers every row of the step.
216        let answered = ASKED.iter().all(|key| appearance.preferences().source(*key) == Source::Ecosystem);
217        let first = usize::from(needed && answered);
218        Self {
219            ecosystem,
220            app,
221            folder,
222            appearance,
223            step: first,
224            first,
225            needed,
226            install: Install::new(),
227            font_dirs,
228            installed,
229            progress: None,
230            wrap: Arc::new(wrap),
231            on_finish: None,
232            failure: None,
233        }
234    }
235
236    /// The message the application is sent once the wizard has written the shared keys and made
237    /// the application's file: where the application writes its own keys.
238    #[must_use]
239    pub fn on_finish(mut self, message: Msg) -> Self {
240        self.on_finish = Some(message);
241        self
242    }
243
244    /// Installs the Nerd Font symbols with `install` instead of [`Install::new`], for a test or a
245    /// demo that must leave the user's own fonts alone.
246    #[must_use]
247    pub fn install(mut self, install: Install) -> Self {
248        self.install = install;
249        self
250    }
251
252    /// Looks for a Nerd Font in `dirs` instead of this system's font folders, for a test or a demo.
253    #[must_use]
254    pub fn font_dirs(mut self, dirs: Vec<PathBuf>) -> Self {
255        self.installed = nerd_font::installed_in(&dirs);
256        self.font_dirs = dirs;
257        self
258    }
259
260    /// Says that the application adds no steps of its own, so a wizard whose appearance step is
261    /// answered by the shared file would have nothing to ask. Then it is finished here and now: the
262    /// application's file is written following the ecosystem for language, theme and icons, so
263    /// the question is not asked again, and [`needed`](Self::needed) is false. The message of
264    /// [`on_finish`](Self::on_finish) is not sent; the application's settings, loaded after this,
265    /// read the file as it now is.
266    ///
267    /// Without a whole shared file the wizard is needed as before, with the appearance step
268    /// alone. A write that fails leaves it needed too, on the appearance step and saying why.
269    #[must_use]
270    pub fn appearance_only(mut self) -> Self {
271        if self.needed && self.first == 1 {
272            match self.write(&mut Settings::in_memory()) {
273                Ok(()) => self.needed = false,
274                Err(error) => {
275                    self.first = 0;
276                    self.step = 0;
277                    self.failure = Some(error.to_string());
278                }
279            }
280        }
281        self
282    }
283
284    /// Whether the wizard is still to be shown: the application has no settings of its own (no
285    /// file, or one holding nothing but the mark [`Ecosystem::settle`] leaves), the wizard has not
286    /// finished, and [`appearance_only`](Self::appearance_only) did not find the question
287    /// answered already.
288    #[must_use]
289    pub fn needed(&self) -> bool {
290        self.needed
291    }
292
293    /// Whether the appearance step is shown. False when the shared file already holds a
294    /// language, a theme and icons: the wizard then opens on the application's first step, 1.
295    #[must_use]
296    pub fn asks_appearance(&self) -> bool {
297        self.first == 0
298    }
299
300    /// The step the wizard is on, counting the appearance step as 0 whether or not it is shown.
301    #[must_use]
302    pub fn step(&self) -> usize {
303        self.step
304    }
305
306    /// The shared preferences as the appearance step has them now, before anything is written.
307    #[must_use]
308    pub fn preferences(&self) -> &Preferences {
309        self.appearance.preferences()
310    }
311
312    /// Applies `message` and returns the command that shows it: a theme, a language or an icon
313    /// mode is applied at once, so the wizard is drawn the way the user just chose. `settings` are
314    /// the application's own settings as it holds them in memory; they take every shared key too,
315    /// so a later [`Settings::save`] writes what the wizard wrote instead of what the file said
316    /// before.
317    ///
318    /// On [`SetupMsg::Finish`] the three shared keys are written with [`Ecosystem::set`], each to the
319    /// ecosystem's file or the application's own by its box, and the application's file is made. Only
320    /// then is the wizard over and the message of [`Setup::on_finish`] sent. A write that fails
321    /// leaves the wizard open and says why.
322    pub fn update(&mut self, message: SetupMsg, settings: &mut Settings) -> Command<Msg> {
323        match message {
324            SetupMsg::Appearance(change) => self.appearance.update(change, settings),
325            SetupMsg::Install => {
326                let wrap = Arc::clone(&self.wrap);
327                self.progress = Some(Progress::Downloading { fraction: None });
328                // The task is built here, where the language is known: its own thread has none.
329                Command::task(self.install.clone().task(move |progress| wrap(SetupMsg::Installing(progress))))
330            }
331            SetupMsg::Installing(progress) => {
332                if matches!(progress, Progress::Done { .. }) {
333                    // Look again, so the offer and the samples read what is on disk now.
334                    self.installed = nerd_font::installed_in(&self.font_dirs);
335                }
336                self.progress = Some(progress);
337                Command::none()
338            }
339            SetupMsg::Back => {
340                self.step = self.step.saturating_sub(1).max(self.first);
341                Command::none()
342            }
343            SetupMsg::Next => {
344                self.step += 1;
345                Command::none()
346            }
347            SetupMsg::Step(step) => {
348                // Only a finished step can be gone back to; the steps on top offer no other. They
349                // count from the first step shown.
350                self.step = (step + self.first).min(self.step);
351                Command::none()
352            }
353            SetupMsg::Finish => match self.write(settings) {
354                Ok(()) => {
355                    self.needed = false;
356                    self.failure = None;
357                    match self.on_finish.clone() {
358                        Some(message) => Command::perform(move || message),
359                        None => Command::none(),
360                    }
361                }
362                Err(error) => {
363                    self.failure = Some(error.to_string());
364                    Command::none()
365                }
366            },
367        }
368    }
369
370    /// Writes the three shared keys the step asks, each where its box says, which makes both
371    /// files. Reduced motion is not asked; missing from the application's file, it follows the
372    /// ecosystem.
373    fn write(&self, settings: &mut Settings) -> io::Result<()> {
374        for key in ASKED {
375            let value = self.preferences().text(key);
376            let scope = if self.preferences().source(key) == Source::App { Scope::App } else { Scope::Ecosystem };
377            // The file says plainly where the value comes from: the value itself, or the ecosystem.
378            let written = match scope {
379                Scope::Ecosystem => self.ecosystem.id().to_owned(),
380                Scope::App => value.clone(),
381            };
382            settings.set(key.key(), written);
383            match &self.folder {
384                Some(folder) => self.ecosystem.set_in(folder, &self.app, key, &value, scope)?,
385                None => self.ecosystem.set(&self.app, key, &value, scope)?,
386            }
387        }
388        Ok(())
389    }
390
391    fn send(&self, message: SetupMsg) -> Msg {
392        (self.wrap)(message)
393    }
394}
395
396/// The first-run wizard: the appearance step the framework draws and drives, then a step for each
397/// one the application adds, on the [`Wizard`] every other flow uses.
398///
399/// The first step asks for the language, the theme and the icons as
400/// [`Appearance`] rows, each with its "In every Quvyta application" box, and shows the same icons
401/// in all three glyph modes so the user chooses by eye. Without a Nerd Font on the machine it
402/// offers to install the symbols, shows how far the install is and, once it is done, what to look
403/// at ([`nerd_font::after_install_text`]). Under the rows, "Start with the defaults" writes what is
404/// filled in and ends the wizard.
405///
406/// Build it in `view` from the [`Setup`] the application holds; see [`Setup`] for the whole of it.
407pub struct SetupWizard<'a, Msg> {
408    setup: &'a Setup<Msg>,
409    steps: Vec<AppStep<'a, Msg>>,
410    on_cancel: Option<Msg>,
411    page_height: Option<u16>,
412}
413
414impl<'a, Msg: Clone + Send + 'static> SetupWizard<'a, Msg> {
415    /// The wizard of `setup`, with the appearance step alone.
416    #[must_use]
417    pub fn new(setup: &'a Setup<Msg>) -> Self {
418        Self { setup, steps: Vec::new(), on_cancel: None, page_height: None }
419    }
420
421    /// Adds a step of the application's own, named `title`, whose page is built by `page` and
422    /// whose messages are the application's. Steps come in the order they are added, after the
423    /// appearance step.
424    #[must_use]
425    pub fn step(mut self, title: impl Into<String>, page: impl FnOnce(&mut View<'_, Msg>) + 'a) -> Self {
426        self.steps.push((title.into(), Box::new(page)));
427        self
428    }
429
430    /// Adds a Cancel button, and makes Esc inside the wizard send `message` too. Closing the
431    /// wizard writes nothing at all, so the application usually quits on it and the wizard comes
432    /// again next start.
433    #[must_use]
434    pub fn on_cancel(mut self, message: Msg) -> Self {
435        self.on_cancel = Some(message);
436        self
437    }
438
439    /// Gives every step exactly `rows` rows, so the buttons stay put between steps.
440    #[must_use]
441    pub fn page_height(mut self, rows: u16) -> Self {
442        self.page_height = Some(rows);
443        self
444    }
445
446    /// Adds the wizard to `ui`.
447    pub fn show<'v>(self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
448        let setup = self.setup;
449        let mut labels = Vec::new();
450        if setup.asks_appearance() {
451            labels.push(crate::t!("quvyta.appearance.heading"));
452        }
453        let mut pages = Vec::new();
454        for (title, page) in self.steps {
455            labels.push(title);
456            pages.push(page);
457        }
458        let mut wizard = Wizard::new(labels)
459            .current(setup.step - setup.first)
460            .on_back(setup.send(SetupMsg::Back))
461            .on_next(setup.send(SetupMsg::Next))
462            .on_finish(setup.send(SetupMsg::Finish))
463            .on_step({
464                let wrap = Arc::clone(&setup.wrap);
465                move |step| wrap(SetupMsg::Step(step))
466            });
467        if let Some(message) = self.on_cancel {
468            wizard = wizard.on_cancel(message);
469        }
470        if let Some(rows) = self.page_height {
471            wizard = wizard.page_height(rows);
472        }
473        wizard.show(ui, |ui| match setup.step.checked_sub(1) {
474            None => appearance_step(setup, ui),
475            Some(index) => {
476                if let Some(page) = pages.into_iter().nth(index) {
477                    page(ui);
478                }
479                // Finish is pressed on the last of these steps; without the appearance step
480                // there is no other place to say why it could not be saved.
481                failure(setup, ui);
482            }
483        })
484    }
485}
486
487/// The step the framework draws: the shared rows, the glyph samples, the font install and the way
488/// out through the defaults.
489fn appearance_step<Msg: Clone + Send + 'static>(setup: &Setup<Msg>, ui: &mut View<'_, Msg>) {
490    let wrap = Arc::clone(&setup.wrap);
491    SettingsList::show(ui, |list| {
492        setup.appearance.rows(list, move |change| wrap(SetupMsg::Appearance(change)));
493    })
494    .fill_width()
495    .id("setup-appearance");
496
497    ui.spacer().height(Length::Cells(1));
498    ui.add(Text::new(crate::t!("quvyta.setup.sample-hint")).role("secondary")).fill_width();
499    for (mode, name) in [(GlyphMode::Nerd, "nerd"), (GlyphMode::Unicode, "unicode"), (GlyphMode::Ascii, "ascii")] {
500        ui.row(|ui| {
501            let label = crate::t!(&format!("quvyta.appearance.icons-{name}"));
502            ui.add(Text::new(label).role("secondary").no_wrap()).width(Length::Cells(SAMPLE_LABEL));
503            ui.add(GlyphSample::new(mode)).id(format!("setup-sample-{name}"));
504        })
505        .fill_width();
506    }
507
508    if !setup.installed {
509        ui.spacer().height(Length::Cells(1));
510        ui.add(Text::new(nerd_font::status_text(false)).role("secondary")).fill_width().id("setup-font-status");
511        ui.add(Button::new(crate::t!("quvyta.setup.install")).on_press(setup.send(SetupMsg::Install)))
512            .id("setup-install");
513    }
514    install_progress(setup, ui);
515    failure(setup, ui);
516    ui.spacer().height(Length::Cells(1));
517    ui.add(Button::new(crate::t!("quvyta.setup.defaults")).on_press(setup.send(SetupMsg::Finish))).id("setup-defaults");
518}
519
520/// Why finishing could not be saved, until the next try.
521fn failure<Msg: Clone + Send + 'static>(setup: &Setup<Msg>, ui: &mut View<'_, Msg>) {
522    if let Some(reason) = &setup.failure {
523        let mark = ui.env().icons().glyph("warning").into_owned();
524        let reason = crate::t!("quvyta.setup.not-saved", reason = reason.as_str());
525        ui.add(Text::new(format!("{mark} {reason}")).color("danger")).fill_width().id("setup-failure");
526    }
527}
528
529/// Whether the application's own file at `path` counts as a setup: any file but one holding the
530/// mark [`Ecosystem::settle`] leaves in a file it had to make, and nothing else. An empty file the
531/// application wrote itself counts, as it always has. A file that cannot be read counts too, so a
532/// broken file is repaired by the application rather than asked over.
533fn set_up(path: &Path) -> bool {
534    if !path.is_file() {
535        return false;
536    }
537    let settings = Settings::open(path);
538    let mut keys = settings.keys().peekable();
539    !settings.diagnostics().is_empty() || keys.peek().is_none() || keys.any(|key| key != Settings::SHARED_CHECKED)
540}
541
542/// How far the font install is, and the honest word once it is done.
543fn install_progress<Msg: Clone + Send + 'static>(setup: &Setup<Msg>, ui: &mut View<'_, Msg>) {
544    let Some(progress) = &setup.progress else { return };
545    match progress {
546        Progress::Downloading { fraction: Some(fraction) } => {
547            ui.add(ProgressBar::new(*fraction).percent(true)).fill_width().id("setup-install-bar");
548        }
549        Progress::Downloading { fraction: None } | Progress::Verifying | Progress::Installing => {
550            ui.add(ProgressBar::indeterminate()).fill_width().id("setup-install-bar");
551        }
552        Progress::Done { .. } | Progress::Failed(_) => {}
553    }
554    ui.add(Text::new(progress.text()).role("secondary")).fill_width().id("setup-install-step");
555    if matches!(progress, Progress::Done { .. }) {
556        ui.add(Text::new(nerd_font::after_install_text())).fill_width().id("setup-after-install");
557    }
558}
559
560#[cfg(test)]
561#[path = "setup_tests.rs"]
562mod tests;