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