1use std::io;
6use std::path::PathBuf;
7
8use crate::icons::{IconMode, PillarStyle};
9use crate::runtime::Command;
10use crate::storage::{Family, 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 ReducedMotion,
59 Pillar,
60 UpdateNotice,
61}
62
63#[derive(Debug, Clone)]
116pub struct Appearance {
117 family: Family,
118 app: String,
119 folder: Option<PathBuf>,
120 preferences: Preferences,
121 saving: bool,
123 failure: Option<(Row, String)>,
124}
125
126impl Appearance {
127 #[must_use]
130 pub fn new(family: Family, app: impl Into<String>, preferences: Preferences) -> Self {
131 Self { family, app: app.into(), folder: None, preferences, saving: true, failure: None }
132 }
133
134 #[must_use]
137 pub fn in_folder(mut self, folder: impl Into<PathBuf>) -> Self {
138 self.folder = Some(folder.into());
139 self
140 }
141
142 #[must_use]
149 pub fn without_saving(mut self) -> Self {
150 self.saving = false;
151 self
152 }
153
154 #[must_use]
156 pub fn preferences(&self) -> &Preferences {
157 &self.preferences
158 }
159
160 pub fn section<Msg: Clone + 'static>(
163 &self,
164 list: &mut SettingsRows<'_, Msg>,
165 message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
166 ) {
167 list.heading(crate::t!("quvyta.appearance.heading"));
168 self.rows(list, message.clone());
169 self.own_rows(list, message);
170 }
171
172 pub fn updates<Msg: Clone + 'static>(
179 &self,
180 list: &mut SettingsRows<'_, Msg>,
181 message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
182 ) {
183 let row = SettingRow::new(crate::t!("quvyta.appearance.updates"));
184 let row = match self.failed(Row::UpdateNotice) {
185 Some(failure) => row.description(failure),
186 None => row.description(crate::t!("quvyta.appearance.updates-text", family = self.family.title())),
187 };
188 let on = self.preferences.update_notice();
189 list.row(row, |ui| {
190 ui.add(Switch::new(on).on_toggle(move |on| message(AppearanceChange::UpdateNotice(on))));
191 });
192 }
193
194 pub fn rows<Msg: Clone + 'static>(
199 &self,
200 list: &mut SettingsRows<'_, Msg>,
201 message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
202 ) {
203 let env = list.env();
204 let languages = env.i18n().list();
205 let active = env.i18n().active().to_owned();
206 let themes = env.themes();
207 let theme = env.theme().id().to_owned();
208 let icons = env.icon_mode();
209 let icon_names = IconMode::ALL.map(|mode| crate::t!(&format!("quvyta.appearance.icons-{}", mode.name())));
210
211 let padding = env.theme().style("select", None, &[]).pair("padding").map_or(1, |(_, horizontal)| horizontal);
217 let width = choice_width(
218 &languages
219 .iter()
220 .map(|(_, name)| name.clone())
221 .chain(themes.iter().map(|(_, name)| name.clone()))
222 .chain(icon_names.iter().cloned())
223 .collect::<Vec<String>>(),
224 padding,
225 );
226
227 let codes: Vec<String> = languages.iter().map(|(code, _)| code.clone()).collect();
228 let chosen = codes.iter().position(|code| *code == active);
229 let send = message.clone();
230 list.row(self.row(Row::Shared(Shared::Language), crate::t!("quvyta.appearance.language")), |ui| {
231 let names = languages.into_iter().map(|(_, name)| name);
232 let select = Select::new(names)
233 .selected(chosen)
234 .on_select(move |index| send(AppearanceChange::Language(codes[index].clone())));
235 ui.add(select).width(Length::Cells(width));
236 });
237 self.everywhere(list, Shared::Language, &message);
238
239 let ids: Vec<String> = themes.iter().map(|(id, _)| id.clone()).collect();
240 let chosen = ids.iter().position(|id| *id == theme);
241 let send = message.clone();
242 list.row(self.row(Row::Shared(Shared::Theme), crate::t!("quvyta.appearance.theme")), |ui| {
243 let names = themes.into_iter().map(|(_, name)| name);
244 let select = Select::new(names)
245 .selected(chosen)
246 .on_select(move |index| send(AppearanceChange::Theme(ids[index].clone())));
247 ui.add(select).width(Length::Cells(width));
248 });
249 self.everywhere(list, Shared::Theme, &message);
250
251 let chosen = IconMode::ALL.iter().position(|mode| *mode == icons);
252 let send = message.clone();
253 list.row(self.row(Row::Shared(Shared::Icons), crate::t!("quvyta.appearance.icons")), |ui| {
254 let select = Select::new(icon_names)
255 .selected(chosen)
256 .on_select(move |index| send(AppearanceChange::Icons(IconMode::ALL[index])));
257 ui.add(select).width(Length::Cells(width));
258 });
259 self.everywhere(list, Shared::Icons, &message);
260 }
261
262 fn own_rows<Msg: Clone + 'static>(
264 &self,
265 list: &mut SettingsRows<'_, Msg>,
266 message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
267 ) {
268 let env = list.env();
269 let (reduced, forced) = (env.reduced_motion(), env.reduced_motion_forced());
270 let pillar = env.pillar_style().unwrap_or(PillarStyle::Thick);
271
272 let note = match (forced, reduced) {
273 (true, true) => crate::t!("quvyta.appearance.forced-on"),
274 (true, false) => crate::t!("quvyta.appearance.forced-off"),
275 (false, _) => crate::t!("quvyta.appearance.reduce-motion-text"),
276 };
277 let row = SettingRow::new(crate::t!("quvyta.appearance.reduce-motion")).disabled(forced);
278 let row = match self.failed(Row::ReducedMotion) {
279 Some(failure) => row.description(failure),
280 None => row.description(note),
281 };
282 let send = message.clone();
283 list.row(row, |ui| {
284 ui.add(
285 Switch::new(reduced).disabled(forced).on_toggle(move |on| send(AppearanceChange::ReducedMotion(on))),
286 );
287 });
288
289 let styles = PillarStyle::ALL.map(|style| crate::t!(&format!("quvyta.appearance.pillar-{}", style.name())));
290 let chosen = PillarStyle::ALL.iter().position(|style| *style == pillar).unwrap_or(0);
291 list.row(self.row(Row::Pillar, crate::t!("quvyta.appearance.pillar")), |ui| {
292 let segmented = Segmented::new(styles)
293 .selected(chosen)
294 .on_select(move |index| message(AppearanceChange::Pillar(PillarStyle::ALL[index])));
295 ui.add(segmented);
296 });
297 }
298
299 fn row<Msg>(&self, row: Row, label: String) -> SettingRow<Msg> {
301 let setting = SettingRow::new(label);
302 match self.failed(row) {
303 Some(failure) => setting.description(failure),
304 None => setting,
305 }
306 }
307
308 fn failed(&self, row: Row) -> Option<String> {
310 self.failure
311 .as_ref()
312 .filter(|(failed, _)| *failed == row)
313 .map(|(_, reason)| crate::t!("quvyta.appearance.not-saved", reason = reason.as_str()))
314 }
315
316 fn everywhere<Msg: Clone + 'static>(
318 &self,
319 list: &mut SettingsRows<'_, Msg>,
320 key: Shared,
321 message: &(impl Fn(AppearanceChange) -> Msg + Clone + 'static),
322 ) {
323 let checked = self.preferences.source(key) != Source::App;
324 let label = crate::t!("quvyta.appearance.everywhere", family = self.family.title());
325 let send = message.clone();
326 list.row(SettingRow::new(label).nested(true), |ui| {
327 ui.add(Checkbox::new(checked).on_toggle(move |on| send(AppearanceChange::Everywhere(key, on))));
328 });
329 }
330
331 pub fn update<Msg: Send + 'static>(&mut self, change: AppearanceChange, settings: &mut Settings) -> Command<Msg> {
338 let (row, saved, command) = match change {
339 AppearanceChange::Language(code) => {
340 let saved = self.share(Shared::Language, &code, None, settings);
341 (Row::Shared(Shared::Language), saved, Command::set_locale(code))
342 }
343 AppearanceChange::Theme(id) => {
344 let saved = self.share(Shared::Theme, &id, None, settings);
345 (Row::Shared(Shared::Theme), saved, Command::set_theme(id))
346 }
347 AppearanceChange::Icons(mode) => {
348 let saved = self.share(Shared::Icons, mode.name(), None, settings);
349 (Row::Shared(Shared::Icons), saved, Command::set_icon_mode(mode))
350 }
351 AppearanceChange::Everywhere(key, on) => {
352 let scope = if on { Scope::Family } else { Scope::App };
353 let value = self.preferences.text(key);
354 (Row::Shared(key), self.share(key, &value, Some(scope), settings), Command::none())
355 }
356 AppearanceChange::ReducedMotion(on) => {
357 let saved = self.own(Settings::REDUCED_MOTION, on, settings);
358 (Row::ReducedMotion, saved, Command::set_reduced_motion(on))
359 }
360 AppearanceChange::Pillar(style) => {
361 let saved = self.own(Settings::PILLAR, style.name().to_owned(), settings);
362 (Row::Pillar, saved, Command::set_pillar(style))
363 }
364 AppearanceChange::UpdateNotice(on) => (Row::UpdateNotice, self.update_notice(on), Command::none()),
365 };
366 self.failure = saved.err().map(|error| (row, error.to_string()));
367 command
368 }
369
370 fn share(&mut self, key: Shared, value: &str, scope: Option<Scope>, settings: &mut Settings) -> io::Result<()> {
373 let scope =
374 scope.unwrap_or(if self.preferences.source(key) == Source::App { Scope::App } else { Scope::Family });
375 let written = match scope {
376 Scope::Family => self.family.id().to_owned(),
377 Scope::App => value.to_owned(),
378 };
379 settings.set(key.key(), written);
380 let source = if scope == Scope::Family { Source::Family } else { Source::App };
381 self.preferences.record(key, value, source);
382 if !self.saving {
383 return Ok(());
384 }
385 match &self.folder {
386 Some(folder) => self.family.set_in(folder, &self.app, key, value, scope),
387 None => self.family.set(&self.app, key, value, scope),
388 }
389 }
390
391 fn update_notice(&mut self, on: bool) -> io::Result<()> {
393 self.preferences.record_update_notice(on);
394 if !self.saving {
395 return Ok(());
396 }
397 match &self.folder {
398 Some(folder) => self.family.set_update_notice_in(folder, on),
399 None => self.family.set_update_notice(on),
400 }
401 }
402
403 fn own<T: Setting + Clone>(&self, key: &str, value: T, settings: &mut Settings) -> io::Result<()> {
405 settings.set(key, value.clone());
406 if !self.saving {
407 return Ok(());
408 }
409 let folder = match &self.folder {
410 Some(folder) => folder.clone(),
411 None => self
412 .family
413 .config_dir()
414 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no config directory found"))?,
415 };
416 self.family.set_own_in(&folder, &self.app, key, value.to_setting())
417 }
418}
419
420#[cfg(test)]
421#[path = "appearance_tests.rs"]
422mod tests;