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;