corundum_framework_gui/settings/
layout.rs1use crate::COMPACT_WIDTH;
4use crate::tr;
5
6const LIST_WIDTH: f32 = 170.0;
8
9#[derive(Clone, PartialEq, Eq, Debug)]
11pub enum ListItem<K> {
12 Heading(String),
14 Entry { page: K, label: String, dirty: bool },
17 Gap,
19}
20
21impl<K> ListItem<K> {
22 pub fn entry(page: K, label: impl Into<String>) -> ListItem<K> {
24 ListItem::Entry {
25 page,
26 label: label.into(),
27 dirty: false,
28 }
29 }
30}
31
32#[derive(Clone, Debug)]
36pub struct SettingsLayout<K> {
37 open: Option<K>,
39}
40
41impl<K> Default for SettingsLayout<K> {
42 fn default() -> SettingsLayout<K> {
43 SettingsLayout { open: None }
44 }
45}
46
47impl<K: Clone + PartialEq> SettingsLayout<K> {
48 pub fn new() -> SettingsLayout<K> {
50 SettingsLayout::default()
51 }
52
53 pub fn current(&self) -> Option<&K> {
55 self.open.as_ref()
56 }
57
58 pub fn open(&mut self, page: Option<K>) {
60 self.open = page;
61 }
62
63 pub fn ui(
67 &mut self,
68 ui: &mut egui::Ui,
69 items: &[ListItem<K>],
70 page: impl FnOnce(&mut egui::Ui, K),
71 ) {
72 let listed = |k: &K| {
73 items
74 .iter()
75 .any(|i| matches!(i, ListItem::Entry { page, .. } if page == k))
76 };
77 if self.open.as_ref().is_some_and(|k| !listed(k)) {
78 self.open = None;
79 }
80 if ui.available_width() < COMPACT_WIDTH {
81 egui::CentralPanel::default().show(ui, |ui| match self.open.clone() {
82 None => {
83 egui::ScrollArea::vertical()
84 .id_salt("corundum_settings_list")
85 .show(ui, |ui| self.list_ui(ui, items));
86 }
87 Some(open) => {
88 let back = crate::icons::image(ui, crate::icons::arrow_left(), 14.0);
89 if ui
90 .add(egui::Button::image_and_text(
91 back,
92 tr!(ui, "corundum-settings"),
93 ))
94 .clicked()
95 {
96 self.open = None;
97 }
98 ui.separator();
99 page_ui(ui, open, page);
100 }
101 });
102 } else {
103 if self.open.is_none() {
104 self.open = items.iter().find_map(|i| match i {
105 ListItem::Entry { page, .. } => Some(page.clone()),
106 _ => None,
107 });
108 }
109 egui::Panel::left("corundum_settings_list")
110 .resizable(false)
111 .exact_size(LIST_WIDTH)
112 .show(ui, |ui| {
113 egui::ScrollArea::vertical()
114 .id_salt("corundum_settings_list")
115 .show(ui, |ui| self.list_ui(ui, items));
116 });
117 let open = self.open.clone();
118 egui::CentralPanel::default().show(ui, |ui| {
119 if let Some(open) = open {
120 page_ui(ui, open, page);
121 }
122 });
123 }
124 }
125
126 fn list_ui(&mut self, ui: &mut egui::Ui, items: &[ListItem<K>]) {
127 ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| {
129 for item in items {
130 match item {
131 ListItem::Heading(text) => {
132 ui.add_space(6.0);
133 ui.label(egui::RichText::new(text).small().weak());
134 }
135 ListItem::Entry { page, label, dirty } => {
136 let open = self.open.as_ref() == Some(page);
137 if ui.selectable_label(open, marked(label, *dirty)).clicked() {
138 self.open = Some(page.clone());
139 }
140 }
141 ListItem::Gap => {
142 ui.add_space(12.0);
143 }
144 }
145 }
146 });
147 }
148}
149
150fn page_ui<K>(ui: &mut egui::Ui, open: K, page: impl FnOnce(&mut egui::Ui, K)) {
152 egui::ScrollArea::vertical()
153 .id_salt("corundum_settings_page")
154 .auto_shrink(false)
155 .show(ui, |ui| page(ui, open));
156}
157
158pub(super) fn marked(label: &str, dirty: bool) -> String {
160 if dirty {
161 format!("{label} •")
162 } else {
163 label.to_owned()
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use crate::test_support::render;
171
172 fn items() -> Vec<ListItem<u8>> {
173 vec![
174 ListItem::Heading("App".into()),
175 ListItem::entry(1, "One"),
176 ListItem::Entry {
177 page: 2,
178 label: "Two".into(),
179 dirty: true,
180 },
181 ListItem::Gap,
182 ListItem::entry(3, "About"),
183 ]
184 }
185
186 #[test]
187 fn wide_it_opens_the_first_page_beside_the_list() {
188 let mut layout = SettingsLayout::new();
189 let mut drawn = None;
190 render([900.0, 600.0], |ui| {
191 layout.ui(ui, &items(), |_, k| drawn = Some(k))
192 });
193 assert_eq!(layout.current(), Some(&1));
194 assert_eq!(drawn, Some(1));
195 }
196
197 #[test]
198 fn compact_it_shows_the_list_then_one_page() {
199 let mut layout = SettingsLayout::new();
200 let mut drawn = None;
201 render([400.0, 700.0], |ui| {
202 layout.ui(ui, &items(), |_, k| drawn = Some(k))
203 });
204 assert_eq!(layout.current(), None, "the list alone, no page chosen");
205 assert_eq!(drawn, None);
206 layout.open(Some(3));
207 render([400.0, 700.0], |ui| {
208 layout.ui(ui, &items(), |_, k| drawn = Some(k))
209 });
210 assert_eq!(drawn, Some(3));
211 }
212
213 #[test]
214 fn a_page_gone_from_the_list_is_closed() {
215 let mut layout = SettingsLayout::new();
216 layout.open(Some(9));
217 render([400.0, 700.0], |ui| layout.ui(ui, &items(), |_, _| {}));
218 assert_eq!(layout.current(), None);
219 layout.open(Some(9));
220 render([900.0, 600.0], |ui| layout.ui(ui, &items(), |_, _| {}));
221 assert_eq!(layout.current(), Some(&1), "wide, the first page instead");
222 }
223
224 #[test]
225 fn a_dirty_entry_is_marked() {
226 assert_eq!(marked("Two", true), "Two •");
227 assert_eq!(marked("One", false), "One");
228 }
229}