Skip to main content

corundum_framework_gui/settings/
mod.rs

1//! The settings screen, and the pieces its pages are made of.
2//!
3//! [`Settings`] is the screen: the pages an app and the frameworks under it put in, by
4//! group, in a list beside the open page — or, narrower than
5//! [`COMPACT_WIDTH`](crate::COMPACT_WIDTH), the list, then one page under a way back to
6//! it. A host whose pages share state, or come and go (pages made from a schema, say),
7//! gives the list itself each frame to a [`SettingsLayout`] instead.
8//!
9//! [`scope_tabs`] is the row of tabs a page shows when a setting can be kept in more than
10//! one place — on this device or for the whole workgroup, on the client or on the server.
11//! [`AppearancePage`] is the theme and the language over those tabs.
12//!
13//! Nothing here stores a setting or checks one: the pages show the host's values and give
14//! back what the person changed, and the host keeps them as it keeps the rest of its
15//! settings.
16
17mod appearance;
18mod layout;
19mod scope;
20
21pub use appearance::{AppearanceChange, AppearanceCtx, AppearancePage, AppearanceScope};
22pub use layout::{ListItem, SettingsLayout};
23pub use scope::{ScopeTab, scope_tabs};
24
25/// A page of [`Settings`].
26pub trait SettingsSection {
27    /// The page's name in the list, in the language installed in `ctx`. It is asked every
28    /// frame, so a language switch reaches the list at once.
29    fn label(&self, ctx: &egui::Context) -> String;
30
31    /// Render the page, its own heading first.
32    fn ui(&mut self, ui: &mut egui::Ui);
33
34    /// Whether the page holds changes not saved yet; its entry is marked.
35    fn dirty(&self) -> bool {
36        false
37    }
38}
39
40/// How a group's heading is named.
41enum Heading {
42    /// No heading: the group is set apart by a gap.
43    None,
44    /// A message id, looked up in the installed language.
45    Message(&'static str),
46    /// Text as it is: the app's name, say.
47    Text(String),
48}
49
50/// Pages under one heading in [`Settings`]: the app's own, a framework's, About.
51pub struct SettingsGroup {
52    heading: Heading,
53    sections: Vec<Box<dyn SettingsSection>>,
54}
55
56impl SettingsGroup {
57    /// `sections` with no heading, set apart from the group before by a gap: About, say.
58    pub fn new(sections: Vec<Box<dyn SettingsSection>>) -> SettingsGroup {
59        SettingsGroup {
60            heading: Heading::None,
61            sections,
62        }
63    }
64
65    /// Head the group with the message `id`, in the installed language.
66    pub fn titled(mut self, id: &'static str) -> SettingsGroup {
67        self.heading = Heading::Message(id);
68        self
69    }
70
71    /// Head the group with `name` as it is: the app's name, say.
72    pub fn named(mut self, name: impl Into<String>) -> SettingsGroup {
73        self.heading = Heading::Text(name.into());
74        self
75    }
76}
77
78/// The settings screen: groups of pages, in a list beside the open page. Hold one; call
79/// [`ui`](Self::ui) every frame it is shown, as the root of what the shell leaves.
80///
81/// ```no_run
82/// # use corundum_framework_gui::settings::{Settings, SettingsGroup, SettingsSection};
83/// # fn example(general: Box<dyn SettingsSection>, about: Box<dyn SettingsSection>, ui: &mut egui::Ui) {
84/// let mut settings = Settings::new()
85///     .with_group(SettingsGroup::new(vec![general]).named("Journal"))
86///     .with_group(SettingsGroup::new(vec![about]));
87/// settings.ui(ui);
88/// # }
89/// ```
90#[derive(Default)]
91pub struct Settings {
92    groups: Vec<SettingsGroup>,
93    /// The open page, by group and by section.
94    layout: SettingsLayout<(usize, usize)>,
95}
96
97impl Settings {
98    /// Settings with no pages yet.
99    pub fn new() -> Settings {
100        Settings::default()
101    }
102
103    /// Add `group`, after the groups already there.
104    pub fn with_group(mut self, group: SettingsGroup) -> Settings {
105        self.groups.push(group);
106        self
107    }
108
109    /// Add `group`, after the groups already there: a framework's pages, put in by the
110    /// framework once the app has added its own.
111    pub fn push_group(&mut self, group: SettingsGroup) {
112        self.groups.push(group);
113    }
114
115    /// Open the `section`th page of the `group`th group.
116    pub fn open(&mut self, group: usize, section: usize) {
117        self.layout.open(Some((group, section)));
118    }
119
120    /// The open page, by group and by section.
121    pub fn current(&self) -> Option<(usize, usize)> {
122        self.layout.current().copied()
123    }
124
125    /// Render into `ui`, as the root of what the shell leaves of it.
126    pub fn ui(&mut self, ui: &mut egui::Ui) {
127        let items = self.items(ui.ctx());
128        let Settings { groups, layout } = self;
129        layout.ui(ui, &items, |ui, (g, s)| {
130            if let Some(section) = groups.get_mut(g).and_then(|g| g.sections.get_mut(s)) {
131                section.ui(ui);
132            }
133        });
134    }
135
136    /// The list, in the language installed in `ctx`.
137    fn items(&self, ctx: &egui::Context) -> Vec<ListItem<(usize, usize)>> {
138        let mut out = Vec::new();
139        for (g, group) in self.groups.iter().enumerate() {
140            match &group.heading {
141                Heading::None if g > 0 => out.push(ListItem::Gap),
142                Heading::None => {}
143                Heading::Message(id) => out.push(ListItem::Heading(crate::i18n::tr(ctx, id))),
144                Heading::Text(text) => out.push(ListItem::Heading(text.clone())),
145            }
146            for (s, section) in group.sections.iter().enumerate() {
147                out.push(ListItem::Entry {
148                    page: (g, s),
149                    label: section.label(ctx),
150                    dirty: section.dirty(),
151                });
152            }
153        }
154        out
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::test_support::render;
162
163    struct Named(&'static str, bool);
164
165    impl SettingsSection for Named {
166        fn label(&self, _ctx: &egui::Context) -> String {
167            self.0.to_owned()
168        }
169
170        fn ui(&mut self, ui: &mut egui::Ui) {
171            ui.label(self.0);
172        }
173
174        fn dirty(&self) -> bool {
175            self.1
176        }
177    }
178
179    fn settings() -> Settings {
180        Settings::new()
181            .with_group(
182                SettingsGroup::new(vec![
183                    Box::new(Named("Look", false)),
184                    Box::new(Named("Sync", true)),
185                ])
186                .named("Example"),
187            )
188            .with_group(
189                SettingsGroup::new(vec![Box::new(Named("Appearance", false))])
190                    .titled("corundum-settings"),
191            )
192            .with_group(SettingsGroup::new(vec![Box::new(Named("About", false))]))
193    }
194
195    fn labels(settings: &Settings) -> Vec<String> {
196        let ctx = egui::Context::default();
197        crate::i18n::install(
198            &ctx,
199            &crate::i18n::LanguageSettings {
200                language: crate::i18n::Language::Explicit("en".into()),
201            },
202        );
203        settings
204            .items(&ctx)
205            .into_iter()
206            .map(|item| match item {
207                ListItem::Heading(h) => format!("# {h}"),
208                ListItem::Entry { label, dirty, .. } => layout::marked(&label, dirty),
209                ListItem::Gap => "-".into(),
210            })
211            .collect()
212    }
213
214    #[test]
215    fn groups_are_listed_in_order() {
216        let mut settings = settings();
217        settings
218            .push_group(SettingsGroup::new(vec![Box::new(Named("Later", false))]).named("Late"));
219        let labels = labels(&settings);
220        assert_eq!(
221            labels,
222            [
223                "# Example",
224                "Look",
225                "Sync •",
226                "# Settings",
227                "Appearance",
228                "-",
229                "About",
230                "# Late",
231                "Later",
232            ]
233        );
234    }
235
236    #[test]
237    fn wide_it_opens_the_first_page() {
238        let mut settings = settings();
239        render([900.0, 600.0], |ui| settings.ui(ui));
240        assert_eq!(settings.current(), Some((0, 0)));
241    }
242
243    #[test]
244    fn compact_it_shows_the_list_then_the_page_opened() {
245        let mut settings = settings();
246        render([400.0, 700.0], |ui| settings.ui(ui));
247        assert_eq!(settings.current(), None);
248        settings.open(2, 0);
249        render([400.0, 700.0], |ui| settings.ui(ui));
250        assert_eq!(settings.current(), Some((2, 0)));
251    }
252}