Skip to main content

corundum_framework_gui/
shell.rs

1//! [`Shell`]: an app's window frame — its screens on a rail, its actions under them, and
2//! at the rail's foot the screens that belong there (Settings) and a slot of the app's own
3//! (a status dot).
4//!
5//! Wide, the rail runs down the left (as Obsidian's does); narrower than
6//! [`COMPACT_WIDTH`], it becomes a bar along the bottom, as on a phone. The width decides,
7//! not the platform, so a desktop window made narrow shows the phone layout.
8
9/// Below this width the layout is a phone's: the rail becomes a bottom bar, and screens
10/// with a list and a page show one at a time.
11pub const COMPACT_WIDTH: f32 = 600.0;
12
13/// The rail's width, wide.
14const RAIL_WIDTH: f32 = 48.0;
15
16/// The space above and below the status bar's row.
17const STATUS_MARGIN: f32 = 3.0;
18
19/// One of the app's screens on the rail.
20pub struct Destination<D> {
21    /// What the app matches on to draw it.
22    pub id: D,
23    /// A Lucide icon (see [`icons`](crate::icons)), stroked in white so it takes the text
24    /// colour: the rail shows only this.
25    pub icon: egui::ImageSource<'static>,
26    /// The id of the screen's name in the app's translations, looked up each frame and shown
27    /// when the icon is hovered.
28    pub label: &'static str,
29}
30
31impl<D> Destination<D> {
32    /// A screen named by the message `label`, drawn as `icon`.
33    pub fn new(id: D, icon: egui::ImageSource<'static>, label: &'static str) -> Destination<D> {
34        Destination { id, icon, label }
35    }
36}
37
38/// A button of the app's on the rail that does something — "new-entry" — rather than
39/// open a screen. [`Shell::take_action`] says which one was clicked.
40pub struct Action {
41    /// What [`Shell::take_action`] returns when it is clicked.
42    pub id: &'static str,
43    /// A Lucide icon, as for a [`Destination`].
44    pub icon: egui::ImageSource<'static>,
45    /// The id of what it does in the app's translations, looked up each frame and shown
46    /// when the icon is hovered.
47    pub label: &'static str,
48}
49
50impl Action {
51    /// A button named by the message `label`, drawn as `icon`, reported as `id`.
52    pub fn new(id: &'static str, icon: egui::ImageSource<'static>, label: &'static str) -> Action {
53        Action { id, icon, label }
54    }
55}
56
57/// The window frame. Hold one as the app's root; call [`ui`](Self::ui) every frame.
58///
59/// Labels are message ids: each frame the hover text is looked up in the language
60/// installed (see [`i18n`](crate::i18n)), so a language switch shows on the next frame.
61///
62/// The shell keeps clear of the system's bars and notches (egui's
63/// [safe area](egui::InputState::safe_area_insets), which iOS fills): the rail, the bar's
64/// buttons and the screens stay inside it, and the bottom bar's fill runs on under the home
65/// indicator, as a phone's tab bar does. Give it the window's whole root `ui`; the app adds
66/// no margin of its own. If the app does keep `ui` clear of them itself, the shell sees
67/// that and adds nothing, so the margin is never doubled. On Android,
68/// `window::run_android` keeps the app's `ui` clear of the bars and the
69/// keyboard already.
70///
71/// ```no_run
72/// # use corundum_framework_gui::{Destination, Shell, icons};
73/// # fn example(ui: &mut egui::Ui) {
74/// #[derive(Clone, Copy, PartialEq)]
75/// enum Screen { Gallery, Albums, Settings }
76///
77/// let mut shell = Shell::new(vec![
78///     // The app's own Lucide SVGs: `egui::include_image!("../assets/icons/images.svg")`.
79///     Destination::new(Screen::Gallery, icons::folder(), "gallery"),
80///     Destination::new(Screen::Albums, icons::folder(), "albums"),
81/// ])
82/// .with_footer(vec![Destination::new(Screen::Settings, icons::settings(), "settings")]);
83/// shell.ui(ui, |ui, screen| match screen {
84///     Screen::Gallery => { egui::CentralPanel::default().show(ui, |ui| ui.label("…")); }
85///     Screen::Albums => { egui::CentralPanel::default().show(ui, |ui| ui.label("…")); }
86///     Screen::Settings => { egui::CentralPanel::default().show(ui, |ui| ui.label("…")); }
87/// });
88/// # }
89/// ```
90pub struct Shell<D> {
91    destinations: Vec<Destination<D>>,
92    actions: Vec<Action>,
93    footer: Vec<Destination<D>>,
94    /// The action clicked and not yet taken.
95    clicked: Option<&'static str>,
96    /// Where each action and footer destination was last drawn, for the tests to click.
97    #[cfg(test)]
98    action_rects: Vec<egui::Rect>,
99    #[cfg(test)]
100    footer_rects: Vec<egui::Rect>,
101    /// The destinations' and the actions' rects on the bar, for the tests.
102    #[cfg(test)]
103    cell_rects: Vec<egui::Rect>,
104    open: Option<D>,
105    /// How wide the footer and the foot were on the bar last frame: their end of the bar
106    /// is kept this wide, and the cells share the rest.
107    foot_width: f32,
108    /// Whether the SVG loader, the theme and the language have been put in place.
109    loaders: bool,
110}
111
112impl<D: Copy + PartialEq> Shell<D> {
113    /// A frame with `destinations` on the rail, the first one open.
114    pub fn new(destinations: Vec<Destination<D>>) -> Shell<D> {
115        let open = destinations.first().map(|d| d.id);
116        Shell {
117            destinations,
118            actions: Vec::new(),
119            footer: Vec::new(),
120            clicked: None,
121            #[cfg(test)]
122            action_rects: Vec::new(),
123            #[cfg(test)]
124            footer_rects: Vec::new(),
125            #[cfg(test)]
126            cell_rects: Vec::new(),
127            open,
128            foot_width: 0.0,
129            loaders: false,
130        }
131    }
132
133    /// Put `actions` on the rail, under the screens (on the bottom bar, after them).
134    pub fn with_actions(mut self, actions: Vec<Action>) -> Shell<D> {
135        self.actions = actions;
136        self
137    }
138
139    /// Put `footer` at the rail's foot (at the bottom bar's end): where an app puts
140    /// Settings. With no other destinations, the first of these is open.
141    pub fn with_footer(mut self, footer: Vec<Destination<D>>) -> Shell<D> {
142        self.footer = footer;
143        if self.open.is_none() {
144            self.open = self.footer.first().map(|d| d.id);
145        }
146        self
147    }
148
149    /// The [`Action`] clicked in the last [`ui`](Self::ui), once: call it after `ui`
150    /// every frame and do what it names.
151    pub fn take_action(&mut self) -> Option<&'static str> {
152        self.clicked.take()
153    }
154
155    /// The destination that is open; `None` only when there are none.
156    pub fn current(&self) -> Option<D> {
157        self.open
158    }
159
160    /// Open the destination `id`.
161    pub fn open(&mut self, id: D) {
162        self.open = Some(id);
163    }
164
165    /// Render into the window's root `ui`. `content` draws the open destination into what
166    /// the frame leaves of `ui`, as it would at the root: its own panels, then a
167    /// `CentralPanel`. The first frame installs the default [`theme`](crate::theme) and
168    /// [`language`](crate::i18n) if the app has not installed them.
169    pub fn ui(&mut self, ui: &mut egui::Ui, content: impl FnOnce(&mut egui::Ui, D)) {
170        self.ui_with_foot(ui, |_| None, content);
171    }
172
173    /// As [`ui`](Self::ui), with `foot` drawn beside the footer — a status dot, say. A
174    /// destination `foot` returns is opened, and drawn this frame.
175    pub fn ui_with_foot(
176        &mut self,
177        ui: &mut egui::Ui,
178        foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
179        content: impl FnOnce(&mut egui::Ui, D),
180    ) {
181        self.frame_ui(ui, foot, None::<fn(&mut egui::Ui)>, content);
182    }
183
184    /// As [`ui_with_foot`](Self::ui_with_foot), with a status bar along the window's
185    /// bottom when wide. `status` fills it from the left — a log's last line, the services'
186    /// state, the track playing; the bar's right end holds `foot` (there rather than at the
187    /// rail's foot), then the [detail pane](crate::detail)'s toggle when the open screen has
188    /// one. Narrow, as on a phone, there is no status bar: `foot` stays on the bottom bar,
189    /// and `status` is not called.
190    pub fn ui_with_status(
191        &mut self,
192        ui: &mut egui::Ui,
193        foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
194        status: impl FnOnce(&mut egui::Ui),
195        content: impl FnOnce(&mut egui::Ui, D),
196    ) {
197        self.frame_ui(ui, foot, Some(status), content);
198    }
199
200    fn frame_ui(
201        &mut self,
202        ui: &mut egui::Ui,
203        foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
204        status: Option<impl FnOnce(&mut egui::Ui)>,
205        content: impl FnOnce(&mut egui::Ui, D),
206    ) {
207        if !std::mem::replace(&mut self.loaders, true) {
208            crate::icons::install_loaders(ui.ctx());
209            // The app may have installed its saved theme already; only fill the gap.
210            if !crate::theme::is_installed(ui.ctx()) {
211                crate::theme::install(ui.ctx(), &crate::theme::ThemeSettings::default());
212            }
213            // Likewise the language.
214            if !crate::i18n::is_installed(ui.ctx()) {
215                crate::i18n::install(ui.ctx(), &crate::i18n::LanguageSettings::default());
216            }
217        }
218        let safe = unsafe_margin(ui);
219        let full = ui.max_rect();
220        let compact = full.width() - safe.sum().x < COMPACT_WIDTH;
221        // Narrow, the bar's fill runs on under the home indicator, its buttons above it;
222        // wide, the rail stops above it too.
223        let inner = if compact {
224            full - egui::epaint::MarginF32 {
225                bottom: 0.0,
226                ..safe
227            }
228        } else {
229            full - safe
230        };
231        // The strips under the status bar and the notch take the panels' colour.
232        if safe != egui::epaint::MarginF32::ZERO {
233            ui.painter().rect_filled(full, 0.0, ui.visuals().panel_fill);
234        }
235        if !compact && let Some(status) = status {
236            // The bar under the rail and the screen, which keep what is above it.
237            let height = ui.spacing().interact_size.y + 2.0 * STATUS_MARGIN;
238            let above = inner.with_max_y(inner.bottom() - height);
239            let strip = inner.with_min_y(above.bottom());
240            ui.scope_builder(egui::UiBuilder::new().max_rect(above), |ui| {
241                let opened = egui::Panel::left("shell_rail")
242                    .resizable(false)
243                    .exact_size(RAIL_WIDTH)
244                    .show(ui, |ui| self.rail_ui(ui, |_| None))
245                    .inner;
246                if let Some(id) = opened {
247                    self.open = Some(id);
248                }
249                if let Some(id) = self.open {
250                    content(ui, id);
251                }
252            });
253            // Drawn after the screen, so the toggle knows of the detail pane drawn there.
254            if let Some(id) = status_bar_ui(ui, strip, foot, status) {
255                self.open = Some(id);
256                ui.ctx().request_repaint();
257            }
258            return;
259        }
260        ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| {
261            let opened = if compact {
262                let mut frame = egui::Frame::side_top_panel(ui.style());
263                frame.inner_margin.bottom = frame
264                    .inner_margin
265                    .bottom
266                    .saturating_add(safe.bottom.round().min(127.0) as i8);
267                egui::Panel::bottom("shell_bar")
268                    .frame(frame)
269                    .show(ui, |ui| self.bar_ui(ui, foot))
270                    .inner
271            } else {
272                egui::Panel::left("shell_rail")
273                    .resizable(false)
274                    .exact_size(RAIL_WIDTH)
275                    .show(ui, |ui| self.rail_ui(ui, foot))
276                    .inner
277            };
278            if let Some(id) = opened {
279                self.open = Some(id);
280            }
281            if let Some(id) = self.open {
282                content(ui, id);
283            }
284        });
285    }
286
287    fn rail_ui(
288        &mut self,
289        ui: &mut egui::Ui,
290        foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
291    ) -> Option<D> {
292        ui.vertical_centered(|ui| {
293            ui.add_space(4.0);
294            for i in 0..self.destinations.len() {
295                self.destination_ui(ui, i, BUTTON);
296            }
297            if !self.actions.is_empty() {
298                ui.add_space(4.0);
299                ui.separator();
300                ui.add_space(4.0);
301                for i in 0..self.actions.len() {
302                    self.action_ui(ui, i, BUTTON);
303                }
304            }
305        });
306        ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| {
307            ui.add_space(4.0);
308            for i in 0..self.footer.len() {
309                self.footer_ui(ui, i, BUTTON);
310            }
311            foot(ui)
312        })
313        .inner
314    }
315
316    /// The phone layout: one cell per screen, then one per action, the footer and the
317    /// foot last.
318    ///
319    /// The footer and the foot keep the width they need at the bar's end; the screens and
320    /// the actions share what is left, their buttons narrowing below [`BUTTON`] when the
321    /// cells do. So the foot never leaves the screen, however many screens there are.
322    ///
323    /// Every screen gets a cell for now. When an app has more than a bar can hold, the ones
324    /// that do not fit move to a separate list of all of them (with a choice of which stay
325    /// on the bar) — not into Settings (#275).
326    fn bar_ui(
327        &mut self,
328        ui: &mut egui::Ui,
329        foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
330    ) -> Option<D> {
331        let rect = ui.available_rect_before_wrap();
332        let gap = ui.spacing().item_spacing.x;
333        let end = rect.right() - self.foot_width.min(rect.width());
334        let screens = self.destinations.len();
335        let cells = screens + self.actions.len();
336        if cells > 0 {
337            let row = egui::Rect::from_min_max(rect.min, egui::pos2(end - gap, rect.bottom()));
338            ui.scope_builder(egui::UiBuilder::new().max_rect(row), |ui| {
339                ui.columns(cells, |columns| {
340                    for (i, column) in columns.iter_mut().enumerate() {
341                        column.vertical_centered(|ui| {
342                            let side = ui.available_width().min(BUTTON);
343                            let response = if i < screens {
344                                self.destination_ui(ui, i, side)
345                            } else {
346                                self.action_ui(ui, i - screens, side)
347                            };
348                            #[cfg(test)]
349                            {
350                                self.cell_rects.resize(cells, egui::Rect::NOTHING);
351                                self.cell_rects[i] = response.rect;
352                            }
353                            #[cfg(not(test))]
354                            let _ = response;
355                        });
356                    }
357                });
358            });
359        }
360        let at_end = egui::Rect::from_min_max(egui::pos2(end, rect.top()), rect.max);
361        let end_row = ui
362            .scope_builder(egui::UiBuilder::new().max_rect(at_end), |ui| {
363                ui.horizontal(|ui| {
364                    for i in 0..self.footer.len() {
365                        self.footer_ui(ui, i, BUTTON);
366                    }
367                    foot(ui)
368                })
369            })
370            .inner;
371        let opened = end_row.inner;
372        // The end's width is known only once drawn: lay the frame out again with it.
373        let width = end_row.response.rect.width();
374        if (width - self.foot_width).abs() > 0.5 {
375            self.foot_width = width;
376            ui.ctx()
377                .request_discard("the shell's bar foot changed width");
378        }
379        opened
380    }
381
382    fn destination_ui(&mut self, ui: &mut egui::Ui, i: usize, side: f32) -> egui::Response {
383        let d = &self.destinations[i];
384        let open = self.open == Some(d.id);
385        let response = icon_button(ui, open, d.icon.clone(), side)
386            .on_hover_text(crate::i18n::tr(ui.ctx(), d.label));
387        if response.clicked() {
388            self.open = Some(d.id);
389        }
390        response
391    }
392
393    /// What the hover text of destination `i` says now.
394    #[cfg(test)]
395    fn label_text(&self, ctx: &egui::Context, i: usize) -> String {
396        crate::i18n::tr(ctx, self.destinations[i].label)
397    }
398
399    fn footer_ui(&mut self, ui: &mut egui::Ui, i: usize, side: f32) {
400        let d = &self.footer[i];
401        let open = self.open == Some(d.id);
402        let response = icon_button(ui, open, d.icon.clone(), side)
403            .on_hover_text(crate::i18n::tr(ui.ctx(), d.label));
404        #[cfg(test)]
405        {
406            self.footer_rects
407                .resize(self.footer.len(), egui::Rect::NOTHING);
408            self.footer_rects[i] = response.rect;
409        }
410        if response.clicked() {
411            self.open = Some(self.footer[i].id);
412        }
413    }
414
415    fn action_ui(&mut self, ui: &mut egui::Ui, i: usize, side: f32) -> egui::Response {
416        let a = &self.actions[i];
417        let response = icon_button(ui, false, a.icon.clone(), side)
418            .on_hover_text(crate::i18n::tr(ui.ctx(), a.label));
419        #[cfg(test)]
420        {
421            self.action_rects
422                .resize(self.actions.len(), egui::Rect::NOTHING);
423            self.action_rects[i] = response.rect;
424        }
425        if response.clicked() {
426            self.clicked = Some(a.id);
427        }
428        response
429    }
430}
431
432/// The status bar along the bottom of `strip`: `status` from the left, `foot` and the
433/// detail pane's toggle at the right end. The destination `foot` returns, if any.
434fn status_bar_ui<D>(
435    ui: &mut egui::Ui,
436    strip: egui::Rect,
437    foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
438    status: impl FnOnce(&mut egui::Ui),
439) -> Option<D> {
440    let visuals = ui.visuals();
441    ui.painter().rect_filled(strip, 0.0, visuals.panel_fill);
442    ui.painter().hline(
443        strip.x_range(),
444        strip.top(),
445        visuals.widgets.noninteractive.bg_stroke,
446    );
447    let row = strip.shrink2(egui::vec2(8.0, STATUS_MARGIN));
448    ui.scope_builder(
449        egui::UiBuilder::new()
450            .max_rect(row)
451            .layout(egui::Layout::right_to_left(egui::Align::Center)),
452        |ui| {
453            ui.set_clip_rect(row);
454            crate::detail::toggle_ui(ui);
455            let opened = foot(ui);
456            // What is left, from the left: the app's.
457            ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), status);
458            opened
459        },
460    )
461    .inner
462}
463
464/// How far the system's bars and notches (egui's safe area insets) reach into `ui`, side by
465/// side: none where `ui` already keeps clear of them.
466fn unsafe_margin(ui: &egui::Ui) -> egui::epaint::MarginF32 {
467    let safe = ui.ctx().content_rect();
468    let rect = ui.max_rect();
469    egui::epaint::MarginF32 {
470        left: (safe.left() - rect.left()).max(0.0),
471        right: (rect.right() - safe.right()).max(0.0),
472        top: (safe.top() - rect.top()).max(0.0),
473        bottom: (rect.bottom() - safe.bottom()).max(0.0),
474    }
475}
476
477/// A rail or bar button's side, where there is room for it.
478const BUTTON: f32 = 36.0;
479
480/// A button with `icon` in it, `side` points wide ([`BUTTON`], or less on a crowded bar) and
481/// [`BUTTON`] tall, so a narrowed one still lines up with the rest and is as easy to tap.
482fn icon_button(
483    ui: &mut egui::Ui,
484    open: bool,
485    icon: egui::ImageSource<'static>,
486    side: f32,
487) -> egui::Response {
488    // A narrow button takes a smaller icon first, then less padding beside it, so it stays
489    // `side` wide.
490    let pad = ui.spacing().button_padding.x;
491    let icon_size = (side - 2.0 * pad).clamp(12.0, 20.0);
492    ui.scope(|ui| {
493        let padding = &mut ui.spacing_mut().button_padding.x;
494        *padding = padding.min(((side - icon_size) / 2.0).max(0.0));
495        // The open destination's icon takes the accent, as egui's selected text does.
496        ui.add(
497            crate::icons::button(icon, icon_size)
498                .selected(open)
499                .frame_when_inactive(open)
500                .min_size(egui::vec2(side, BUTTON)),
501        )
502    })
503    .inner
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use crate::test_support::render;
510
511    #[derive(Clone, Copy, PartialEq, Debug)]
512    enum Screen {
513        Entries,
514        Summary,
515        Prefs,
516    }
517
518    const SIZES: [[f32; 2]; 2] = [[900.0, 600.0], [400.0, 700.0]];
519
520    fn shell() -> Shell<Screen> {
521        Shell::new(vec![
522            Destination::new(Screen::Entries, crate::icons::settings(), "entries"),
523            Destination::new(Screen::Summary, crate::icons::arrow_left(), "summary"),
524        ])
525    }
526
527    fn prefs() -> Vec<Destination<Screen>> {
528        vec![Destination::new(
529            Screen::Prefs,
530            crate::icons::settings(),
531            "preferences",
532        )]
533    }
534
535    fn screen(ui: &mut egui::Ui) {
536        egui::CentralPanel::default().show(ui, |ui| ui.label("screen"));
537    }
538
539    /// Lay `shell` out over two frames of a `size` screen, then click where `at` says.
540    fn click(shell: &mut Shell<Screen>, size: [f32; 2], at: impl Fn(&Shell<Screen>) -> egui::Pos2) {
541        let ctx = egui::Context::default();
542        let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(size[0], size[1]));
543        let frame = |events: Vec<egui::Event>, shell: &mut Shell<Screen>| {
544            let input = egui::RawInput {
545                screen_rect: Some(rect),
546                events,
547                ..Default::default()
548            };
549            let mut output = ctx.run_ui(input, |ui| shell.ui(ui, |ui, _| screen(ui)));
550            output.textures_delta.clear();
551        };
552        frame(vec![], shell);
553        frame(vec![], shell);
554        let pos = at(shell);
555        let button = |pressed| egui::Event::PointerButton {
556            pos,
557            button: egui::PointerButton::Primary,
558            pressed,
559            modifiers: egui::Modifiers::NONE,
560        };
561        frame(vec![egui::Event::PointerMoved(pos)], shell);
562        frame(vec![button(true)], shell);
563        frame(vec![button(false)], shell);
564    }
565
566    #[test]
567    fn it_opens_on_the_first_screen_and_draws_it() {
568        let mut shell = shell();
569        assert_eq!(shell.current(), Some(Screen::Entries));
570        for size in SIZES {
571            let mut drawn = Vec::new();
572            render(size, |ui| {
573                shell.ui(ui, |ui, s| {
574                    drawn.push(s);
575                    screen(ui);
576                })
577            });
578            assert_eq!(drawn, [Screen::Entries, Screen::Entries]);
579        }
580    }
581
582    #[test]
583    fn actions_are_drawn_in_both_layouts_and_report_nothing_unclicked() {
584        let mut shell =
585            shell().with_actions(vec![Action::new("new", crate::icons::plus(), "new-entry")]);
586        for size in SIZES {
587            shell.action_rects.clear();
588            render(size, |ui| shell.ui(ui, |ui, _| screen(ui)));
589            assert!(shell.action_rects[0].is_positive(), "{size:?}");
590            assert_eq!(shell.take_action(), None);
591        }
592        assert_eq!(shell.current(), Some(Screen::Entries));
593    }
594
595    #[test]
596    fn a_clicked_action_is_taken_once_and_opens_nothing() {
597        let mut shell =
598            shell().with_actions(vec![Action::new("new", crate::icons::plus(), "new-entry")]);
599        click(&mut shell, SIZES[0], |s| s.action_rects[0].center());
600        assert_eq!(shell.take_action(), Some("new"));
601        assert_eq!(shell.take_action(), None);
602        assert_eq!(shell.current(), Some(Screen::Entries));
603    }
604
605    #[test]
606    fn footer_and_foot_are_in_both_layouts() {
607        let mut shell = shell().with_footer(prefs());
608        for size in SIZES {
609            shell.footer_rects.clear();
610            let mut foot = egui::Rect::NOTHING;
611            render(size, |ui| {
612                shell.ui_with_foot(
613                    ui,
614                    |ui| {
615                        foot = ui
616                            .allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::click())
617                            .0;
618                        None
619                    },
620                    |ui, _| screen(ui),
621                )
622            });
623            assert!(shell.footer_rects[0].is_positive(), "{size:?}");
624            assert!(foot.is_positive(), "{size:?}");
625        }
626    }
627
628    #[test]
629    fn clicking_a_footer_destination_opens_it() {
630        for size in SIZES {
631            let mut shell = shell().with_footer(prefs());
632            click(&mut shell, size, |s| s.footer_rects[0].center());
633            assert_eq!(shell.current(), Some(Screen::Prefs), "{size:?}");
634        }
635        let mut shell = shell().with_footer(prefs());
636        shell.open(Screen::Prefs);
637        let mut drawn = Vec::new();
638        render([900.0, 600.0], |ui| {
639            shell.ui(ui, |ui, s| {
640                drawn.push(s);
641                screen(ui);
642            })
643        });
644        assert_eq!(drawn, [Screen::Prefs, Screen::Prefs]);
645    }
646
647    #[test]
648    fn a_destination_the_foot_returns_is_opened() {
649        let mut shell = shell();
650        let mut drawn = Vec::new();
651        render([900.0, 600.0], |ui| {
652            shell.ui_with_foot(
653                ui,
654                |_| Some(Screen::Summary),
655                |ui, s| {
656                    drawn.push(s);
657                    screen(ui);
658                },
659            )
660        });
661        assert_eq!(shell.current(), Some(Screen::Summary));
662        assert_eq!(drawn, [Screen::Summary, Screen::Summary]);
663    }
664
665    #[test]
666    fn the_shell_installs_the_default_theme() {
667        let ctx = egui::Context::default();
668        let mut shell = shell();
669        crate::test_support::render_in(&ctx, [900.0, 600.0], |ui| shell.ui(ui, |ui, _| screen(ui)));
670        assert!(crate::theme::is_installed(&ctx));
671    }
672
673    #[test]
674    fn the_shell_keeps_an_installed_theme() {
675        use crate::theme::{Contrast, ThemeSettings};
676        let ctx = egui::Context::default();
677        let settings = ThemeSettings {
678            preference: egui::ThemePreference::Dark,
679            contrast: Contrast::High,
680            ..Default::default()
681        };
682        crate::theme::install(&ctx, &settings);
683        let mut shell = shell();
684        crate::test_support::render_in(&ctx, [900.0, 600.0], |ui| shell.ui(ui, |ui, _| screen(ui)));
685        let high = crate::theme::Theme::new(
686            true,
687            Contrast::High,
688            crate::theme::Tint::Swatch(crate::theme::Swatch::Blue),
689        );
690        assert_eq!(*crate::theme::current(&ctx), high);
691    }
692
693    const T: crate::i18n::Resources = crate::i18n::Resources {
694        name: "shell-test",
695        files: &[
696            ("en", "t-entries = Entries\n"),
697            ("ja", "t-entries = 項目\n"),
698        ],
699    };
700
701    #[test]
702    fn the_shell_installs_the_default_language() {
703        let ctx = egui::Context::default();
704        let mut shell = shell();
705        crate::test_support::render_in(&ctx, [900.0, 600.0], |ui| shell.ui(ui, |ui, _| screen(ui)));
706        assert!(crate::i18n::is_installed(&ctx));
707    }
708
709    #[test]
710    fn the_shell_keeps_an_installed_language() {
711        use crate::i18n::{Language, LanguageSettings};
712        let ctx = egui::Context::default();
713        let ja = LanguageSettings {
714            language: Language::Explicit("ja".into()),
715        };
716        crate::i18n::install(&ctx, &ja);
717        let mut shell = shell();
718        crate::test_support::render_in(&ctx, [900.0, 600.0], |ui| shell.ui(ui, |ui, _| screen(ui)));
719        assert_eq!(crate::i18n::current(&ctx).to_string(), "ja");
720    }
721
722    #[test]
723    fn hover_text_follows_the_language() {
724        use crate::i18n::{Language, LanguageSettings};
725        let ctx = egui::Context::default();
726        crate::i18n::register(&ctx, T);
727        let shell = Shell::new(vec![Destination::new(
728            Screen::Entries,
729            crate::icons::settings(),
730            "t-entries",
731        )]);
732        let set = |tag: &str| {
733            crate::i18n::install(
734                &ctx,
735                &LanguageSettings {
736                    language: Language::Explicit(tag.into()),
737                },
738            )
739        };
740        set("en");
741        assert_eq!(shell.label_text(&ctx, 0), "Entries");
742        set("ja");
743        assert_eq!(shell.label_text(&ctx, 0), "項目");
744    }
745
746    /// Lay `shell` out over two frames of a `size` screen whose system bars take `safe`,
747    /// with `ui` clear of them by `own` already; returns the rect the open screen got.
748    fn render_safe(
749        shell: &mut Shell<Screen>,
750        size: [f32; 2],
751        safe: egui::epaint::MarginF32,
752        own: egui::epaint::MarginF32,
753    ) -> egui::Rect {
754        let ctx = egui::Context::default();
755        let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(size[0], size[1]));
756        let mut drawn = egui::Rect::NOTHING;
757        for _ in 0..2 {
758            let input = egui::RawInput {
759                screen_rect: Some(rect),
760                safe_area_insets: Some(egui::SafeAreaInsets(safe)),
761                ..Default::default()
762            };
763            let mut output = ctx.run_ui(input, |ui| {
764                let inner = ui.max_rect() - own;
765                ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| {
766                    shell.ui(ui, |ui, _| {
767                        drawn = ui.available_rect_before_wrap();
768                        screen(ui);
769                    })
770                });
771            });
772            output.textures_delta.clear();
773        }
774        drawn
775    }
776
777    fn margin(left: f32, top: f32, right: f32, bottom: f32) -> egui::epaint::MarginF32 {
778        egui::epaint::MarginF32 {
779            left,
780            right,
781            top,
782            bottom,
783        }
784    }
785
786    #[test]
787    fn narrow_it_keeps_clear_of_the_status_bar_and_the_home_indicator() {
788        // An iPhone upright: the status bar and the dynamic island, the home indicator.
789        let mut shell = shell().with_footer(prefs());
790        let drawn = render_safe(
791            &mut shell,
792            [400.0, 800.0],
793            margin(0.0, 62.0, 0.0, 34.0),
794            egui::epaint::MarginF32::ZERO,
795        );
796        assert_eq!(drawn.top(), 62.0);
797        let button = shell.footer_rects[0];
798        assert!(button.bottom() <= 800.0 - 34.0, "{button:?}");
799        // The bar sits right above the indicator: its fill runs on under it.
800        assert!(button.bottom() > 800.0 - 34.0 - 12.0, "{button:?}");
801        assert!(drawn.bottom() <= button.top(), "{drawn:?} {button:?}");
802    }
803
804    #[test]
805    fn wide_it_keeps_clear_of_the_notch_and_the_home_indicator() {
806        // An iPhone on its side: the notch on the left, the home indicator below.
807        let mut shell = shell().with_footer(prefs());
808        let drawn = render_safe(
809            &mut shell,
810            [900.0, 400.0],
811            margin(59.0, 0.0, 59.0, 21.0),
812            egui::epaint::MarginF32::ZERO,
813        );
814        let button = shell.footer_rects[0];
815        assert!(button.left() >= 59.0, "{button:?}");
816        assert!(button.bottom() <= 400.0 - 21.0, "{button:?}");
817        assert!(drawn.right() <= 900.0 - 59.0, "{drawn:?}");
818        assert!(drawn.bottom() <= 400.0 - 21.0, "{drawn:?}");
819    }
820
821    #[test]
822    fn a_margin_the_app_already_keeps_is_not_doubled() {
823        let safe = margin(0.0, 62.0, 0.0, 34.0);
824        let mut shell = shell().with_footer(prefs());
825        let drawn = render_safe(&mut shell, [400.0, 800.0], safe, safe);
826        assert_eq!(drawn.top(), 62.0);
827        let button = shell.footer_rects[0];
828        assert!(button.bottom() > 800.0 - 34.0 - 12.0, "{button:?}");
829    }
830
831    #[test]
832    fn with_only_a_footer_it_opens_on_the_footer() {
833        let shell = Shell::new(vec![]).with_footer(prefs());
834        assert_eq!(shell.current(), Some(Screen::Prefs));
835    }
836
837    /// A crowded bar on a `width`-point phone: eight screens, an action, the footer and a
838    /// 12-point foot. Returns the cells', the footer's and the foot's rects.
839    fn crowded(width: f32) -> (Vec<egui::Rect>, egui::Rect, egui::Rect) {
840        let screens = (0..8)
841            .map(|_| Destination::new(Screen::Entries, crate::icons::settings(), "entries"))
842            .collect();
843        let mut shell = Shell::new(screens)
844            .with_actions(vec![Action::new("new", crate::icons::plus(), "new-entry")])
845            .with_footer(prefs());
846        let mut foot = egui::Rect::NOTHING;
847        render([width, 700.0], |ui| {
848            shell.ui_with_foot(
849                ui,
850                |ui| {
851                    foot = ui
852                        .allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::click())
853                        .0;
854                    None
855                },
856                |ui, _| screen(ui),
857            )
858        });
859        (shell.cell_rects, shell.footer_rects[0], foot)
860    }
861
862    #[test]
863    fn a_crowded_bar_keeps_the_footer_and_the_foot_on_the_screen() {
864        for width in [320.0, 360.0, 411.0] {
865            let (cells, footer, foot) = crowded(width);
866            assert_eq!(cells.len(), 9, "{width}");
867            for rect in cells.iter().chain([&footer, &foot]) {
868                assert!(rect.is_positive(), "{width}: {rect:?}");
869                assert!(
870                    rect.left() >= 0.0 && rect.right() <= width,
871                    "{width}: {rect:?}"
872                );
873            }
874            // In order, and none on top of the next.
875            for pair in cells.windows(2) {
876                assert!(pair[0].right() <= pair[1].left(), "{width}: {pair:?}");
877            }
878            assert!(cells[8].right() <= footer.left(), "{width}");
879            assert!(footer.right() <= foot.left(), "{width}");
880            // The footer keeps its full size; the cells give way in width only, and line up.
881            assert_eq!(footer.width(), BUTTON, "{width}");
882            for rect in &cells {
883                assert_eq!(rect.height(), BUTTON, "{width}: {rect:?}");
884                assert_eq!(rect.center().y, footer.center().y, "{width}: {rect:?}");
885            }
886        }
887    }
888
889    #[test]
890    fn a_roomy_bar_keeps_full_size_buttons() {
891        let mut shell = shell().with_footer(prefs());
892        render(SIZES[1], |ui| shell.ui(ui, |ui, _| screen(ui)));
893        assert_eq!(shell.cell_rects.len(), 2);
894        for rect in &shell.cell_rects {
895            assert_eq!(rect.size(), egui::vec2(BUTTON, BUTTON), "{rect:?}");
896        }
897    }
898
899    /// Render `shell` with a status bar at `size`: where the foot was drawn, and whether
900    /// the status was.
901    fn with_status(shell: &mut Shell<Screen>, size: [f32; 2]) -> (egui::Rect, bool) {
902        let (mut foot, mut status) = (egui::Rect::NOTHING, false);
903        render(size, |ui| {
904            shell.ui_with_status(
905                ui,
906                |ui| {
907                    foot = ui.label("dot").rect;
908                    None
909                },
910                |ui| {
911                    status = true;
912                    ui.label("saved");
913                },
914                |ui, _| screen(ui),
915            )
916        });
917        (foot, status)
918    }
919
920    #[test]
921    fn wide_the_status_bar_holds_the_status_and_the_foot() {
922        let mut shell = shell().with_footer(prefs());
923        let (foot, status) = with_status(&mut shell, SIZES[0]);
924        assert!(status);
925        // On the bar along the bottom, at its right end: not at the rail's foot.
926        assert!(foot.bottom() > SIZES[0][1] - 30.0, "{foot:?}");
927        assert!(foot.left() > SIZES[0][0] / 2.0, "{foot:?}");
928        // The rail stops above the bar.
929        assert!(
930            shell.footer_rects[0].bottom() < foot.top(),
931            "{:?}",
932            shell.footer_rects
933        );
934    }
935
936    #[test]
937    fn narrow_there_is_no_status_bar_and_the_foot_stays_on_the_bar() {
938        let mut shell = shell().with_footer(prefs());
939        let (foot, status) = with_status(&mut shell, SIZES[1]);
940        assert!(!status);
941        assert!(foot.is_positive());
942        let bar_top = shell.footer_rects[0].top();
943        assert!(foot.top() >= bar_top - 10.0, "{foot:?} beside {bar_top}");
944    }
945
946    #[test]
947    fn with_nothing_there_is_nothing_to_draw() {
948        let mut shell: Shell<Screen> = Shell::new(vec![]);
949        assert_eq!(shell.current(), None);
950        for size in SIZES {
951            render(size, |ui| shell.ui(ui, |_, s| panic!("{s:?} drawn")));
952        }
953    }
954}