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