Skip to main content

corundum_framework_gui/settings/
layout.rs

1//! [`SettingsLayout`]: the list of pages beside the open one, or, narrow, one at a time.
2
3use crate::COMPACT_WIDTH;
4use crate::tr;
5
6/// The list's width, wide.
7const LIST_WIDTH: f32 = 170.0;
8
9/// One line of the list.
10#[derive(Clone, PartialEq, Eq, Debug)]
11pub enum ListItem<K> {
12    /// A group's heading, in small weak text.
13    Heading(String),
14    /// A page: `page` names it to [`SettingsLayout::ui`]'s page closure. A `dirty` page
15    /// holds changes not saved yet, and is marked.
16    Entry { page: K, label: String, dirty: bool },
17    /// Space setting the lines after it apart: About, say.
18    Gap,
19}
20
21impl<K> ListItem<K> {
22    /// A page's entry, not dirty.
23    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/// The settings screen's layout: the list beside the open page, or — narrower than
33/// [`COMPACT_WIDTH`] — the list, or the open page under a way back to it. It keeps only
34/// which page is open; the host gives the list every frame, so pages may come and go.
35#[derive(Clone, Debug)]
36pub struct SettingsLayout<K> {
37    /// `None` shows the list alone when compact; when wide, the first page opens.
38    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    /// A layout with no page chosen yet.
49    pub fn new() -> SettingsLayout<K> {
50        SettingsLayout::default()
51    }
52
53    /// The open page.
54    pub fn current(&self) -> Option<&K> {
55        self.open.as_ref()
56    }
57
58    /// Open `page`; `None` goes back to the list when compact.
59    pub fn open(&mut self, page: Option<K>) {
60        self.open = page;
61    }
62
63    /// Render into `ui`, as the root of what the host leaves it: the list of `items`, and
64    /// `page` drawing the open one in a vertical scroll area. A page gone from `items` is
65    /// closed.
66    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        // Full-width rows, their labels at the left.
128        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
150/// The open page, scrolled.
151fn 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
158/// `label`, marked when `dirty`: a page's entry, a scope's tab.
159pub(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}