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