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;