use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::i18n::I18n;
use crate::icons::nerd_font::{self, Install, Progress};
use crate::icons::{GlyphMode, GlyphSample};
use crate::runtime::Command;
use crate::storage::{Ecosystem, Preferences, Scope, Settings, Shared, Source};
use crate::widget::{Length, NodeMut, View};
use super::{Appearance, AppearanceChange, Button, ProgressBar, SettingsList, Text, Wizard};
const ASKED: [Shared; 3] = [Shared::Language, Shared::Theme, Shared::Icons];
type AppStep<'a, Msg> = (String, Box<dyn FnOnce(&mut View<'_, Msg>) + 'a>);
const SAMPLE_LABEL: u16 = 12;
#[derive(Debug, Clone, PartialEq)]
pub enum SetupMsg {
Appearance(AppearanceChange),
Install,
Installing(Progress),
Back,
Next,
Step(usize),
Finish,
}
pub struct Setup<Msg> {
ecosystem: Ecosystem,
app: String,
folder: Option<PathBuf>,
appearance: Appearance,
step: usize,
first: usize,
needed: bool,
install: Install,
font_dirs: Vec<PathBuf>,
installed: bool,
progress: Option<Progress>,
wrap: Arc<dyn Fn(SetupMsg) -> Msg + Send + Sync>,
on_finish: Option<Msg>,
failure: Option<String>,
}
impl<Msg> std::fmt::Debug for Setup<Msg> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Setup")
.field("app", &self.app)
.field("folder", &self.folder)
.field("step", &self.step)
.field("needed", &self.needed)
.field("installed", &self.installed)
.field("progress", &self.progress)
.field("failure", &self.failure)
.finish_non_exhaustive()
}
}
impl<Msg: Clone + Send + 'static> Setup<Msg> {
#[must_use]
pub fn new(
ecosystem: Ecosystem,
app: impl Into<String>,
i18n: &I18n,
wrap: impl Fn(SetupMsg) -> Msg + Send + Sync + 'static,
) -> Self {
let app = app.into();
let preferences = ecosystem.preferences_without_saving(&app, i18n);
let own_file = ecosystem.config_dir().map(|dir| dir.join(format!("{app}.conf")));
let needed = own_file.is_none_or(|file| !set_up(&file));
Self::build(ecosystem, app, None, preferences, needed, wrap)
}
#[must_use]
pub fn new_in(
config_dir: &Path,
ecosystem: Ecosystem,
app: impl Into<String>,
i18n: &I18n,
wrap: impl Fn(SetupMsg) -> Msg + Send + Sync + 'static,
) -> Self {
let app = app.into();
let preferences = ecosystem.preferences_without_saving_in(config_dir, &app, i18n);
let needed = !set_up(&config_dir.join(format!("{app}.conf")));
Self::build(ecosystem, app, Some(config_dir.to_path_buf()), preferences, needed, wrap)
}
fn build(
ecosystem: Ecosystem,
app: String,
folder: Option<PathBuf>,
preferences: Preferences,
needed: bool,
wrap: impl Fn(SetupMsg) -> Msg + Send + Sync + 'static,
) -> Self {
let mut appearance = Appearance::new(ecosystem, app.clone(), preferences).without_saving();
if let Some(folder) = &folder {
appearance = appearance.in_folder(folder);
}
let font_dirs = crate::icons::default_font_dirs(|name| std::env::var(name).ok());
let installed = nerd_font::installed_in(&font_dirs);
let answered = ASKED.iter().all(|key| appearance.preferences().source(*key) == Source::Ecosystem);
let first = usize::from(needed && answered);
Self {
ecosystem,
app,
folder,
appearance,
step: first,
first,
needed,
install: Install::new(),
font_dirs,
installed,
progress: None,
wrap: Arc::new(wrap),
on_finish: None,
failure: None,
}
}
#[must_use]
pub fn on_finish(mut self, message: Msg) -> Self {
self.on_finish = Some(message);
self
}
#[must_use]
pub fn install(mut self, install: Install) -> Self {
self.install = install;
self
}
#[must_use]
pub fn font_dirs(mut self, dirs: Vec<PathBuf>) -> Self {
self.installed = nerd_font::installed_in(&dirs);
self.font_dirs = dirs;
self
}
#[must_use]
pub fn appearance_only(mut self) -> Self {
if self.needed && self.first == 1 {
match self.write(&mut Settings::in_memory()) {
Ok(()) => self.needed = false,
Err(error) => {
self.first = 0;
self.step = 0;
self.failure = Some(error.to_string());
}
}
}
self
}
#[must_use]
pub fn needed(&self) -> bool {
self.needed
}
#[must_use]
pub fn asks_appearance(&self) -> bool {
self.first == 0
}
#[must_use]
pub fn step(&self) -> usize {
self.step
}
#[must_use]
pub fn preferences(&self) -> &Preferences {
self.appearance.preferences()
}
pub fn update(&mut self, message: SetupMsg, settings: &mut Settings) -> Command<Msg> {
match message {
SetupMsg::Appearance(change) => self.appearance.update(change, settings),
SetupMsg::Install => {
let wrap = Arc::clone(&self.wrap);
self.progress = Some(Progress::Downloading { fraction: None });
Command::task(self.install.clone().task(move |progress| wrap(SetupMsg::Installing(progress))))
}
SetupMsg::Installing(progress) => {
if matches!(progress, Progress::Done { .. }) {
self.installed = nerd_font::installed_in(&self.font_dirs);
}
self.progress = Some(progress);
Command::none()
}
SetupMsg::Back => {
self.step = self.step.saturating_sub(1).max(self.first);
Command::none()
}
SetupMsg::Next => {
self.step += 1;
Command::none()
}
SetupMsg::Step(step) => {
self.step = (step + self.first).min(self.step);
Command::none()
}
SetupMsg::Finish => match self.write(settings) {
Ok(()) => {
self.needed = false;
self.failure = None;
match self.on_finish.clone() {
Some(message) => Command::perform(move || message),
None => Command::none(),
}
}
Err(error) => {
self.failure = Some(error.to_string());
Command::none()
}
},
}
}
fn write(&self, settings: &mut Settings) -> io::Result<()> {
for key in ASKED {
let value = self.preferences().text(key);
let scope = if self.preferences().source(key) == Source::App { Scope::App } else { Scope::Ecosystem };
let written = match scope {
Scope::Ecosystem => self.ecosystem.id().to_owned(),
Scope::App => value.clone(),
};
settings.set(key.key(), written);
match &self.folder {
Some(folder) => self.ecosystem.set_in(folder, &self.app, key, &value, scope)?,
None => self.ecosystem.set(&self.app, key, &value, scope)?,
}
}
Ok(())
}
fn send(&self, message: SetupMsg) -> Msg {
(self.wrap)(message)
}
}
pub struct SetupWizard<'a, Msg> {
setup: &'a Setup<Msg>,
steps: Vec<AppStep<'a, Msg>>,
on_cancel: Option<Msg>,
page_height: Option<u16>,
}
impl<'a, Msg: Clone + Send + 'static> SetupWizard<'a, Msg> {
#[must_use]
pub fn new(setup: &'a Setup<Msg>) -> Self {
Self { setup, steps: Vec::new(), on_cancel: None, page_height: None }
}
#[must_use]
pub fn step(mut self, title: impl Into<String>, page: impl FnOnce(&mut View<'_, Msg>) + 'a) -> Self {
self.steps.push((title.into(), Box::new(page)));
self
}
#[must_use]
pub fn on_cancel(mut self, message: Msg) -> Self {
self.on_cancel = Some(message);
self
}
#[must_use]
pub fn page_height(mut self, rows: u16) -> Self {
self.page_height = Some(rows);
self
}
pub fn show<'v>(self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
let setup = self.setup;
let mut labels = Vec::new();
if setup.asks_appearance() {
labels.push(crate::t!("quvyta.appearance.heading"));
}
let mut pages = Vec::new();
for (title, page) in self.steps {
labels.push(title);
pages.push(page);
}
let mut wizard = Wizard::new(labels)
.current(setup.step - setup.first)
.on_back(setup.send(SetupMsg::Back))
.on_next(setup.send(SetupMsg::Next))
.on_finish(setup.send(SetupMsg::Finish))
.on_step({
let wrap = Arc::clone(&setup.wrap);
move |step| wrap(SetupMsg::Step(step))
});
if let Some(message) = self.on_cancel {
wizard = wizard.on_cancel(message);
}
if let Some(rows) = self.page_height {
wizard = wizard.page_height(rows);
}
wizard.show(ui, |ui| match setup.step.checked_sub(1) {
None => appearance_step(setup, ui),
Some(index) => {
if let Some(page) = pages.into_iter().nth(index) {
page(ui);
}
failure(setup, ui);
}
})
}
}
fn appearance_step<Msg: Clone + Send + 'static>(setup: &Setup<Msg>, ui: &mut View<'_, Msg>) {
let wrap = Arc::clone(&setup.wrap);
SettingsList::show(ui, |list| {
setup.appearance.rows(list, move |change| wrap(SetupMsg::Appearance(change)));
})
.fill_width()
.id("setup-appearance");
ui.spacer().height(Length::Cells(1));
ui.add(Text::new(crate::t!("quvyta.setup.sample-hint")).role("secondary")).fill_width();
for (mode, name) in [(GlyphMode::Nerd, "nerd"), (GlyphMode::Unicode, "unicode"), (GlyphMode::Ascii, "ascii")] {
ui.row(|ui| {
let label = crate::t!(&format!("quvyta.appearance.icons-{name}"));
ui.add(Text::new(label).role("secondary").no_wrap()).width(Length::Cells(SAMPLE_LABEL));
ui.add(GlyphSample::new(mode)).id(format!("setup-sample-{name}"));
})
.fill_width();
}
if !setup.installed {
ui.spacer().height(Length::Cells(1));
ui.add(Text::new(nerd_font::status_text(false)).role("secondary")).fill_width().id("setup-font-status");
ui.add(Button::new(crate::t!("quvyta.setup.install")).on_press(setup.send(SetupMsg::Install)))
.id("setup-install");
}
install_progress(setup, ui);
failure(setup, ui);
ui.spacer().height(Length::Cells(1));
ui.add(Button::new(crate::t!("quvyta.setup.defaults")).on_press(setup.send(SetupMsg::Finish))).id("setup-defaults");
}
fn failure<Msg: Clone + Send + 'static>(setup: &Setup<Msg>, ui: &mut View<'_, Msg>) {
if let Some(reason) = &setup.failure {
let mark = ui.env().icons().glyph("warning").into_owned();
let reason = crate::t!("quvyta.setup.not-saved", reason = reason.as_str());
ui.add(Text::new(format!("{mark} {reason}")).color("danger")).fill_width().id("setup-failure");
}
}
fn set_up(path: &Path) -> bool {
if !path.is_file() {
return false;
}
let settings = Settings::open(path);
let mut keys = settings.keys().peekable();
!settings.diagnostics().is_empty() || keys.peek().is_none() || keys.any(|key| key != Settings::SHARED_CHECKED)
}
fn install_progress<Msg: Clone + Send + 'static>(setup: &Setup<Msg>, ui: &mut View<'_, Msg>) {
let Some(progress) = &setup.progress else { return };
match progress {
Progress::Downloading { fraction: Some(fraction) } => {
ui.add(ProgressBar::new(*fraction).percent(true)).fill_width().id("setup-install-bar");
}
Progress::Downloading { fraction: None } | Progress::Verifying | Progress::Installing => {
ui.add(ProgressBar::indeterminate()).fill_width().id("setup-install-bar");
}
Progress::Done { .. } | Progress::Failed(_) => {}
}
ui.add(Text::new(progress.text()).role("secondary")).fill_width().id("setup-install-step");
if matches!(progress, Progress::Done { .. }) {
ui.add(Text::new(nerd_font::after_install_text())).fill_width().id("setup-after-install");
}
}
#[cfg(test)]
#[path = "setup_tests.rs"]
mod tests;