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_WIDTH: u16 = 18;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum AppearanceChange {
23 Language(String),
25 Theme(String),
27 Icons(IconMode),
29 Everywhere(Shared, bool),
32 ReducedMotion(bool),
34 Pillar(PillarStyle),
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40enum Row {
41 Shared(Shared),
42 ReducedMotion,
43 Pillar,
44}
45
46#[derive(Debug, Clone)]
97pub struct Appearance {
98 family: Family,
99 app: String,
100 folder: Option<PathBuf>,
101 preferences: Preferences,
102 saving: bool,
104 failure: Option<(Row, String)>,
105}
106
107impl Appearance {
108 #[must_use]
111 pub fn new(family: Family, app: impl Into<String>, preferences: Preferences) -> Self {
112 Self { family, app: app.into(), folder: None, preferences, saving: true, failure: None }
113 }
114
115 #[must_use]
118 pub fn in_folder(mut self, folder: impl Into<PathBuf>) -> Self {
119 self.folder = Some(folder.into());
120 self
121 }
122
123 #[must_use]
130 pub fn without_saving(mut self) -> Self {
131 self.saving = false;
132 self
133 }
134
135 #[must_use]
137 pub fn preferences(&self) -> &Preferences {
138 &self.preferences
139 }
140
141 pub fn section<Msg: Clone + 'static>(
144 &self,
145 list: &mut SettingsRows<'_, Msg>,
146 message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
147 ) {
148 list.heading(crate::t!("quvyta.appearance.heading"));
149 self.rows(list, message.clone());
150 self.own_rows(list, message);
151 }
152
153 pub fn rows<Msg: Clone + 'static>(
158 &self,
159 list: &mut SettingsRows<'_, Msg>,
160 message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
161 ) {
162 let env = list.env();
163 let languages = env.i18n().list();
164 let active = env.i18n().active().to_owned();
165 let themes = env.themes();
166 let theme = env.theme().id().to_owned();
167 let icons = env.icon_mode();
168
169 let codes: Vec<String> = languages.iter().map(|(code, _)| code.clone()).collect();
170 let chosen = codes.iter().position(|code| *code == active);
171 let send = message.clone();
172 list.row(self.row(Row::Shared(Shared::Language), crate::t!("quvyta.appearance.language")), |ui| {
173 let names = languages.into_iter().map(|(_, name)| name);
174 let select = Select::new(names)
175 .selected(chosen)
176 .on_select(move |index| send(AppearanceChange::Language(codes[index].clone())));
177 ui.add(select).width(Length::Cells(CHOICE_WIDTH));
178 });
179 self.everywhere(list, Shared::Language, &message);
180
181 let ids: Vec<String> = themes.iter().map(|(id, _)| id.clone()).collect();
182 let chosen = ids.iter().position(|id| *id == theme);
183 let send = message.clone();
184 list.row(self.row(Row::Shared(Shared::Theme), crate::t!("quvyta.appearance.theme")), |ui| {
185 let names = themes.into_iter().map(|(_, name)| name);
186 let select = Select::new(names)
187 .selected(chosen)
188 .on_select(move |index| send(AppearanceChange::Theme(ids[index].clone())));
189 ui.add(select).width(Length::Cells(CHOICE_WIDTH));
190 });
191 self.everywhere(list, Shared::Theme, &message);
192
193 let chosen = IconMode::ALL.iter().position(|mode| *mode == icons);
194 let send = message.clone();
195 list.row(self.row(Row::Shared(Shared::Icons), crate::t!("quvyta.appearance.icons")), |ui| {
196 let names = IconMode::ALL.map(|mode| crate::t!(&format!("quvyta.appearance.icons-{}", mode.name())));
197 let select = Select::new(names)
198 .selected(chosen)
199 .on_select(move |index| send(AppearanceChange::Icons(IconMode::ALL[index])));
200 ui.add(select).width(Length::Cells(CHOICE_WIDTH));
201 });
202 self.everywhere(list, Shared::Icons, &message);
203 }
204
205 fn own_rows<Msg: Clone + 'static>(
207 &self,
208 list: &mut SettingsRows<'_, Msg>,
209 message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
210 ) {
211 let env = list.env();
212 let (reduced, forced) = (env.reduced_motion(), env.reduced_motion_forced());
213 let pillar = env.pillar_style().unwrap_or(PillarStyle::Thick);
214
215 let note = match (forced, reduced) {
216 (true, true) => crate::t!("quvyta.appearance.forced-on"),
217 (true, false) => crate::t!("quvyta.appearance.forced-off"),
218 (false, _) => crate::t!("quvyta.appearance.reduce-motion-text"),
219 };
220 let row = SettingRow::new(crate::t!("quvyta.appearance.reduce-motion")).disabled(forced);
221 let row = match self.failed(Row::ReducedMotion) {
222 Some(failure) => row.description(failure),
223 None => row.description(note),
224 };
225 let send = message.clone();
226 list.row(row, |ui| {
227 ui.add(
228 Switch::new(reduced).disabled(forced).on_toggle(move |on| send(AppearanceChange::ReducedMotion(on))),
229 );
230 });
231
232 let styles = PillarStyle::ALL.map(|style| crate::t!(&format!("quvyta.appearance.pillar-{}", style.name())));
233 let chosen = PillarStyle::ALL.iter().position(|style| *style == pillar).unwrap_or(0);
234 list.row(self.row(Row::Pillar, crate::t!("quvyta.appearance.pillar")), |ui| {
235 let segmented = Segmented::new(styles)
236 .selected(chosen)
237 .on_select(move |index| message(AppearanceChange::Pillar(PillarStyle::ALL[index])));
238 ui.add(segmented);
239 });
240 }
241
242 fn row<Msg>(&self, row: Row, label: String) -> SettingRow<Msg> {
244 let setting = SettingRow::new(label);
245 match self.failed(row) {
246 Some(failure) => setting.description(failure),
247 None => setting,
248 }
249 }
250
251 fn failed(&self, row: Row) -> Option<String> {
253 self.failure
254 .as_ref()
255 .filter(|(failed, _)| *failed == row)
256 .map(|(_, reason)| crate::t!("quvyta.appearance.not-saved", reason = reason.as_str()))
257 }
258
259 fn everywhere<Msg: Clone + 'static>(
261 &self,
262 list: &mut SettingsRows<'_, Msg>,
263 key: Shared,
264 message: &(impl Fn(AppearanceChange) -> Msg + Clone + 'static),
265 ) {
266 let checked = self.preferences.source(key) != Source::App;
267 let label = crate::t!("quvyta.appearance.everywhere", family = self.family.title());
268 let send = message.clone();
269 list.row(SettingRow::new(label).nested(true), |ui| {
270 ui.add(Checkbox::new(checked).on_toggle(move |on| send(AppearanceChange::Everywhere(key, on))));
271 });
272 }
273
274 pub fn update<Msg: Send + 'static>(&mut self, change: AppearanceChange, settings: &mut Settings) -> Command<Msg> {
281 let (row, saved, command) = match change {
282 AppearanceChange::Language(code) => {
283 let saved = self.share(Shared::Language, &code, None, settings);
284 (Row::Shared(Shared::Language), saved, Command::set_locale(code))
285 }
286 AppearanceChange::Theme(id) => {
287 let saved = self.share(Shared::Theme, &id, None, settings);
288 (Row::Shared(Shared::Theme), saved, Command::set_theme(id))
289 }
290 AppearanceChange::Icons(mode) => {
291 let saved = self.share(Shared::Icons, mode.name(), None, settings);
292 (Row::Shared(Shared::Icons), saved, Command::set_icon_mode(mode))
293 }
294 AppearanceChange::Everywhere(key, on) => {
295 let scope = if on { Scope::Family } else { Scope::App };
296 let value = self.preferences.text(key);
297 (Row::Shared(key), self.share(key, &value, Some(scope), settings), Command::none())
298 }
299 AppearanceChange::ReducedMotion(on) => {
300 let saved = self.own(Settings::REDUCED_MOTION, on, settings);
301 (Row::ReducedMotion, saved, Command::set_reduced_motion(on))
302 }
303 AppearanceChange::Pillar(style) => {
304 let saved = self.own(Settings::PILLAR, style.name().to_owned(), settings);
305 (Row::Pillar, saved, Command::set_pillar(style))
306 }
307 };
308 self.failure = saved.err().map(|error| (row, error.to_string()));
309 command
310 }
311
312 fn share(&mut self, key: Shared, value: &str, scope: Option<Scope>, settings: &mut Settings) -> io::Result<()> {
315 let scope =
316 scope.unwrap_or(if self.preferences.source(key) == Source::App { Scope::App } else { Scope::Family });
317 let written = match scope {
318 Scope::Family => self.family.id().to_owned(),
319 Scope::App => value.to_owned(),
320 };
321 settings.set(key.key(), written);
322 let source = if scope == Scope::Family { Source::Family } else { Source::App };
323 self.preferences.record(key, value, source);
324 if !self.saving {
325 return Ok(());
326 }
327 match &self.folder {
328 Some(folder) => self.family.set_in(folder, &self.app, key, value, scope),
329 None => self.family.set(&self.app, key, value, scope),
330 }
331 }
332
333 fn own<T: Setting + Clone>(&self, key: &str, value: T, settings: &mut Settings) -> io::Result<()> {
335 settings.set(key, value.clone());
336 if !self.saving {
337 return Ok(());
338 }
339 let folder = match &self.folder {
340 Some(folder) => folder.clone(),
341 None => self
342 .family
343 .config_dir()
344 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no config directory found"))?,
345 };
346 self.family.set_own_in(&folder, &self.app, key, value.to_setting())
347 }
348}
349
350#[cfg(test)]
351#[path = "appearance_tests.rs"]
352mod tests;