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}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53enum Row {
54 Shared(Shared),
55 ReducedMotion,
56 Pillar,
57}
58
59#[derive(Debug, Clone)]
110pub struct Appearance {
111 family: Family,
112 app: String,
113 folder: Option<PathBuf>,
114 preferences: Preferences,
115 saving: bool,
117 failure: Option<(Row, String)>,
118}
119
120impl Appearance {
121 #[must_use]
124 pub fn new(family: Family, app: impl Into<String>, preferences: Preferences) -> Self {
125 Self { family, app: app.into(), folder: None, preferences, saving: true, failure: None }
126 }
127
128 #[must_use]
131 pub fn in_folder(mut self, folder: impl Into<PathBuf>) -> Self {
132 self.folder = Some(folder.into());
133 self
134 }
135
136 #[must_use]
143 pub fn without_saving(mut self) -> Self {
144 self.saving = false;
145 self
146 }
147
148 #[must_use]
150 pub fn preferences(&self) -> &Preferences {
151 &self.preferences
152 }
153
154 pub fn section<Msg: Clone + 'static>(
157 &self,
158 list: &mut SettingsRows<'_, Msg>,
159 message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
160 ) {
161 list.heading(crate::t!("quvyta.appearance.heading"));
162 self.rows(list, message.clone());
163 self.own_rows(list, message);
164 }
165
166 pub fn rows<Msg: Clone + 'static>(
171 &self,
172 list: &mut SettingsRows<'_, Msg>,
173 message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
174 ) {
175 let env = list.env();
176 let languages = env.i18n().list();
177 let active = env.i18n().active().to_owned();
178 let themes = env.themes();
179 let theme = env.theme().id().to_owned();
180 let icons = env.icon_mode();
181 let icon_names = IconMode::ALL.map(|mode| crate::t!(&format!("quvyta.appearance.icons-{}", mode.name())));
182
183 let padding = env.theme().style("select", None, &[]).pair("padding").map_or(1, |(_, horizontal)| horizontal);
189 let width = choice_width(
190 &languages
191 .iter()
192 .map(|(_, name)| name.clone())
193 .chain(themes.iter().map(|(_, name)| name.clone()))
194 .chain(icon_names.iter().cloned())
195 .collect::<Vec<String>>(),
196 padding,
197 );
198
199 let codes: Vec<String> = languages.iter().map(|(code, _)| code.clone()).collect();
200 let chosen = codes.iter().position(|code| *code == active);
201 let send = message.clone();
202 list.row(self.row(Row::Shared(Shared::Language), crate::t!("quvyta.appearance.language")), |ui| {
203 let names = languages.into_iter().map(|(_, name)| name);
204 let select = Select::new(names)
205 .selected(chosen)
206 .on_select(move |index| send(AppearanceChange::Language(codes[index].clone())));
207 ui.add(select).width(Length::Cells(width));
208 });
209 self.everywhere(list, Shared::Language, &message);
210
211 let ids: Vec<String> = themes.iter().map(|(id, _)| id.clone()).collect();
212 let chosen = ids.iter().position(|id| *id == theme);
213 let send = message.clone();
214 list.row(self.row(Row::Shared(Shared::Theme), crate::t!("quvyta.appearance.theme")), |ui| {
215 let names = themes.into_iter().map(|(_, name)| name);
216 let select = Select::new(names)
217 .selected(chosen)
218 .on_select(move |index| send(AppearanceChange::Theme(ids[index].clone())));
219 ui.add(select).width(Length::Cells(width));
220 });
221 self.everywhere(list, Shared::Theme, &message);
222
223 let chosen = IconMode::ALL.iter().position(|mode| *mode == icons);
224 let send = message.clone();
225 list.row(self.row(Row::Shared(Shared::Icons), crate::t!("quvyta.appearance.icons")), |ui| {
226 let select = Select::new(icon_names)
227 .selected(chosen)
228 .on_select(move |index| send(AppearanceChange::Icons(IconMode::ALL[index])));
229 ui.add(select).width(Length::Cells(width));
230 });
231 self.everywhere(list, Shared::Icons, &message);
232 }
233
234 fn own_rows<Msg: Clone + 'static>(
236 &self,
237 list: &mut SettingsRows<'_, Msg>,
238 message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
239 ) {
240 let env = list.env();
241 let (reduced, forced) = (env.reduced_motion(), env.reduced_motion_forced());
242 let pillar = env.pillar_style().unwrap_or(PillarStyle::Thick);
243
244 let note = match (forced, reduced) {
245 (true, true) => crate::t!("quvyta.appearance.forced-on"),
246 (true, false) => crate::t!("quvyta.appearance.forced-off"),
247 (false, _) => crate::t!("quvyta.appearance.reduce-motion-text"),
248 };
249 let row = SettingRow::new(crate::t!("quvyta.appearance.reduce-motion")).disabled(forced);
250 let row = match self.failed(Row::ReducedMotion) {
251 Some(failure) => row.description(failure),
252 None => row.description(note),
253 };
254 let send = message.clone();
255 list.row(row, |ui| {
256 ui.add(
257 Switch::new(reduced).disabled(forced).on_toggle(move |on| send(AppearanceChange::ReducedMotion(on))),
258 );
259 });
260
261 let styles = PillarStyle::ALL.map(|style| crate::t!(&format!("quvyta.appearance.pillar-{}", style.name())));
262 let chosen = PillarStyle::ALL.iter().position(|style| *style == pillar).unwrap_or(0);
263 list.row(self.row(Row::Pillar, crate::t!("quvyta.appearance.pillar")), |ui| {
264 let segmented = Segmented::new(styles)
265 .selected(chosen)
266 .on_select(move |index| message(AppearanceChange::Pillar(PillarStyle::ALL[index])));
267 ui.add(segmented);
268 });
269 }
270
271 fn row<Msg>(&self, row: Row, label: String) -> SettingRow<Msg> {
273 let setting = SettingRow::new(label);
274 match self.failed(row) {
275 Some(failure) => setting.description(failure),
276 None => setting,
277 }
278 }
279
280 fn failed(&self, row: Row) -> Option<String> {
282 self.failure
283 .as_ref()
284 .filter(|(failed, _)| *failed == row)
285 .map(|(_, reason)| crate::t!("quvyta.appearance.not-saved", reason = reason.as_str()))
286 }
287
288 fn everywhere<Msg: Clone + 'static>(
290 &self,
291 list: &mut SettingsRows<'_, Msg>,
292 key: Shared,
293 message: &(impl Fn(AppearanceChange) -> Msg + Clone + 'static),
294 ) {
295 let checked = self.preferences.source(key) != Source::App;
296 let label = crate::t!("quvyta.appearance.everywhere", family = self.family.title());
297 let send = message.clone();
298 list.row(SettingRow::new(label).nested(true), |ui| {
299 ui.add(Checkbox::new(checked).on_toggle(move |on| send(AppearanceChange::Everywhere(key, on))));
300 });
301 }
302
303 pub fn update<Msg: Send + 'static>(&mut self, change: AppearanceChange, settings: &mut Settings) -> Command<Msg> {
310 let (row, saved, command) = match change {
311 AppearanceChange::Language(code) => {
312 let saved = self.share(Shared::Language, &code, None, settings);
313 (Row::Shared(Shared::Language), saved, Command::set_locale(code))
314 }
315 AppearanceChange::Theme(id) => {
316 let saved = self.share(Shared::Theme, &id, None, settings);
317 (Row::Shared(Shared::Theme), saved, Command::set_theme(id))
318 }
319 AppearanceChange::Icons(mode) => {
320 let saved = self.share(Shared::Icons, mode.name(), None, settings);
321 (Row::Shared(Shared::Icons), saved, Command::set_icon_mode(mode))
322 }
323 AppearanceChange::Everywhere(key, on) => {
324 let scope = if on { Scope::Family } else { Scope::App };
325 let value = self.preferences.text(key);
326 (Row::Shared(key), self.share(key, &value, Some(scope), settings), Command::none())
327 }
328 AppearanceChange::ReducedMotion(on) => {
329 let saved = self.own(Settings::REDUCED_MOTION, on, settings);
330 (Row::ReducedMotion, saved, Command::set_reduced_motion(on))
331 }
332 AppearanceChange::Pillar(style) => {
333 let saved = self.own(Settings::PILLAR, style.name().to_owned(), settings);
334 (Row::Pillar, saved, Command::set_pillar(style))
335 }
336 };
337 self.failure = saved.err().map(|error| (row, error.to_string()));
338 command
339 }
340
341 fn share(&mut self, key: Shared, value: &str, scope: Option<Scope>, settings: &mut Settings) -> io::Result<()> {
344 let scope =
345 scope.unwrap_or(if self.preferences.source(key) == Source::App { Scope::App } else { Scope::Family });
346 let written = match scope {
347 Scope::Family => self.family.id().to_owned(),
348 Scope::App => value.to_owned(),
349 };
350 settings.set(key.key(), written);
351 let source = if scope == Scope::Family { Source::Family } else { Source::App };
352 self.preferences.record(key, value, source);
353 if !self.saving {
354 return Ok(());
355 }
356 match &self.folder {
357 Some(folder) => self.family.set_in(folder, &self.app, key, value, scope),
358 None => self.family.set(&self.app, key, value, scope),
359 }
360 }
361
362 fn own<T: Setting + Clone>(&self, key: &str, value: T, settings: &mut Settings) -> io::Result<()> {
364 settings.set(key, value.clone());
365 if !self.saving {
366 return Ok(());
367 }
368 let folder = match &self.folder {
369 Some(folder) => folder.clone(),
370 None => self
371 .family
372 .config_dir()
373 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no config directory found"))?,
374 };
375 self.family.set_own_in(&folder, &self.app, key, value.to_setting())
376 }
377}
378
379#[cfg(test)]
380#[path = "appearance_tests.rs"]
381mod tests;