1use std::io;
6use std::path::PathBuf;
7
8use crate::icons::{IconMode, PillarStyle};
9use crate::runtime::Command;
10use crate::storage::{Ecosystem, Preferences, Scope, Setting, Settings, Shared, Source};
11use crate::widget::Length;
12
13use super::{Checkbox, Segmented, Select, SettingRow, SettingsRows, Switch};
14
15const CHOICE_MIN: u16 = 18;
18
19const CHOICE_MAX: u16 = 28;
23
24fn choice_width(names: &[String], padding: u16) -> u16 {
28 let longest = names.iter().map(|name| crate::text::width(name)).max().unwrap_or(0);
29 crate::widgets::cells::sum([longest, 3, padding.saturating_mul(2)]).clamp(CHOICE_MIN, CHOICE_MAX)
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum AppearanceChange {
36 Language(String),
38 Theme(String),
40 Icons(IconMode),
42 Everywhere(Shared, bool),
45 ReducedMotion(bool),
47 Pillar(PillarStyle),
49 UpdateNotice(bool),
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56enum Row {
57 Shared(Shared),
58 Pillar,
59 UpdateNotice,
60}
61
62#[derive(Debug, Clone)]
115pub struct Appearance {
116 ecosystem: Ecosystem,
117 app: String,
118 folder: Option<PathBuf>,
119 preferences: Preferences,
120 saving: bool,
122 failure: Option<(Row, String)>,
123}
124
125impl Appearance {
126 #[must_use]
129 pub fn new(ecosystem: Ecosystem, app: impl Into<String>, preferences: Preferences) -> Self {
130 Self { ecosystem, app: app.into(), folder: None, preferences, saving: true, failure: None }
131 }
132
133 #[must_use]
136 pub fn in_folder(mut self, folder: impl Into<PathBuf>) -> Self {
137 self.folder = Some(folder.into());
138 self
139 }
140
141 #[must_use]
148 pub fn without_saving(mut self) -> Self {
149 self.saving = false;
150 self
151 }
152
153 #[must_use]
155 pub fn preferences(&self) -> &Preferences {
156 &self.preferences
157 }
158
159 pub fn refresh(&mut self, preferences: Preferences) {
169 self.preferences = preferences;
170 }
171
172 pub fn section<Msg: Clone + 'static>(
175 &self,
176 list: &mut SettingsRows<'_, Msg>,
177 message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
178 ) {
179 list.heading(crate::t!("quvyta.appearance.heading"));
180 self.rows(list, message.clone());
181 self.motion_and_pillar(list, message);
182 }
183
184 pub fn updates<Msg: Clone + 'static>(
191 &self,
192 list: &mut SettingsRows<'_, Msg>,
193 message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
194 ) {
195 let row = SettingRow::new(crate::t!("quvyta.appearance.updates"));
196 let row = match self.failed(Row::UpdateNotice) {
197 Some(failure) => row.description(failure),
198 None => row.description(crate::t!("quvyta.appearance.updates-text", family = self.ecosystem.title())),
199 };
200 let on = self.preferences.update_notice();
201 list.row(row, |ui| {
202 ui.add(Switch::new(on).on_toggle(move |on| message(AppearanceChange::UpdateNotice(on))));
203 });
204 }
205
206 pub fn rows<Msg: Clone + 'static>(
211 &self,
212 list: &mut SettingsRows<'_, Msg>,
213 message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
214 ) {
215 let env = list.env();
216 let languages = env.i18n().list();
217 let active = env.i18n().active().to_owned();
218 let themes = env.themes();
219 let theme = env.theme().id().to_owned();
220 let icons = env.icon_mode();
221 let icon_names = IconMode::ALL.map(|mode| crate::t!(&format!("quvyta.appearance.icons-{}", mode.name())));
222
223 let padding = env.theme().style("select", None, &[]).pair("padding").map_or(1, |(_, horizontal)| horizontal);
229 let width = choice_width(
230 &languages
231 .iter()
232 .map(|(_, name)| name.clone())
233 .chain(themes.iter().map(|(_, name)| name.clone()))
234 .chain(icon_names.iter().cloned())
235 .collect::<Vec<String>>(),
236 padding,
237 );
238
239 let codes: Vec<String> = languages.iter().map(|(code, _)| code.clone()).collect();
240 let chosen = codes.iter().position(|code| *code == active);
241 let send = message.clone();
242 list.row(self.row(Row::Shared(Shared::Language), crate::t!("quvyta.appearance.language")), |ui| {
243 let names = languages.into_iter().map(|(_, name)| name);
244 let select = Select::new(names)
245 .selected(chosen)
246 .on_select(move |index| send(AppearanceChange::Language(codes[index].clone())));
247 ui.add(select).width(Length::Cells(width));
248 });
249 self.everywhere(list, Shared::Language, false, &message);
250
251 let ids: Vec<String> = themes.iter().map(|(id, _)| id.clone()).collect();
252 let chosen = ids.iter().position(|id| *id == theme);
253 let send = message.clone();
254 list.row(self.row(Row::Shared(Shared::Theme), crate::t!("quvyta.appearance.theme")), |ui| {
255 let names = themes.into_iter().map(|(_, name)| name);
256 let select = Select::new(names)
257 .selected(chosen)
258 .on_select(move |index| send(AppearanceChange::Theme(ids[index].clone())));
259 ui.add(select).width(Length::Cells(width));
260 });
261 self.everywhere(list, Shared::Theme, false, &message);
262
263 let chosen = IconMode::ALL.iter().position(|mode| *mode == icons);
264 let send = message.clone();
265 list.row(self.row(Row::Shared(Shared::Icons), crate::t!("quvyta.appearance.icons")), |ui| {
266 let select = Select::new(icon_names)
267 .selected(chosen)
268 .on_select(move |index| send(AppearanceChange::Icons(IconMode::ALL[index])));
269 ui.add(select).width(Length::Cells(width));
270 });
271 self.everywhere(list, Shared::Icons, false, &message);
272 }
273
274 fn motion_and_pillar<Msg: Clone + 'static>(
276 &self,
277 list: &mut SettingsRows<'_, Msg>,
278 message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
279 ) {
280 let env = list.env();
281 let (reduced, forced) = (env.reduced_motion(), env.reduced_motion_forced());
282 let pillar = env.pillar_style().unwrap_or(PillarStyle::Thick);
283
284 let note = match (forced, reduced) {
285 (true, true) => crate::t!("quvyta.appearance.forced-on"),
286 (true, false) => crate::t!("quvyta.appearance.forced-off"),
287 (false, _) => crate::t!("quvyta.appearance.reduce-motion-text"),
288 };
289 let row = SettingRow::new(crate::t!("quvyta.appearance.reduce-motion")).disabled(forced);
290 let row = match self.failed(Row::Shared(Shared::ReducedMotion)) {
291 Some(failure) => row.description(failure),
292 None => row.description(note),
293 };
294 let send = message.clone();
295 list.row(row, |ui| {
296 ui.add(
297 Switch::new(reduced).disabled(forced).on_toggle(move |on| send(AppearanceChange::ReducedMotion(on))),
298 );
299 });
300 self.everywhere(list, Shared::ReducedMotion, forced, &message);
301
302 let styles = PillarStyle::ALL.map(|style| crate::t!(&format!("quvyta.appearance.pillar-{}", style.name())));
303 let chosen = PillarStyle::ALL.iter().position(|style| *style == pillar).unwrap_or(0);
304 list.row(self.row(Row::Pillar, crate::t!("quvyta.appearance.pillar")), |ui| {
305 let segmented = Segmented::new(styles)
306 .selected(chosen)
307 .on_select(move |index| message(AppearanceChange::Pillar(PillarStyle::ALL[index])));
308 ui.add(segmented);
309 });
310 }
311
312 fn row<Msg>(&self, row: Row, label: String) -> SettingRow<Msg> {
314 let setting = SettingRow::new(label);
315 match self.failed(row) {
316 Some(failure) => setting.description(failure),
317 None => setting,
318 }
319 }
320
321 fn failed(&self, row: Row) -> Option<String> {
323 self.failure
324 .as_ref()
325 .filter(|(failed, _)| *failed == row)
326 .map(|(_, reason)| crate::t!("quvyta.appearance.not-saved", reason = reason.as_str()))
327 }
328
329 fn everywhere<Msg: Clone + 'static>(
332 &self,
333 list: &mut SettingsRows<'_, Msg>,
334 key: Shared,
335 disabled: bool,
336 message: &(impl Fn(AppearanceChange) -> Msg + Clone + 'static),
337 ) {
338 let checked = self.preferences.source(key) != Source::App;
339 let label = crate::t!("quvyta.appearance.everywhere", family = self.ecosystem.title());
340 let send = message.clone();
341 list.row(SettingRow::new(label).nested(true).disabled(disabled), |ui| {
342 ui.add(
343 Checkbox::new(checked)
344 .disabled(disabled)
345 .on_toggle(move |on| send(AppearanceChange::Everywhere(key, on))),
346 );
347 });
348 }
349
350 pub fn update<Msg: Send + 'static>(&mut self, change: AppearanceChange, settings: &mut Settings) -> Command<Msg> {
357 let (row, saved, command) = match change {
358 AppearanceChange::Language(code) => {
359 let saved = self.share(Shared::Language, &code, None, settings);
360 (Row::Shared(Shared::Language), saved, Command::set_locale(code))
361 }
362 AppearanceChange::Theme(id) => {
363 let saved = self.share(Shared::Theme, &id, None, settings);
364 (Row::Shared(Shared::Theme), saved, Command::set_theme(id))
365 }
366 AppearanceChange::Icons(mode) => {
367 let saved = self.share(Shared::Icons, mode.name(), None, settings);
368 (Row::Shared(Shared::Icons), saved, Command::set_icon_mode(mode))
369 }
370 AppearanceChange::Everywhere(key, on) => {
371 let scope = if on { Scope::Ecosystem } else { Scope::App };
372 let value = self.preferences.text(key);
373 (Row::Shared(key), self.share(key, &value, Some(scope), settings), Command::none())
374 }
375 AppearanceChange::ReducedMotion(on) => {
376 let saved = self.share(Shared::ReducedMotion, &on.to_string(), None, settings);
377 (Row::Shared(Shared::ReducedMotion), saved, Command::set_reduced_motion(on))
378 }
379 AppearanceChange::Pillar(style) => {
380 let saved = self.own(Settings::PILLAR, style.name().to_owned(), settings);
381 (Row::Pillar, saved, Command::set_pillar(style))
382 }
383 AppearanceChange::UpdateNotice(on) => (Row::UpdateNotice, self.update_notice(on), Command::none()),
384 };
385 self.failure = saved.err().map(|error| (row, error.to_string()));
386 command
387 }
388
389 fn share(&mut self, key: Shared, value: &str, scope: Option<Scope>, settings: &mut Settings) -> io::Result<()> {
392 let scope =
393 scope.unwrap_or(if self.preferences.source(key) == Source::App { Scope::App } else { Scope::Ecosystem });
394 let written = match scope {
395 Scope::Ecosystem => self.ecosystem.id().to_owned(),
396 Scope::App => value.to_owned(),
397 };
398 settings.store(key.key(), key.setting(&written));
399 let source = if scope == Scope::Ecosystem { Source::Ecosystem } else { Source::App };
400 self.preferences.record(key, value, source);
401 if !self.saving {
402 return Ok(());
403 }
404 match &self.folder {
405 Some(folder) => self.ecosystem.set_in(folder, &self.app, key, value, scope),
406 None => self.ecosystem.set(&self.app, key, value, scope),
407 }
408 }
409
410 fn update_notice(&mut self, on: bool) -> io::Result<()> {
412 self.preferences.record_update_notice(on);
413 if !self.saving {
414 return Ok(());
415 }
416 match &self.folder {
417 Some(folder) => self.ecosystem.set_update_notice_in(folder, on),
418 None => self.ecosystem.set_update_notice(on),
419 }
420 }
421
422 fn own<T: Setting + Clone>(&self, key: &str, value: T, settings: &mut Settings) -> io::Result<()> {
424 settings.set(key, value.clone());
425 if !self.saving {
426 return Ok(());
427 }
428 let folder = match &self.folder {
429 Some(folder) => folder.clone(),
430 None => self
431 .ecosystem
432 .config_dir()
433 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no config directory found"))?,
434 };
435 self.ecosystem.set_own_in(&folder, &self.app, key, value.to_setting())
436 }
437}
438
439#[cfg(test)]
440#[path = "appearance_tests.rs"]
441mod tests;