Skip to main content

corundum_framework_gui/settings/
rail.rs

1//! [`RailPage`]: the order of the shell's rail, and which items it shows.
2
3use crate::shell::{RailEntry, RailOrder, move_to, published, request};
4use crate::tr;
5
6/// The settings page where the person drags the [`Shell`](crate::Shell)'s screens and
7/// actions into their order and ticks those the rail shows; the rest are in its More menu,
8/// with those the window has no room for. Put it into the app's
9/// [`Settings`](super::Settings), in its general group, say.
10///
11/// Unlike the other pages, it takes effect at once: it reaches the shell through the egui
12/// context, and the shell keeps the order — on the device too, with the `serde` feature and
13/// a [store](crate::store). An app that keeps the order itself reads it back with
14/// [`Shell::rail_order`](crate::Shell::rail_order).
15#[derive(Default)]
16pub struct RailPage {
17    /// The rows' rects, for the tests.
18    #[cfg(test)]
19    rows: Vec<egui::Rect>,
20}
21
22/// What a row carries when dragged: its place.
23struct RowDrag(usize);
24
25impl RailPage {
26    pub fn new() -> RailPage {
27        RailPage::default()
28    }
29}
30
31impl super::SettingsSection for RailPage {
32    fn label(&self, ctx: &egui::Context) -> String {
33        tr!(ctx, "corundum-settings-rail")
34    }
35
36    fn ui(&mut self, ui: &mut egui::Ui) {
37        ui.heading(tr!(ui, "corundum-settings-rail"));
38        ui.separator();
39        ui.label(tr!(ui, "corundum-settings-rail-intro"));
40        ui.add_space(8.0);
41        let Some(mut entries) = published(ui.ctx()) else {
42            return;
43        };
44        if let Some(changed) = self.rows_ui(ui, &entries) {
45            match changed {
46                Change::Moved(from, to, after) => {
47                    move_to(&mut entries, from, to, after);
48                }
49                Change::Toggled(i) => entries[i].shown = !entries[i].shown,
50            }
51            request(ui.ctx(), order_of(&entries));
52        }
53        ui.add_space(8.0);
54        if ui.button(tr!(ui, "corundum-settings-rail-reset")).clicked() {
55            request(ui.ctx(), RailOrder::default());
56        }
57    }
58}
59
60/// What the person did to the list.
61enum Change {
62    /// The row at the first place was dropped before (or, `true`, after) the second's.
63    Moved(usize, usize, bool),
64    /// The row's box was ticked or cleared.
65    Toggled(usize),
66}
67
68impl RailPage {
69    fn rows_ui(&mut self, ui: &mut egui::Ui, entries: &[RailEntry]) -> Option<Change> {
70        let ctx = ui.ctx().clone();
71        let mut change = None;
72        #[cfg(test)]
73        self.rows.clear();
74        for (n, entry) in entries.iter().enumerate() {
75            let row = ui.horizontal(|ui| {
76                ui.dnd_drag_source(
77                    egui::Id::unique(("corundum_rail_row", n)),
78                    RowDrag(n),
79                    |ui| {
80                        ui.add(crate::icons::image_as(
81                            ui,
82                            crate::icons::grip_vertical(),
83                            16.0,
84                            crate::theme::Tone::Muted,
85                            crate::theme::Swatch::Neutral,
86                        ));
87                    },
88                )
89                .response
90                .on_hover_text(tr!(&ctx, "corundum-settings-rail-drag"));
91                let mut shown = entry.shown;
92                let image = crate::icons::image(ui, entry.icon.clone(), 16.0);
93                if ui
94                    .checkbox(&mut shown, (image, tr!(&ctx, entry.label)))
95                    .on_hover_text(tr!(&ctx, "corundum-settings-rail-show"))
96                    .changed()
97                {
98                    change = Some(Change::Toggled(n));
99                }
100            });
101            let row = row.response;
102            #[cfg(test)]
103            self.rows.push(row.rect);
104            // Dropped on the row's upper half, the dragged one goes before it; else after.
105            let below = ctx
106                .pointer_interact_pos()
107                .is_some_and(|p| p.y > row.rect.center().y);
108            if row.dnd_hover_payload::<RowDrag>().is_some() {
109                let y = if below {
110                    row.rect.bottom()
111                } else {
112                    row.rect.top()
113                };
114                ui.painter().hline(
115                    row.rect.x_range(),
116                    y,
117                    egui::Stroke::new(2.0, ui.visuals().selection.stroke.color),
118                );
119            }
120            if let Some(drag) = row.dnd_release_payload::<RowDrag>() {
121                change = Some(Change::Moved(drag.0, n, below));
122            }
123        }
124        change
125    }
126}
127
128/// The order `entries` stand in.
129fn order_of(entries: &[RailEntry]) -> RailOrder {
130    RailOrder {
131        order: entries.iter().map(|e| e.key.into()).collect(),
132        hidden: entries
133            .iter()
134            .filter(|e| !e.shown)
135            .map(|e| e.key.into())
136            .collect(),
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::settings::SettingsSection;
144    use crate::{Action, Destination, Shell};
145
146    #[derive(Clone, Copy, PartialEq, Debug)]
147    enum Screen {
148        A,
149        B,
150    }
151
152    fn shell() -> Shell<Screen> {
153        Shell::from_items(vec![
154            Action::new("new", crate::icons::plus(), "new-entry").into(),
155            Destination::new(Screen::A, crate::icons::info(), "a").into(),
156            Destination::new(Screen::B, crate::icons::star(), "b").into(),
157        ])
158    }
159
160    /// Frames of the shell with the page as its screen, in `ctx`; a click at `at` if given.
161    fn frames(
162        ctx: &egui::Context,
163        shell: &mut Shell<Screen>,
164        page: &mut RailPage,
165        at: Option<egui::Pos2>,
166    ) {
167        let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0));
168        let mut frame = |events: Vec<egui::Event>| {
169            let input = egui::RawInput {
170                screen_rect: Some(rect),
171                events,
172                ..Default::default()
173            };
174            let mut output = ctx.run_ui(input, |ui| {
175                shell.ui(ui, |ui, _| {
176                    egui::CentralPanel::default().show(ui, |ui| page.ui(ui));
177                })
178            });
179            output.textures_delta.clear();
180        };
181        frame(vec![]);
182        frame(vec![]);
183        if let Some(pos) = at {
184            let button = |pressed| egui::Event::PointerButton {
185                pos,
186                button: egui::PointerButton::Primary,
187                pressed,
188                modifiers: egui::Modifiers::NONE,
189            };
190            frame(vec![egui::Event::PointerMoved(pos)]);
191            frame(vec![button(true)]);
192            frame(vec![button(false)]);
193            frame(vec![]);
194        }
195    }
196
197    #[test]
198    fn the_page_lists_the_rail_in_its_order() {
199        let ctx = egui::Context::default();
200        let (mut shell, mut page) = (shell(), RailPage::new());
201        frames(&ctx, &mut shell, &mut page, None);
202        assert_eq!(page.rows.len(), 3);
203        for pair in page.rows.windows(2) {
204            assert!(pair[0].bottom() <= pair[1].top(), "{pair:?}");
205        }
206    }
207
208    #[test]
209    fn clearing_a_box_hides_the_item() {
210        let ctx = egui::Context::default();
211        let (mut shell, mut page) = (shell(), RailPage::new());
212        frames(&ctx, &mut shell, &mut page, None);
213        // The box sits after the handle.
214        let row = page.rows[0];
215        frames(
216            &ctx,
217            &mut shell,
218            &mut page,
219            Some(row.left_center() + egui::vec2(30.0, 0.0)),
220        );
221        assert_eq!(shell.rail_order().hidden, ["new"]);
222    }
223
224    #[test]
225    fn the_order_of_entries_is_their_keys() {
226        let ctx = egui::Context::default();
227        let (mut shell, mut page) = (shell(), RailPage::new());
228        frames(&ctx, &mut shell, &mut page, None);
229        let mut entries = published(&ctx).unwrap();
230        move_to(&mut entries, 0, 2, true);
231        entries[1].shown = false;
232        let order = order_of(&entries);
233        assert_eq!(order.order, ["a", "b", "new"]);
234        assert_eq!(order.hidden, ["b"]);
235    }
236
237    #[test]
238    fn without_a_shell_the_page_lists_nothing() {
239        let mut page = RailPage::new();
240        crate::test_support::render([600.0, 400.0], |ui| page.ui(ui));
241        assert!(page.rows.is_empty());
242    }
243}