1use crate::i18n::{self, LanguageSettings};
4use crate::theme::{self, Swatch, ThemeSettings};
5use crate::tr;
6
7use super::scope::{ScopeTab, scope_tabs};
8
9#[derive(Clone, PartialEq, Debug)]
13pub struct AppearanceScope<S> {
14 pub tab: ScopeTab<S>,
16 pub theme: ThemeSettings,
18 pub language: LanguageSettings,
20}
21
22#[derive(Clone, Debug)]
24pub struct AppearanceCtx<'a, S> {
25 pub scopes: &'a [AppearanceScope<S>],
28 pub unavailable: Option<String>,
30 pub language_unavailable: Option<String>,
32 pub busy: bool,
34}
35
36impl<'a, S> AppearanceCtx<'a, S> {
37 pub fn new(scopes: &'a [AppearanceScope<S>]) -> AppearanceCtx<'a, S> {
39 AppearanceCtx {
40 scopes,
41 unavailable: None,
42 language_unavailable: None,
43 busy: false,
44 }
45 }
46}
47
48#[derive(Clone, PartialEq, Debug)]
50pub struct AppearanceChange<S> {
51 pub scope: S,
53 pub theme: Option<ThemeSettings>,
55 pub language: Option<LanguageSettings>,
57}
58
59#[derive(Clone, Debug)]
70pub struct AppearancePage<S> {
71 tab: Option<S>,
73 shown: Option<S>,
75 working: ThemeSettings,
77 language: LanguageSettings,
79 editing: bool,
81 held: bool,
83 error: Option<String>,
85}
86
87impl<S> Default for AppearancePage<S> {
88 fn default() -> AppearancePage<S> {
89 AppearancePage {
90 tab: None,
91 shown: None,
92 working: ThemeSettings::default(),
93 language: LanguageSettings::default(),
94 editing: false,
95 held: false,
96 error: None,
97 }
98 }
99}
100
101impl<S: Copy + PartialEq> AppearancePage<S> {
102 pub fn new() -> AppearancePage<S> {
104 AppearancePage::default()
105 }
106
107 pub fn with_tab(mut self, scope: S) -> AppearancePage<S> {
109 self.tab = Some(scope);
110 self
111 }
112
113 pub fn tab(&self) -> Option<S> {
115 self.tab
116 }
117
118 pub fn ui(
121 &mut self,
122 ui: &mut egui::Ui,
123 cx: &AppearanceCtx<S>,
124 notes: impl FnOnce(&mut egui::Ui, S),
125 ) -> Option<AppearanceChange<S>> {
126 ui.horizontal(|ui| {
127 ui.heading(tr!(ui, "corundum-settings-appearance"));
128 if cx.busy {
129 ui.spinner();
130 }
131 });
132 ui.separator();
133
134 if cx.scopes.len() > 1 {
135 let tabs: Vec<ScopeTab<S>> = cx.scopes.iter().map(|s| s.tab.clone()).collect();
136 let mut open = self.tab.unwrap_or(tabs[0].scope);
137 scope_tabs(ui, &mut open, &tabs, None);
138 self.tab = Some(open);
139 } else if let Some(only) = cx.scopes.first() {
140 self.tab = Some(only.tab.scope);
141 }
142 let scope = self
143 .tab
144 .and_then(|tab| cx.scopes.iter().find(|s| s.tab.scope == tab))
145 .filter(|s| s.tab.enabled);
146 let (Some(scope), None) = (scope, cx.unavailable.as_ref()) else {
147 if let Some(why) = &cx.unavailable {
148 ui.weak(why);
149 }
150 let mut theme = self.working.clone();
152 let mut language = self.language.clone();
153 ui.add_enabled_ui(false, |ui| {
154 theme::settings_ui(ui, &mut theme);
155 language_row(ui, &mut language);
156 });
157 return None;
158 };
159
160 if self.shown != Some(scope.tab.scope) {
161 self.shown = Some(scope.tab.scope);
163 self.editing = false;
164 self.held = false;
165 }
166 notes(ui, scope.tab.scope);
167 error_line(ui, &mut self.error);
168
169 let mut change = AppearanceChange {
170 scope: scope.tab.scope,
171 theme: None,
172 language: None,
173 };
174 if !self.editing && !self.held {
175 self.working = scope.theme.clone();
176 }
177 let before = self.working.clone();
178 let done = ui
179 .add_enabled_ui(!cx.busy, |ui| theme::settings_ui(ui, &mut self.working))
180 .inner;
181 if done {
182 self.editing = false;
183 if self.working != scope.theme {
184 self.held = true;
185 change.theme = Some(self.working.clone());
186 }
187 } else if self.working != before {
188 self.editing = true;
189 } else if !ui.input(|i| i.pointer.any_down()) {
190 self.editing = false;
191 }
192
193 ui.add_space(8.0);
194 if !self.held {
195 self.language = scope.language.clone();
196 }
197 if let Some(why) = &cx.language_unavailable {
198 ui.weak(why);
199 }
200 let chosen = ui
201 .add_enabled_ui(!cx.busy && cx.language_unavailable.is_none(), |ui| {
202 language_row(ui, &mut self.language)
203 })
204 .inner;
205 if chosen && self.language != scope.language {
206 self.held = true;
207 change.language = Some(self.language.clone());
208 }
209
210 (change.theme.is_some() || change.language.is_some()).then_some(change)
211 }
212
213 pub fn reloaded(&mut self) {
215 self.held = false;
216 }
217
218 pub fn failed(&mut self, why: impl Into<String>) {
221 self.editing = false;
222 self.held = false;
223 self.error = Some(why.into());
224 }
225}
226
227fn language_row(ui: &mut egui::Ui, language: &mut LanguageSettings) -> bool {
229 egui::Grid::new("corundum_language_row")
230 .num_columns(2)
231 .spacing([16.0, 8.0])
232 .show(ui, |ui| {
233 ui.label(tr!(ui, "corundum-language"));
234 let chosen = i18n::settings_ui(ui, language);
235 ui.end_row();
236 chosen
237 })
238 .inner
239}
240
241fn error_line(ui: &mut egui::Ui, error: &mut Option<String>) {
243 let Some(text) = error.clone() else {
244 return;
245 };
246 ui.horizontal_wrapped(|ui| {
248 if ui.small_button("×").clicked() {
249 *error = None;
250 }
251 let colour = theme::current(ui.ctx()).text(Swatch::DANGER);
252 ui.add(egui::Label::new(egui::RichText::new(text).color(colour)).wrap());
253 });
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use crate::i18n::Language;
260 use crate::test_support::{render, render_in};
261 use crate::theme::Contrast;
262
263 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
264 enum Scope {
265 Client,
266 Server,
267 }
268
269 fn scope(scope: Scope, contrast: Contrast) -> AppearanceScope<Scope> {
270 AppearanceScope {
271 tab: ScopeTab::new(scope, format!("{scope:?}")),
272 theme: ThemeSettings {
273 contrast,
274 ..Default::default()
275 },
276 language: LanguageSettings::default(),
277 }
278 }
279
280 fn scopes() -> Vec<AppearanceScope<Scope>> {
281 vec![
282 scope(Scope::Client, Contrast::High),
283 scope(Scope::Server, Contrast::Low),
284 ]
285 }
286
287 #[test]
288 fn it_opens_on_the_first_tab_and_shows_its_values() {
289 let scopes = scopes();
290 let mut page = AppearancePage::new();
291 let mut noted = None;
292 render([600.0, 600.0], |ui| {
293 let change = page.ui(ui, &AppearanceCtx::new(&scopes), |_, s| noted = Some(s));
294 assert_eq!(change, None);
295 });
296 assert_eq!(page.tab(), Some(Scope::Client));
297 assert_eq!(noted, Some(Scope::Client));
298 assert_eq!(page.working.contrast, Contrast::High);
299 }
300
301 #[test]
302 fn with_tab_opens_on_that_tab() {
303 let scopes = scopes();
304 let mut page = AppearancePage::new().with_tab(Scope::Server);
305 render([600.0, 600.0], |ui| {
306 page.ui(ui, &AppearanceCtx::new(&scopes), |_, _| {});
307 });
308 assert_eq!(page.working.contrast, Contrast::Low);
309 }
310
311 #[test]
312 fn a_disabled_tab_yields() {
313 let mut scopes = scopes();
314 scopes[1].tab.enabled = false;
315 let mut page = AppearancePage::new().with_tab(Scope::Server);
316 render([600.0, 600.0], |ui| {
317 page.ui(ui, &AppearanceCtx::new(&scopes), |_, _| {});
318 });
319 assert_eq!(page.tab(), Some(Scope::Client));
320 }
321
322 #[test]
323 fn unavailable_shows_why_and_gives_nothing() {
324 let scopes = scopes();
325 let mut page = AppearancePage::new();
326 let cx = AppearanceCtx {
327 unavailable: Some("the server is down".into()),
328 ..AppearanceCtx::new(&scopes)
329 };
330 let mut noted = false;
331 render([600.0, 600.0], |ui| {
332 assert_eq!(page.ui(ui, &cx, |_, _| noted = true), None);
333 });
334 assert!(!noted, "no notes without values");
335 let none: Vec<AppearanceScope<Scope>> = Vec::new();
336 render([600.0, 600.0], |ui| {
337 assert_eq!(page.ui(ui, &AppearanceCtx::new(&none), |_, _| {}), None);
338 });
339 }
340
341 #[test]
342 fn a_held_change_stays_until_reloaded() {
343 let scopes = scopes();
344 let mut page = AppearancePage::new();
345 render([600.0, 600.0], |ui| {
346 page.ui(ui, &AppearanceCtx::new(&scopes), |_, _| {});
347 });
348 page.working.contrast = Contrast::Medium;
350 page.held = true;
351 render([600.0, 600.0], |ui| {
352 page.ui(ui, &AppearanceCtx::new(&scopes), |_, _| {});
353 });
354 assert_eq!(page.working.contrast, Contrast::Medium);
355 page.reloaded();
356 render([600.0, 600.0], |ui| {
357 page.ui(ui, &AppearanceCtx::new(&scopes), |_, _| {});
358 });
359 assert_eq!(page.working.contrast, Contrast::High);
360 }
361
362 #[test]
363 fn a_failure_shows_the_hosts_values_again() {
364 let scopes = scopes();
365 let mut page = AppearancePage::new();
366 page.working.contrast = Contrast::Medium;
367 page.held = true;
368 page.failed("rejected");
369 render([600.0, 600.0], |ui| {
370 page.ui(ui, &AppearanceCtx::new(&scopes), |_, _| {});
371 });
372 assert_eq!(page.working.contrast, Contrast::High);
373 assert_eq!(page.error.as_deref(), Some("rejected"));
374 }
375
376 #[test]
377 fn switching_tabs_drops_a_held_change() {
378 let scopes = scopes();
379 let mut page = AppearancePage::new();
380 render([600.0, 600.0], |ui| {
381 page.ui(ui, &AppearanceCtx::new(&scopes), |_, _| {});
382 });
383 page.held = true;
384 page.tab = Some(Scope::Server);
385 render([600.0, 600.0], |ui| {
386 page.ui(ui, &AppearanceCtx::new(&scopes), |_, _| {});
387 });
388 assert_eq!(page.working.contrast, Contrast::Low);
389 }
390
391 #[test]
392 fn the_page_follows_the_language() {
393 let ctx = egui::Context::default();
394 crate::i18n::install(
395 &ctx,
396 &LanguageSettings {
397 language: Language::Explicit("ja".into()),
398 },
399 );
400 let scopes = scopes();
401 let mut page = AppearancePage::new();
402 render_in(&ctx, [600.0, 600.0], |ui| {
403 page.ui(ui, &AppearanceCtx::new(&scopes), |_, _| {});
404 });
405 assert_eq!(
406 crate::i18n::tr(&ctx, "corundum-settings-appearance"),
407 "外観"
408 );
409 }
410}