corundum_framework_gui/settings/
mod.rs1mod 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
42pub trait SettingsSection {
44 fn label(&self, ctx: &egui::Context) -> String;
47
48 fn ui(&mut self, ui: &mut egui::Ui);
50
51 fn dirty(&self) -> bool {
53 false
54 }
55}
56
57enum Heading {
59 None,
61 Message(&'static str),
63 Text(String),
65}
66
67pub struct SettingsGroup {
69 heading: Heading,
70 sections: Vec<Box<dyn SettingsSection>>,
71}
72
73impl SettingsGroup {
74 pub fn new(sections: Vec<Box<dyn SettingsSection>>) -> SettingsGroup {
76 SettingsGroup {
77 heading: Heading::None,
78 sections,
79 }
80 }
81
82 pub fn titled(mut self, id: &'static str) -> SettingsGroup {
84 self.heading = Heading::Message(id);
85 self
86 }
87
88 pub fn named(mut self, name: impl Into<String>) -> SettingsGroup {
90 self.heading = Heading::Text(name.into());
91 self
92 }
93}
94
95#[derive(Default)]
108pub struct Settings {
109 groups: Vec<SettingsGroup>,
110 layout: SettingsLayout<(usize, usize)>,
112}
113
114impl Settings {
115 pub fn new() -> Settings {
117 Settings::default()
118 }
119
120 pub fn with_group(mut self, group: SettingsGroup) -> Settings {
122 self.groups.push(group);
123 self
124 }
125
126 pub fn push_group(&mut self, group: SettingsGroup) {
129 self.groups.push(group);
130 }
131
132 pub fn open(&mut self, group: usize, section: usize) {
134 self.layout.open(Some((group, section)));
135 }
136
137 pub fn current(&self) -> Option<(usize, usize)> {
139 self.layout.current().copied()
140 }
141
142 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 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}