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