Skip to main content

corundum_framework_gui/shell/
mod.rs

1//! [`Shell`]: an app's window frame — its screens and actions on a rail, and at the rail's
2//! foot the screens that belong there (Settings) and a slot of the app's own (a status dot).
3//!
4//! The screens and the actions are in the app's order, or the person's ([`RailOrder`], set
5//! on the settings page [`RailPage`](crate::settings::RailPage)). When some are left off
6//! the rail — the window has no room for them, or the person hid them — the footer's place
7//! is taken by a "More" button: its menu holds the footer first, then those left off.
8//!
9//! Wide, the rail runs down the left (as Obsidian's does); narrower than
10//! [`COMPACT_WIDTH`], it becomes a bar along the bottom, as on a phone. The width decides,
11//! not the platform, so a desktop window made narrow shows the phone layout.
12
13mod order;
14
15pub use order::RailOrder;
16
17use crate::detail::Part;
18pub(crate) use order::{RailEntry, move_to, published, request};
19
20/// Below this width the layout is a phone's: the rail becomes a bottom bar, and screens
21/// with a list and a page show one at a time.
22pub const COMPACT_WIDTH: f32 = 600.0;
23
24/// The rail's width, wide.
25const RAIL_WIDTH: f32 = 48.0;
26
27/// The space above and below the status bar's row.
28const STATUS_MARGIN: f32 = 3.0;
29
30/// The space at the rail's ends.
31const RAIL_END: f32 = 4.0;
32
33/// One of the app's screens on the rail.
34pub struct Destination<D> {
35    /// What the app matches on to draw it.
36    pub id: D,
37    /// A Lucide icon (see [`icons`](crate::icons)), stroked in white so it takes the text
38    /// colour: the rail shows only this.
39    pub icon: egui::ImageSource<'static>,
40    /// The id of the screen's name in the app's translations, looked up each frame and shown
41    /// when the icon is hovered.
42    pub label: &'static str,
43}
44
45impl<D> Destination<D> {
46    /// A screen named by the message `label`, drawn as `icon`.
47    pub fn new(id: D, icon: egui::ImageSource<'static>, label: &'static str) -> Destination<D> {
48        Destination { id, icon, label }
49    }
50}
51
52/// A button of the app's on the rail that does something — "new-entry" — rather than
53/// open a screen. [`Shell::take_action`] says which one was clicked.
54pub struct Action {
55    /// What [`Shell::take_action`] returns when it is clicked.
56    pub id: &'static str,
57    /// A Lucide icon, as for a [`Destination`].
58    pub icon: egui::ImageSource<'static>,
59    /// The id of what it does in the app's translations, looked up each frame and shown
60    /// when the icon is hovered.
61    pub label: &'static str,
62}
63
64impl Action {
65    /// A button named by the message `label`, drawn as `icon`, reported as `id`.
66    pub fn new(id: &'static str, icon: egui::ImageSource<'static>, label: &'static str) -> Action {
67        Action { id, icon, label }
68    }
69}
70
71/// A screen or an action on the rail, for [`Shell::from_items`]: the two mix in any order,
72/// so the action used most — a new entry — can come first.
73pub enum RailItem<D> {
74    Screen(Destination<D>),
75    Action(Action),
76}
77
78impl<D> From<Destination<D>> for RailItem<D> {
79    fn from(d: Destination<D>) -> RailItem<D> {
80        RailItem::Screen(d)
81    }
82}
83
84impl<D> From<Action> for RailItem<D> {
85    fn from(a: Action) -> RailItem<D> {
86        RailItem::Action(a)
87    }
88}
89
90/// The window frame. Hold one as the app's root; call [`ui`](Self::ui) every frame.
91///
92/// Labels are message ids: each frame the hover text is looked up in the language
93/// installed (see [`i18n`](crate::i18n)), so a language switch shows on the next frame.
94///
95/// The shell keeps clear of the system's bars and notches (egui's
96/// [safe area](egui::InputState::safe_area_insets), which iOS fills): the rail, the bar's
97/// buttons and the screens stay inside it, and the bottom bar's fill runs on under the home
98/// indicator, as a phone's tab bar does. Give it the window's whole root `ui`; the app adds
99/// no margin of its own. If the app does keep `ui` clear of them itself, the shell sees
100/// that and adds nothing, so the margin is never doubled. On Android,
101/// `window::run_android` keeps the app's `ui` clear of the bars and the
102/// keyboard already.
103///
104/// ```no_run
105/// # use corundum_framework_gui::{Action, Destination, Shell, icons};
106/// # fn example(ui: &mut egui::Ui) {
107/// #[derive(Clone, Copy, PartialEq)]
108/// enum Screen { Gallery, Albums, Settings }
109///
110/// let mut shell = Shell::from_items(vec![
111///     Action::new("import", icons::plus(), "import").into(),
112///     // The app's own Lucide SVGs: `egui::include_image!("../assets/icons/images.svg")`.
113///     Destination::new(Screen::Gallery, icons::folder(), "gallery").into(),
114///     Destination::new(Screen::Albums, icons::folder(), "albums").into(),
115/// ])
116/// .with_footer(vec![Destination::new(Screen::Settings, icons::settings(), "settings")]);
117/// shell.ui(ui, |ui, screen| match screen {
118///     Screen::Gallery => { egui::CentralPanel::default().show(ui, |ui| ui.label("…")); }
119///     Screen::Albums => { egui::CentralPanel::default().show(ui, |ui| ui.label("…")); }
120///     Screen::Settings => { egui::CentralPanel::default().show(ui, |ui| ui.label("…")); }
121/// });
122/// if shell.take_action() == Some("import") {
123///     // …
124/// }
125/// # }
126/// ```
127pub struct Shell<D> {
128    destinations: Vec<Destination<D>>,
129    actions: Vec<Action>,
130    footer: Vec<Destination<D>>,
131    /// The screens and the actions in the app's order.
132    defaults: Vec<Item>,
133    /// The screens and the actions in the order the rail shows them, and whether each is
134    /// shown there or left to the menu.
135    slots: Vec<Slot>,
136    /// The slots on the rail or the bar this frame; the rest are in the menu.
137    on_rail: Vec<usize>,
138    /// Whether the footer is in the menu this frame, the More button in its place.
139    footer_in_menu: bool,
140    /// The action clicked and not yet taken.
141    clicked: Option<&'static str>,
142    /// Where each action and footer destination was last drawn, for the tests to click.
143    #[cfg(test)]
144    action_rects: Vec<egui::Rect>,
145    #[cfg(test)]
146    footer_rects: Vec<egui::Rect>,
147    /// The rects of the slots on the rail or the bar, in order, for the tests.
148    #[cfg(test)]
149    cell_rects: Vec<egui::Rect>,
150    /// The More button, and its menu's rows, for the tests.
151    #[cfg(test)]
152    more_rect: egui::Rect,
153    #[cfg(test)]
154    menu_rects: Vec<egui::Rect>,
155    open: Option<D>,
156    /// How big the foot was last frame: the rail or the bar keeps room for it.
157    foot_size: egui::Vec2,
158    /// The side pane, [`with_side_pane`](Self::with_side_pane).
159    pane: Option<crate::detail::DetailPane>,
160    /// Whether the side column was on at the last frame's end, to save it when it turns.
161    side_column: Option<bool>,
162    /// Whether [`show`](Self::show) draws a status bar.
163    status_bar: bool,
164    /// Whether the SVG loader, the theme and the language have been put in place.
165    loaders: bool,
166}
167
168/// An app as the shell draws it, each frame, with [`Shell::show`]: its screens, its side
169/// pane, its foot and status, and what its actions do. The shell calls these in turn, each
170/// with the app's `&mut self`, so a screen and the side pane share the app's state freely.
171///
172/// ```no_run
173/// # use corundum_framework_gui::{Action, Destination, Shell, ShellApp, Side, icons};
174/// #[derive(Clone, Copy, PartialEq)]
175/// enum Screen { Entries, Settings }
176///
177/// struct Journal { draft: Option<String> }
178///
179/// impl ShellApp<Screen> for Journal {
180///     fn screen_ui(&mut self, ui: &mut egui::Ui, screen: Screen, side: &mut Side) {
181///         egui::CentralPanel::default().show(ui, |ui| ui.label("…"));
182///     }
183///
184///     fn side_ui(&mut self, ui: &mut egui::Ui, screen: Screen, side: &mut Side) {
185///         match &mut self.draft {
186///             Some(text) => {
187///                 ui.text_edit_multiline(text);
188///                 if ui.button("Save").clicked() {
189///                     self.draft = None;
190///                     side.close();
191///                 }
192///             }
193///             None => { ui.weak("Nothing selected"); }
194///         }
195///     }
196///
197///     fn action(&mut self, id: &'static str, side: &mut Side) {
198///         if id == "new-entry" {
199///             self.draft = Some(String::new());
200///             side.open();
201///         }
202///     }
203/// }
204///
205/// # fn example(ui: &mut egui::Ui) {
206/// let mut shell = Shell::from_items(vec![
207///     Action::new("new-entry", icons::plus(), "new-entry").into(),
208///     Destination::new(Screen::Entries, icons::file_text(), "entries").into(),
209/// ])
210/// .with_footer(vec![Destination::new(Screen::Settings, icons::settings(), "settings")])
211/// .with_side_pane()
212/// .with_status_bar();
213/// let mut journal = Journal { draft: None };
214/// // Every frame:
215/// shell.show(ui, &mut journal);
216/// # }
217/// ```
218pub trait ShellApp<D> {
219    /// Draw `screen` into what the frame leaves of `ui`, as it would at the root: its own
220    /// panels, then a `CentralPanel`. Through `side` it opens the side pane — on a card, or
221    /// on a phone in the screen's place — when the person picks an item.
222    fn screen_ui(&mut self, ui: &mut egui::Ui, screen: D, side: &mut Side);
223
224    /// Fill the side pane, [`with_side_pane`](Shell::with_side_pane) (no panels of its own):
225    /// the new entry being written, else `screen`'s item, else a word that there is none.
226    /// Through `side` it closes the pane once the entry is saved or dropped.
227    fn side_ui(&mut self, ui: &mut egui::Ui, screen: D, side: &mut Side) {
228        let _ = (ui, screen, side);
229    }
230
231    /// Draw the foot — a status dot, say — at the rail's foot, at the bottom bar's end, or
232    /// on the status bar; a destination it returns is opened.
233    fn foot_ui(&mut self, ui: &mut egui::Ui) -> Option<D> {
234        let _ = ui;
235        None
236    }
237
238    /// Fill the status bar from the left, [`with_status_bar`](Shell::with_status_bar).
239    fn status_ui(&mut self, ui: &mut egui::Ui) {
240        let _ = ui;
241    }
242
243    /// Do what the [`Action`] `id` names: it was clicked on the rail or in the More menu. A
244    /// "new entry" begins the entry and opens it in the side pane through `side`.
245    fn action(&mut self, id: &'static str, side: &mut Side) {
246        let _ = (id, side);
247    }
248}
249
250/// The side pane, as a [`ShellApp`] asks things of it. What is asked is done after the
251/// call. Without a side pane, it asks nothing.
252#[derive(Debug)]
253pub struct Side {
254    pane: bool,
255    open: bool,
256    asked: Option<bool>,
257}
258
259impl Side {
260    /// Show the side pane's content: on the card, or on a phone in the screen's place. While
261    /// the column shows it, there is nothing more to show.
262    pub fn open(&mut self) {
263        self.asked = Some(true);
264        self.open = self.pane;
265    }
266
267    /// Back to the screen alone, on the card or on a phone. The column stays.
268    pub fn close(&mut self) {
269        self.asked = Some(false);
270        self.open = false;
271    }
272
273    /// Whether the side pane is open on the card or in the screen's place.
274    pub fn is_open(&self) -> bool {
275        self.open
276    }
277}
278
279/// The closures of [`Shell::ui`] and its kin, as a [`ShellApp`].
280struct Closures<F, S, C> {
281    foot: Option<F>,
282    status: Option<S>,
283    content: Option<C>,
284}
285
286impl<D, F, S, C> ShellApp<D> for Closures<F, S, C>
287where
288    F: FnOnce(&mut egui::Ui) -> Option<D>,
289    S: FnOnce(&mut egui::Ui),
290    C: FnOnce(&mut egui::Ui, D),
291{
292    fn screen_ui(&mut self, ui: &mut egui::Ui, screen: D, _: &mut Side) {
293        if let Some(content) = self.content.take() {
294            content(ui, screen);
295        }
296    }
297
298    fn foot_ui(&mut self, ui: &mut egui::Ui) -> Option<D> {
299        self.foot.take().and_then(|foot| foot(ui))
300    }
301
302    fn status_ui(&mut self, ui: &mut egui::Ui) {
303        if let Some(status) = self.status.take() {
304            status(ui);
305        }
306    }
307}
308
309/// A screen or an action, by its index in the shell's.
310#[derive(Clone, Copy, PartialEq, Debug)]
311enum Item {
312    Screen(usize),
313    Action(usize),
314}
315
316/// An item in its place on the rail, and whether the rail shows it.
317#[derive(Clone, Copy, PartialEq, Debug)]
318struct Slot {
319    item: Item,
320    shown: bool,
321}
322
323/// A row of the More menu.
324#[derive(Clone, Copy, PartialEq, Debug)]
325enum Row {
326    Footer(usize),
327    Slot(usize),
328}
329
330impl<D: Copy + PartialEq> Shell<D> {
331    /// A frame with `destinations` on the rail, the first one open.
332    pub fn new(destinations: Vec<Destination<D>>) -> Shell<D> {
333        Shell::from_items(destinations.into_iter().map(RailItem::from).collect())
334    }
335
336    /// A frame with `items` — screens and actions — on the rail in this order, the first
337    /// screen open.
338    pub fn from_items(items: Vec<RailItem<D>>) -> Shell<D> {
339        let mut shell = Shell {
340            destinations: Vec::new(),
341            actions: Vec::new(),
342            footer: Vec::new(),
343            defaults: Vec::new(),
344            slots: Vec::new(),
345            on_rail: Vec::new(),
346            footer_in_menu: false,
347            clicked: None,
348            #[cfg(test)]
349            action_rects: Vec::new(),
350            #[cfg(test)]
351            footer_rects: Vec::new(),
352            #[cfg(test)]
353            cell_rects: Vec::new(),
354            #[cfg(test)]
355            more_rect: egui::Rect::NOTHING,
356            #[cfg(test)]
357            menu_rects: Vec::new(),
358            open: None,
359            foot_size: egui::Vec2::ZERO,
360            pane: None,
361            side_column: None,
362            status_bar: false,
363            loaders: false,
364        };
365        for item in items {
366            shell.push(item);
367        }
368        shell.open = shell.destinations.first().map(|d| d.id);
369        shell.slots = shell.default_slots();
370        shell
371    }
372
373    fn push(&mut self, item: RailItem<D>) {
374        match item {
375            RailItem::Screen(d) => {
376                self.defaults.push(Item::Screen(self.destinations.len()));
377                self.destinations.push(d);
378            }
379            RailItem::Action(a) => {
380                self.defaults.push(Item::Action(self.actions.len()));
381                self.actions.push(a);
382            }
383        }
384    }
385
386    /// Put `actions` on the rail after the items already there. To put one first, give the
387    /// items to [`from_items`](Self::from_items) instead.
388    pub fn with_actions(mut self, actions: Vec<Action>) -> Shell<D> {
389        let order = self.rail_order();
390        for action in actions {
391            self.push(action.into());
392        }
393        self.set_rail_order(&order);
394        self
395    }
396
397    /// Put `footer` at the rail's foot (at the bottom bar's end): where an app puts
398    /// Settings. With no other destinations, the first of these is open. When some items
399    /// are left off the rail, the footer moves into the More menu, which takes its place.
400    pub fn with_footer(mut self, footer: Vec<Destination<D>>) -> Shell<D> {
401        self.footer = footer;
402        if self.open.is_none() {
403            self.open = self.footer.first().map(|d| d.id);
404        }
405        self
406    }
407
408    /// Lay the rail out in `order`, as [`set_rail_order`](Self::set_rail_order).
409    pub fn with_rail_order(mut self, order: &RailOrder) -> Shell<D> {
410        self.set_rail_order(order);
411        self
412    }
413
414    /// The order the rail's items are in, and which of them it leaves to the menu.
415    pub fn rail_order(&self) -> RailOrder {
416        RailOrder {
417            order: self.slots.iter().map(|s| self.key(s.item).into()).collect(),
418            hidden: self
419                .slots
420                .iter()
421                .filter(|s| !s.shown)
422                .map(|s| self.key(s.item).into())
423                .collect(),
424        }
425    }
426
427    /// Lay the rail out in `order`. With the `serde` feature and a
428    /// [store](crate::store) attached, the order saved there replaces this on the first
429    /// frame.
430    pub fn set_rail_order(&mut self, order: &RailOrder) {
431        let place = |key: &str| {
432            order
433                .order
434                .iter()
435                .position(|k| k == key)
436                .unwrap_or(usize::MAX)
437        };
438        let mut slots = self.default_slots();
439        // Stable: items of one name, and those the order does not name, keep the app's order.
440        slots.sort_by_key(|s| place(self.key(s.item)));
441        for slot in &mut slots {
442            slot.shown = !order.hidden.iter().any(|k| k == self.key(slot.item));
443        }
444        self.slots = slots;
445    }
446
447    fn default_slots(&self) -> Vec<Slot> {
448        self.defaults
449            .iter()
450            .map(|&item| Slot { item, shown: true })
451            .collect()
452    }
453
454    /// The name `item` goes by in a [`RailOrder`].
455    fn key(&self, item: Item) -> &'static str {
456        match item {
457            Item::Screen(i) => self.destinations[i].label,
458            Item::Action(i) => self.actions[i].id,
459        }
460    }
461
462    fn icon_and_label(&self, item: Item) -> (egui::ImageSource<'static>, &'static str) {
463        match item {
464            Item::Screen(i) => (
465                self.destinations[i].icon.clone(),
466                self.destinations[i].label,
467            ),
468            Item::Action(i) => (self.actions[i].icon.clone(), self.actions[i].label),
469        }
470    }
471
472    /// Take the order the [rail's page](crate::settings::RailPage) set, and keep it in the
473    /// store; then list the items for the page.
474    fn sync_order(&mut self, ctx: &egui::Context) {
475        if let Some(order) = order::take_request(ctx) {
476            self.set_rail_order(&order);
477            #[cfg(feature = "serde")]
478            crate::store::update(ctx, |state| state.rail = Some(self.rail_order()));
479        }
480        let entries = self
481            .slots
482            .iter()
483            .map(|s| {
484                let (icon, label) = self.icon_and_label(s.item);
485                RailEntry {
486                    key: self.key(s.item),
487                    icon,
488                    label,
489                    shown: s.shown,
490                }
491            })
492            .collect();
493        order::publish(ctx, entries);
494    }
495
496    /// The [`Action`] clicked in the last [`ui`](Self::ui), once: call it after `ui`
497    /// every frame and do what it names.
498    pub fn take_action(&mut self) -> Option<&'static str> {
499        self.clicked.take()
500    }
501
502    /// The destination that is open; `None` only when there are none.
503    pub fn current(&self) -> Option<D> {
504        self.open
505    }
506
507    /// Open the destination `id`.
508    pub fn open(&mut self, id: D) {
509        self.open = Some(id);
510    }
511
512    /// Give the app a side pane: a right column beside every screen when wide, which the
513    /// status bar's button hides; a card over the screen when it is hidden or there is no
514    /// room; on a phone, the screen's place. [`ShellApp::side_ui`] fills it, and a screen
515    /// or an action opens it through [`Side`]. Whether the column is on is kept with the
516    /// rail's order, in the [store](crate::store) if one is attached.
517    pub fn with_side_pane(mut self) -> Shell<D> {
518        self.pane = Some(crate::detail::DetailPane::new("corundum_shell_side"));
519        self
520    }
521
522    /// Draw a status bar along the window's bottom when wide: [`ShellApp::status_ui`] fills
523    /// it from the left — a log's last line, the services' state, the track playing; its
524    /// right end holds [`ShellApp::foot_ui`] (there rather than at the rail's foot), then
525    /// the side pane's toggle. Narrow, as on a phone, there is no status bar: the foot stays
526    /// on the bottom bar.
527    pub fn with_status_bar(mut self) -> Shell<D> {
528        self.status_bar = true;
529        self
530    }
531
532    /// Render `app` into the window's root `ui`: the rail, the open screen
533    /// ([`ShellApp::screen_ui`]) and, [`with_side_pane`](Self::with_side_pane), the side
534    /// pane beside it; then hand an action clicked to [`ShellApp::action`]. The first frame
535    /// installs the default [`theme`](crate::theme) and [`language`](crate::i18n) if the app
536    /// has not installed them, and takes the rail's order and the side column from the
537    /// [store](crate::store) if one is attached.
538    pub fn show(&mut self, ui: &mut egui::Ui, app: &mut impl ShellApp<D>) {
539        self.frame_ui(ui, app, self.status_bar);
540        if let Some(id) = self.clicked.take() {
541            let mut side = self.side();
542            app.action(id, &mut side);
543            self.apply_side(side);
544            ui.ctx().request_repaint();
545        }
546    }
547
548    /// Render into the window's root `ui`. `content` draws the open destination into what
549    /// the frame leaves of `ui`, as it would at the root: its own panels, then a
550    /// `CentralPanel`. An action clicked waits for [`take_action`](Self::take_action). The
551    /// first frame installs the default [`theme`](crate::theme) and
552    /// [`language`](crate::i18n) if the app has not installed them, and takes the rail's
553    /// order from the [store](crate::store) if one is attached. An app with a side pane or
554    /// state shared by its screens implements [`ShellApp`] and calls [`show`](Self::show).
555    pub fn ui(&mut self, ui: &mut egui::Ui, content: impl FnOnce(&mut egui::Ui, D)) {
556        self.ui_with_foot(ui, |_| None, content);
557    }
558
559    /// As [`ui`](Self::ui), with `foot` drawn beside the footer — a status dot, say. A
560    /// destination `foot` returns is opened, and drawn this frame.
561    pub fn ui_with_foot(
562        &mut self,
563        ui: &mut egui::Ui,
564        foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
565        content: impl FnOnce(&mut egui::Ui, D),
566    ) {
567        let mut app = Closures {
568            foot: Some(foot),
569            status: None::<fn(&mut egui::Ui)>,
570            content: Some(content),
571        };
572        self.frame_ui(ui, &mut app, false);
573    }
574
575    /// As [`ui_with_foot`](Self::ui_with_foot), with a status bar along the window's
576    /// bottom when wide, as [`with_status_bar`](Self::with_status_bar): `status` fills it
577    /// from the left, and its right end holds `foot`. Narrow, as on a phone, there is no
578    /// status bar: `foot` stays on the bottom bar, and `status` is not called.
579    pub fn ui_with_status(
580        &mut self,
581        ui: &mut egui::Ui,
582        foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
583        status: impl FnOnce(&mut egui::Ui),
584        content: impl FnOnce(&mut egui::Ui, D),
585    ) {
586        let mut app = Closures {
587            foot: Some(foot),
588            status: Some(status),
589            content: Some(content),
590        };
591        self.frame_ui(ui, &mut app, true);
592    }
593
594    /// The side pane's state, for the app to ask things of.
595    fn side(&self) -> Side {
596        Side {
597            pane: self.pane.is_some(),
598            open: self.pane.as_ref().is_some_and(|p| p.is_open()),
599            asked: None,
600        }
601    }
602
603    fn apply_side(&mut self, side: Side) {
604        if let (Some(pane), Some(open)) = (&mut self.pane, side.asked) {
605            if open {
606                pane.open();
607            } else {
608                pane.close();
609            }
610        }
611    }
612
613    /// The open screen, in the side pane when there is one.
614    fn content_ui(&mut self, ui: &mut egui::Ui, app: &mut impl ShellApp<D>, id: D) {
615        let mut side = self.side();
616        match &mut self.pane {
617            Some(pane) => pane.show(ui, |ui, part| match part {
618                Part::Main => app.screen_ui(ui, id, &mut side),
619                Part::Detail => app.side_ui(ui, id, &mut side),
620            }),
621            None => app.screen_ui(ui, id, &mut side),
622        }
623        self.apply_side(side);
624    }
625
626    /// Take the side column's state from the store on the first frame, and keep it there
627    /// when the person turns it on or off.
628    fn sync_side(&mut self, ctx: &egui::Context, first: bool) {
629        let Some(pane) = &mut self.pane else {
630            return;
631        };
632        #[cfg(feature = "serde")]
633        {
634            if first && let Some(saved) = crate::store::state(ctx).side {
635                pane.set_column(saved.column);
636            }
637            let column = pane.column();
638            if !first && self.side_column != Some(column) {
639                crate::store::update(ctx, |state| {
640                    state.side = Some(crate::store::SidePane { column });
641                });
642            }
643        }
644        #[cfg(not(feature = "serde"))]
645        let _ = (ctx, first);
646        self.side_column = Some(pane.column());
647    }
648
649    fn frame_ui(&mut self, ui: &mut egui::Ui, app: &mut impl ShellApp<D>, status: bool) {
650        let first = !std::mem::replace(&mut self.loaders, true);
651        if first {
652            crate::icons::install_loaders(ui.ctx());
653            // The app may have installed its saved theme already; only fill the gap.
654            if !crate::theme::is_installed(ui.ctx()) {
655                crate::theme::install(ui.ctx(), &crate::theme::ThemeSettings::default());
656            }
657            // Likewise the language.
658            if !crate::i18n::is_installed(ui.ctx()) {
659                crate::i18n::install(ui.ctx(), &crate::i18n::LanguageSettings::default());
660            }
661            #[cfg(feature = "serde")]
662            if let Some(order) = crate::store::state(ui.ctx()).rail {
663                self.set_rail_order(&order);
664            }
665            self.sync_side(ui.ctx(), true);
666        }
667        self.sync_order(ui.ctx());
668        let safe = unsafe_margin(ui);
669        let full = ui.max_rect();
670        let compact = full.width() - safe.sum().x < COMPACT_WIDTH;
671        // Narrow, the bar's fill runs on under the home indicator, its buttons above it;
672        // wide, the rail stops above it too.
673        let inner = if compact {
674            full - egui::epaint::MarginF32 {
675                bottom: 0.0,
676                ..safe
677            }
678        } else {
679            full - safe
680        };
681        // The strips under the status bar and the notch take the panels' colour.
682        if safe != egui::epaint::MarginF32::ZERO {
683            ui.painter().rect_filled(full, 0.0, ui.visuals().panel_fill);
684        }
685        if !compact && status {
686            // The bar under the rail and the screen, which keep what is above it.
687            let height = ui.spacing().interact_size.y + 2.0 * STATUS_MARGIN;
688            let above = inner.with_max_y(inner.bottom() - height);
689            let strip = inner.with_min_y(above.bottom());
690            ui.scope_builder(egui::UiBuilder::new().max_rect(above), |ui| {
691                let opened = egui::Panel::left("shell_rail")
692                    .resizable(false)
693                    .exact_size(RAIL_WIDTH)
694                    .show(ui, |ui| self.rail_ui(ui, |_| None))
695                    .inner;
696                if let Some(id) = opened {
697                    self.open = Some(id);
698                }
699                if let Some(id) = self.open {
700                    self.content_ui(ui, app, id);
701                }
702            });
703            // Drawn after the screen, so the toggle knows of the detail pane drawn there.
704            if let Some(id) = status_bar_ui(ui, strip, app) {
705                self.open = Some(id);
706                ui.ctx().request_repaint();
707            }
708        } else {
709            ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| {
710                let foot = |ui: &mut egui::Ui| app.foot_ui(ui);
711                let opened = if compact {
712                    let mut frame = egui::Frame::side_top_panel(ui.style());
713                    frame.inner_margin.bottom = frame
714                        .inner_margin
715                        .bottom
716                        .saturating_add(safe.bottom.round().min(127.0) as i8);
717                    egui::Panel::bottom("shell_bar")
718                        .frame(frame)
719                        .show(ui, |ui| self.bar_ui(ui, foot))
720                        .inner
721                } else {
722                    egui::Panel::left("shell_rail")
723                        .resizable(false)
724                        .exact_size(RAIL_WIDTH)
725                        .show(ui, |ui| self.rail_ui(ui, foot))
726                        .inner
727                };
728                if let Some(id) = opened {
729                    self.open = Some(id);
730                }
731                if let Some(id) = self.open {
732                    self.content_ui(ui, app, id);
733                }
734            });
735        }
736        self.sync_side(ui.ctx(), false);
737    }
738
739    /// How many of the `shown` items the rail or bar holds in `room` (its length less the
740    /// foot's), at `step` per button, and whether the More button takes the footer's place.
741    /// When every shown item fits beside the footer and none is hidden, there is no menu;
742    /// else the More button stands where the footer was and the items share the rest.
743    fn fit(&self, shown: usize, room: f32, step: f32) -> (usize, bool) {
744        let hidden = shown < self.slots.len();
745        let footer = self.footer.len() as f32 * step;
746        if !hidden && shown as f32 * step + footer <= room {
747            return (shown, false);
748        }
749        let held = ((room - step) / step).floor().max(0.0) as usize;
750        (held.min(shown), true)
751    }
752
753    /// Keep `size` as the foot's, laying the frame out again when it changed.
754    fn foot_measured(&mut self, ctx: &egui::Context, size: egui::Vec2) {
755        if (size - self.foot_size).length() > 0.5 {
756            self.foot_size = size;
757            ctx.request_discard("the shell's foot changed size");
758        }
759    }
760
761    /// The wide layout: the footer (or the More button) and the foot from the bottom, then
762    /// the screens and the actions from the top, as many as fit above them.
763    fn rail_ui(
764        &mut self,
765        ui: &mut egui::Ui,
766        foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
767    ) -> Option<D> {
768        let full = ui.available_rect_before_wrap();
769        let step = BUTTON + ui.spacing().item_spacing.y;
770        let shown = self.shown();
771        let room = full.height() - 2.0 * RAIL_END - self.foot_size.y;
772        let (held, more) = self.fit(shown.len(), room, step);
773        self.lay_out(&shown[..held], more);
774        let bottom = ui.scope_builder(
775            egui::UiBuilder::new()
776                .max_rect(full)
777                .layout(egui::Layout::bottom_up(egui::Align::Center)),
778            |ui| {
779                ui.add_space(RAIL_END);
780                self.end_ui(ui, more, false);
781                let foot = ui.scope(foot);
782                (foot.inner, foot.response.rect.size())
783            },
784        );
785        let (opened, foot_size) = bottom.inner;
786        self.foot_measured(ui.ctx(), foot_size);
787        let top = full.with_max_y(bottom.response.rect.top());
788        ui.scope_builder(
789            egui::UiBuilder::new()
790                .max_rect(top)
791                .layout(egui::Layout::top_down(egui::Align::Center)),
792            |ui| {
793                ui.add_space(RAIL_END);
794                for &s in &shown[..held] {
795                    self.slot_ui(ui, s, BUTTON);
796                }
797            },
798        );
799        opened
800    }
801
802    /// The phone layout: a cell per screen or action, as many as fit, then the footer (or
803    /// the More button) and the foot at the bar's end.
804    ///
805    /// The end keeps the width it needs; the items share what is left, a full-size button
806    /// each, and those that do not fit are in the More menu. So the foot never leaves the
807    /// screen, however many items there are.
808    fn bar_ui(
809        &mut self,
810        ui: &mut egui::Ui,
811        foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
812    ) -> Option<D> {
813        let rect = ui.available_rect_before_wrap();
814        let gap = ui.spacing().item_spacing.x;
815        let step = BUTTON + gap;
816        let shown = self.shown();
817        let room = rect.width() - self.foot_size.x - gap;
818        let (held, more) = self.fit(shown.len(), room, step);
819        self.lay_out(&shown[..held], more);
820        // The end first, from the right: the cells get what it leaves.
821        let end = ui.scope_builder(
822            egui::UiBuilder::new()
823                .max_rect(rect)
824                .layout(egui::Layout::right_to_left(egui::Align::Center)),
825            |ui| {
826                let foot = ui.scope(foot);
827                self.end_ui(ui, more, true);
828                (foot.inner, foot.response.rect.size())
829            },
830        );
831        let (opened, foot_size) = end.inner;
832        self.foot_measured(ui.ctx(), foot_size);
833        let cells = held;
834        if cells > 0 {
835            let row = rect.with_max_x(end.response.rect.left() - gap);
836            ui.scope_builder(egui::UiBuilder::new().max_rect(row), |ui| {
837                ui.columns(cells, |columns| {
838                    for (i, column) in columns.iter_mut().enumerate() {
839                        column.vertical_centered(|ui| {
840                            let side = ui.available_width().min(BUTTON);
841                            self.slot_ui(ui, shown[i], side);
842                        });
843                    }
844                });
845            });
846        }
847        opened
848    }
849
850    /// Set out this frame's rail: the slots on it, and whether the footer is in the menu.
851    fn lay_out(&mut self, on_rail: &[usize], more: bool) {
852        self.on_rail = on_rail.to_vec();
853        self.footer_in_menu = more;
854        #[cfg(test)]
855        {
856            self.cell_rects.clear();
857            self.footer_rects.clear();
858            self.more_rect = egui::Rect::NOTHING;
859        }
860    }
861
862    /// The footer, or the More button in its place; `backwards` for a layout running right
863    /// to left, so the footer still reads in its order.
864    fn end_ui(&mut self, ui: &mut egui::Ui, more: bool, backwards: bool) {
865        if more {
866            self.more_ui(ui);
867            return;
868        }
869        let mut order: Vec<usize> = (0..self.footer.len()).collect();
870        if backwards {
871            order.reverse();
872        }
873        for i in order {
874            self.footer_ui(ui, i);
875        }
876    }
877
878    /// The slots the person shows on the rail, in order: the first of them that fit are on it.
879    fn shown(&self) -> Vec<usize> {
880        (0..self.slots.len())
881            .filter(|&s| self.slots[s].shown)
882            .collect()
883    }
884
885    /// Slot `s`'s button on the rail or the bar.
886    fn slot_ui(&mut self, ui: &mut egui::Ui, s: usize, side: f32) {
887        let response = match self.slots[s].item {
888            Item::Screen(i) => self.destination_ui(ui, i, side),
889            Item::Action(i) => self.action_ui(ui, i, side),
890        };
891        #[cfg(test)]
892        self.cell_rects.push(response.rect);
893        #[cfg(not(test))]
894        let _ = response;
895    }
896
897    /// The More menu's rows: the footer first, then the items left off the rail, in order.
898    fn menu_rows(&self) -> Vec<Row> {
899        let footer = (0..self.footer.len()).map(Row::Footer);
900        let off = (0..self.slots.len())
901            .filter(|s| !self.on_rail.contains(s))
902            .map(Row::Slot);
903        footer.chain(off).collect()
904    }
905
906    /// The More button, in the footer's place, and its menu.
907    fn more_ui(&mut self, ui: &mut egui::Ui) {
908        let rows = self.menu_rows();
909        // Marked, as a screen's button is, when the screen open is in the menu.
910        let open_inside = rows
911            .iter()
912            .any(|&row| self.row_opens(row) == self.open && self.open.is_some());
913        let response = icon_button(ui, open_inside, crate::icons::ellipsis(), BUTTON)
914            .on_hover_text(crate::i18n::tr(ui.ctx(), "corundum-shell-more"));
915        #[cfg(test)]
916        {
917            self.more_rect = response.rect;
918        }
919        egui::Popup::menu(&response).show(|ui| self.menu_ui(ui, &rows));
920    }
921
922    /// The screen `row` opens, if it is one.
923    fn row_opens(&self, row: Row) -> Option<D> {
924        match row {
925            Row::Footer(i) => Some(self.footer[i].id),
926            Row::Slot(s) => match self.slots[s].item {
927                Item::Screen(i) => Some(self.destinations[i].id),
928                Item::Action(_) => None,
929            },
930        }
931    }
932
933    fn menu_ui(&mut self, ui: &mut egui::Ui, rows: &[Row]) {
934        #[cfg(test)]
935        self.menu_rects.clear();
936        for (n, &row) in rows.iter().enumerate() {
937            if n > 0 && matches!(rows[n - 1], Row::Footer(_)) && matches!(row, Row::Slot(_)) {
938                ui.separator();
939            }
940            let (icon, label) = match row {
941                Row::Footer(i) => (self.footer[i].icon.clone(), self.footer[i].label),
942                Row::Slot(s) => self.icon_and_label(self.slots[s].item),
943            };
944            let open = self.open.is_some() && self.row_opens(row) == self.open;
945            let image = crate::icons::image(ui, icon, 16.0);
946            let response = ui.add(
947                egui::Button::image_and_text(image, crate::i18n::tr(ui.ctx(), label))
948                    .selected(open),
949            );
950            #[cfg(test)]
951            self.menu_rects.push(response.rect);
952            if response.clicked() {
953                match row {
954                    Row::Slot(s) => match self.slots[s].item {
955                        Item::Screen(i) => self.open = Some(self.destinations[i].id),
956                        Item::Action(i) => self.clicked = Some(self.actions[i].id),
957                    },
958                    Row::Footer(i) => self.open = Some(self.footer[i].id),
959                }
960            }
961        }
962    }
963
964    fn destination_ui(&mut self, ui: &mut egui::Ui, i: usize, side: f32) -> egui::Response {
965        let d = &self.destinations[i];
966        let open = self.open == Some(d.id);
967        let response = icon_button(ui, open, d.icon.clone(), side)
968            .on_hover_text(crate::i18n::tr(ui.ctx(), d.label));
969        if response.clicked() {
970            self.open = Some(d.id);
971        }
972        response
973    }
974
975    /// What the hover text of destination `i` says now.
976    #[cfg(test)]
977    fn label_text(&self, ctx: &egui::Context, i: usize) -> String {
978        crate::i18n::tr(ctx, self.destinations[i].label)
979    }
980
981    fn footer_ui(&mut self, ui: &mut egui::Ui, i: usize) {
982        let d = &self.footer[i];
983        let open = self.open == Some(d.id);
984        let response = icon_button(ui, open, d.icon.clone(), BUTTON)
985            .on_hover_text(crate::i18n::tr(ui.ctx(), d.label));
986        #[cfg(test)]
987        {
988            self.footer_rects
989                .resize(self.footer.len(), egui::Rect::NOTHING);
990            self.footer_rects[i] = response.rect;
991        }
992        if response.clicked() {
993            self.open = Some(self.footer[i].id);
994        }
995    }
996
997    fn action_ui(&mut self, ui: &mut egui::Ui, i: usize, side: f32) -> egui::Response {
998        let a = &self.actions[i];
999        let response = icon_button(ui, false, a.icon.clone(), side)
1000            .on_hover_text(crate::i18n::tr(ui.ctx(), a.label));
1001        #[cfg(test)]
1002        {
1003            self.action_rects
1004                .resize(self.actions.len(), egui::Rect::NOTHING);
1005            self.action_rects[i] = response.rect;
1006        }
1007        if response.clicked() {
1008            self.clicked = Some(a.id);
1009        }
1010        response
1011    }
1012}
1013
1014/// The status bar along the bottom of `strip`: the app's status from the left, its foot and
1015/// the side pane's toggle at the right end. The destination the foot returns, if any.
1016fn status_bar_ui<D>(ui: &mut egui::Ui, strip: egui::Rect, app: &mut impl ShellApp<D>) -> Option<D> {
1017    let visuals = ui.visuals();
1018    ui.painter().rect_filled(strip, 0.0, visuals.panel_fill);
1019    ui.painter().hline(
1020        strip.x_range(),
1021        strip.top(),
1022        visuals.widgets.noninteractive.bg_stroke,
1023    );
1024    let row = strip.shrink2(egui::vec2(8.0, STATUS_MARGIN));
1025    ui.scope_builder(
1026        egui::UiBuilder::new()
1027            .max_rect(row)
1028            .layout(egui::Layout::right_to_left(egui::Align::Center)),
1029        |ui| {
1030            ui.set_clip_rect(row);
1031            crate::detail::toggle_ui(ui);
1032            let opened = app.foot_ui(ui);
1033            // What is left, from the left: the app's.
1034            ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
1035                app.status_ui(ui)
1036            });
1037            opened
1038        },
1039    )
1040    .inner
1041}
1042
1043/// How far the system's bars and notches (egui's safe area insets) reach into `ui`, side by
1044/// side: none where `ui` already keeps clear of them.
1045fn unsafe_margin(ui: &egui::Ui) -> egui::epaint::MarginF32 {
1046    let safe = ui.ctx().content_rect();
1047    let rect = ui.max_rect();
1048    egui::epaint::MarginF32 {
1049        left: (safe.left() - rect.left()).max(0.0),
1050        right: (rect.right() - safe.right()).max(0.0),
1051        top: (safe.top() - rect.top()).max(0.0),
1052        bottom: (rect.bottom() - safe.bottom()).max(0.0),
1053    }
1054}
1055
1056/// A rail or bar button's side, where there is room for it.
1057const BUTTON: f32 = 36.0;
1058
1059/// A button with `icon` in it, `side` points wide ([`BUTTON`], or less on a crowded bar) and
1060/// [`BUTTON`] tall, so a narrowed one still lines up with the rest and is as easy to tap.
1061fn icon_button(
1062    ui: &mut egui::Ui,
1063    open: bool,
1064    icon: egui::ImageSource<'static>,
1065    side: f32,
1066) -> egui::Response {
1067    // A narrow button takes a smaller icon first, then less padding beside it, so it stays
1068    // `side` wide.
1069    let pad = ui.spacing().button_padding.x;
1070    let icon_size = (side - 2.0 * pad).clamp(12.0, 20.0);
1071    ui.scope(|ui| {
1072        let padding = &mut ui.spacing_mut().button_padding.x;
1073        *padding = padding.min(((side - icon_size) / 2.0).max(0.0));
1074        // The open destination's icon takes the accent, as egui's selected text does.
1075        ui.add(
1076            crate::icons::button(icon, icon_size)
1077                .selected(open)
1078                .frame_when_inactive(open)
1079                .min_size(egui::vec2(side, BUTTON)),
1080        )
1081    })
1082    .inner
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087    use super::*;
1088    use crate::test_support::render;
1089
1090    #[derive(Clone, Copy, PartialEq, Debug)]
1091    enum Screen {
1092        Entries,
1093        Summary,
1094        Prefs,
1095    }
1096
1097    const SIZES: [[f32; 2]; 2] = [[900.0, 600.0], [400.0, 700.0]];
1098
1099    fn shell() -> Shell<Screen> {
1100        Shell::new(vec![
1101            Destination::new(Screen::Entries, crate::icons::settings(), "entries"),
1102            Destination::new(Screen::Summary, crate::icons::arrow_left(), "summary"),
1103        ])
1104    }
1105
1106    fn prefs() -> Vec<Destination<Screen>> {
1107        vec![Destination::new(
1108            Screen::Prefs,
1109            crate::icons::settings(),
1110            "preferences",
1111        )]
1112    }
1113
1114    fn screen(ui: &mut egui::Ui) {
1115        egui::CentralPanel::default().show(ui, |ui| ui.label("screen"));
1116    }
1117
1118    /// Lay `shell` out over two frames of a `size` screen, then click where `at` says.
1119    fn click(shell: &mut Shell<Screen>, size: [f32; 2], at: impl Fn(&Shell<Screen>) -> egui::Pos2) {
1120        let ctx = egui::Context::default();
1121        let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(size[0], size[1]));
1122        let frame = |events: Vec<egui::Event>, shell: &mut Shell<Screen>| {
1123            let input = egui::RawInput {
1124                screen_rect: Some(rect),
1125                events,
1126                ..Default::default()
1127            };
1128            let mut output = ctx.run_ui(input, |ui| shell.ui(ui, |ui, _| screen(ui)));
1129            output.textures_delta.clear();
1130        };
1131        frame(vec![], shell);
1132        frame(vec![], shell);
1133        let pos = at(shell);
1134        let button = |pressed| egui::Event::PointerButton {
1135            pos,
1136            button: egui::PointerButton::Primary,
1137            pressed,
1138            modifiers: egui::Modifiers::NONE,
1139        };
1140        frame(vec![egui::Event::PointerMoved(pos)], shell);
1141        frame(vec![button(true)], shell);
1142        frame(vec![button(false)], shell);
1143    }
1144
1145    #[test]
1146    fn it_opens_on_the_first_screen_and_draws_it() {
1147        let mut shell = shell();
1148        assert_eq!(shell.current(), Some(Screen::Entries));
1149        for size in SIZES {
1150            let mut drawn = Vec::new();
1151            render(size, |ui| {
1152                shell.ui(ui, |ui, s| {
1153                    drawn.push(s);
1154                    screen(ui);
1155                })
1156            });
1157            assert_eq!(drawn, [Screen::Entries, Screen::Entries]);
1158        }
1159    }
1160
1161    #[test]
1162    fn actions_are_drawn_in_both_layouts_and_report_nothing_unclicked() {
1163        let mut shell =
1164            shell().with_actions(vec![Action::new("new", crate::icons::plus(), "new-entry")]);
1165        for size in SIZES {
1166            shell.action_rects.clear();
1167            render(size, |ui| shell.ui(ui, |ui, _| screen(ui)));
1168            assert!(shell.action_rects[0].is_positive(), "{size:?}");
1169            assert_eq!(shell.take_action(), None);
1170        }
1171        assert_eq!(shell.current(), Some(Screen::Entries));
1172    }
1173
1174    #[test]
1175    fn a_clicked_action_is_taken_once_and_opens_nothing() {
1176        let mut shell =
1177            shell().with_actions(vec![Action::new("new", crate::icons::plus(), "new-entry")]);
1178        click(&mut shell, SIZES[0], |s| s.action_rects[0].center());
1179        assert_eq!(shell.take_action(), Some("new"));
1180        assert_eq!(shell.take_action(), None);
1181        assert_eq!(shell.current(), Some(Screen::Entries));
1182    }
1183
1184    #[test]
1185    fn footer_and_foot_are_in_both_layouts() {
1186        let mut shell = shell().with_footer(prefs());
1187        for size in SIZES {
1188            shell.footer_rects.clear();
1189            let mut foot = egui::Rect::NOTHING;
1190            render(size, |ui| {
1191                shell.ui_with_foot(
1192                    ui,
1193                    |ui| {
1194                        foot = ui
1195                            .allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::click())
1196                            .0;
1197                        None
1198                    },
1199                    |ui, _| screen(ui),
1200                )
1201            });
1202            assert!(shell.footer_rects[0].is_positive(), "{size:?}");
1203            assert!(foot.is_positive(), "{size:?}");
1204        }
1205    }
1206
1207    #[test]
1208    fn clicking_a_footer_destination_opens_it() {
1209        for size in SIZES {
1210            let mut shell = shell().with_footer(prefs());
1211            click(&mut shell, size, |s| s.footer_rects[0].center());
1212            assert_eq!(shell.current(), Some(Screen::Prefs), "{size:?}");
1213        }
1214        let mut shell = shell().with_footer(prefs());
1215        shell.open(Screen::Prefs);
1216        let mut drawn = Vec::new();
1217        render([900.0, 600.0], |ui| {
1218            shell.ui(ui, |ui, s| {
1219                drawn.push(s);
1220                screen(ui);
1221            })
1222        });
1223        assert_eq!(drawn, [Screen::Prefs, Screen::Prefs]);
1224    }
1225
1226    #[test]
1227    fn a_destination_the_foot_returns_is_opened() {
1228        let mut shell = shell();
1229        let mut drawn = Vec::new();
1230        render([900.0, 600.0], |ui| {
1231            shell.ui_with_foot(
1232                ui,
1233                |_| Some(Screen::Summary),
1234                |ui, s| {
1235                    drawn.push(s);
1236                    screen(ui);
1237                },
1238            )
1239        });
1240        assert_eq!(shell.current(), Some(Screen::Summary));
1241        assert_eq!(drawn, [Screen::Summary, Screen::Summary]);
1242    }
1243
1244    #[test]
1245    fn the_shell_installs_the_default_theme() {
1246        let ctx = egui::Context::default();
1247        let mut shell = shell();
1248        crate::test_support::render_in(&ctx, [900.0, 600.0], |ui| shell.ui(ui, |ui, _| screen(ui)));
1249        assert!(crate::theme::is_installed(&ctx));
1250    }
1251
1252    #[test]
1253    fn the_shell_keeps_an_installed_theme() {
1254        use crate::theme::{Contrast, ThemeSettings};
1255        let ctx = egui::Context::default();
1256        let settings = ThemeSettings {
1257            preference: egui::ThemePreference::Dark,
1258            contrast: Contrast::High,
1259            ..Default::default()
1260        };
1261        crate::theme::install(&ctx, &settings);
1262        let mut shell = shell();
1263        crate::test_support::render_in(&ctx, [900.0, 600.0], |ui| shell.ui(ui, |ui, _| screen(ui)));
1264        let high = crate::theme::Theme::new(
1265            true,
1266            Contrast::High,
1267            crate::theme::Tint::Swatch(crate::theme::Swatch::Blue),
1268        );
1269        assert_eq!(*crate::theme::current(&ctx), high);
1270    }
1271
1272    const T: crate::i18n::Resources = crate::i18n::Resources {
1273        name: "shell-test",
1274        files: &[
1275            ("en", "t-entries = Entries\n"),
1276            ("ja", "t-entries = 項目\n"),
1277        ],
1278    };
1279
1280    #[test]
1281    fn the_shell_installs_the_default_language() {
1282        let ctx = egui::Context::default();
1283        let mut shell = shell();
1284        crate::test_support::render_in(&ctx, [900.0, 600.0], |ui| shell.ui(ui, |ui, _| screen(ui)));
1285        assert!(crate::i18n::is_installed(&ctx));
1286    }
1287
1288    #[test]
1289    fn the_shell_keeps_an_installed_language() {
1290        use crate::i18n::{Language, LanguageSettings};
1291        let ctx = egui::Context::default();
1292        let ja = LanguageSettings {
1293            language: Language::Explicit("ja".into()),
1294        };
1295        crate::i18n::install(&ctx, &ja);
1296        let mut shell = shell();
1297        crate::test_support::render_in(&ctx, [900.0, 600.0], |ui| shell.ui(ui, |ui, _| screen(ui)));
1298        assert_eq!(crate::i18n::current(&ctx).to_string(), "ja");
1299    }
1300
1301    #[test]
1302    fn hover_text_follows_the_language() {
1303        use crate::i18n::{Language, LanguageSettings};
1304        let ctx = egui::Context::default();
1305        crate::i18n::register(&ctx, T);
1306        let shell = Shell::new(vec![Destination::new(
1307            Screen::Entries,
1308            crate::icons::settings(),
1309            "t-entries",
1310        )]);
1311        let set = |tag: &str| {
1312            crate::i18n::install(
1313                &ctx,
1314                &LanguageSettings {
1315                    language: Language::Explicit(tag.into()),
1316                },
1317            )
1318        };
1319        set("en");
1320        assert_eq!(shell.label_text(&ctx, 0), "Entries");
1321        set("ja");
1322        assert_eq!(shell.label_text(&ctx, 0), "項目");
1323    }
1324
1325    /// Lay `shell` out over two frames of a `size` screen whose system bars take `safe`,
1326    /// with `ui` clear of them by `own` already; returns the rect the open screen got.
1327    fn render_safe(
1328        shell: &mut Shell<Screen>,
1329        size: [f32; 2],
1330        safe: egui::epaint::MarginF32,
1331        own: egui::epaint::MarginF32,
1332    ) -> egui::Rect {
1333        let ctx = egui::Context::default();
1334        let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(size[0], size[1]));
1335        let mut drawn = egui::Rect::NOTHING;
1336        for _ in 0..2 {
1337            let input = egui::RawInput {
1338                screen_rect: Some(rect),
1339                safe_area_insets: Some(egui::SafeAreaInsets(safe)),
1340                ..Default::default()
1341            };
1342            let mut output = ctx.run_ui(input, |ui| {
1343                let inner = ui.max_rect() - own;
1344                ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| {
1345                    shell.ui(ui, |ui, _| {
1346                        drawn = ui.available_rect_before_wrap();
1347                        screen(ui);
1348                    })
1349                });
1350            });
1351            output.textures_delta.clear();
1352        }
1353        drawn
1354    }
1355
1356    fn margin(left: f32, top: f32, right: f32, bottom: f32) -> egui::epaint::MarginF32 {
1357        egui::epaint::MarginF32 {
1358            left,
1359            right,
1360            top,
1361            bottom,
1362        }
1363    }
1364
1365    #[test]
1366    fn narrow_it_keeps_clear_of_the_status_bar_and_the_home_indicator() {
1367        // An iPhone upright: the status bar and the dynamic island, the home indicator.
1368        let mut shell = shell().with_footer(prefs());
1369        let drawn = render_safe(
1370            &mut shell,
1371            [400.0, 800.0],
1372            margin(0.0, 62.0, 0.0, 34.0),
1373            egui::epaint::MarginF32::ZERO,
1374        );
1375        assert_eq!(drawn.top(), 62.0);
1376        let button = shell.footer_rects[0];
1377        assert!(button.bottom() <= 800.0 - 34.0, "{button:?}");
1378        // The bar sits right above the indicator: its fill runs on under it.
1379        assert!(button.bottom() > 800.0 - 34.0 - 12.0, "{button:?}");
1380        assert!(drawn.bottom() <= button.top(), "{drawn:?} {button:?}");
1381    }
1382
1383    #[test]
1384    fn wide_it_keeps_clear_of_the_notch_and_the_home_indicator() {
1385        // An iPhone on its side: the notch on the left, the home indicator below.
1386        let mut shell = shell().with_footer(prefs());
1387        let drawn = render_safe(
1388            &mut shell,
1389            [900.0, 400.0],
1390            margin(59.0, 0.0, 59.0, 21.0),
1391            egui::epaint::MarginF32::ZERO,
1392        );
1393        let button = shell.footer_rects[0];
1394        assert!(button.left() >= 59.0, "{button:?}");
1395        assert!(button.bottom() <= 400.0 - 21.0, "{button:?}");
1396        assert!(drawn.right() <= 900.0 - 59.0, "{drawn:?}");
1397        assert!(drawn.bottom() <= 400.0 - 21.0, "{drawn:?}");
1398    }
1399
1400    #[test]
1401    fn a_margin_the_app_already_keeps_is_not_doubled() {
1402        let safe = margin(0.0, 62.0, 0.0, 34.0);
1403        let mut shell = shell().with_footer(prefs());
1404        let drawn = render_safe(&mut shell, [400.0, 800.0], safe, safe);
1405        assert_eq!(drawn.top(), 62.0);
1406        let button = shell.footer_rects[0];
1407        assert!(button.bottom() > 800.0 - 34.0 - 12.0, "{button:?}");
1408    }
1409
1410    #[test]
1411    fn with_only_a_footer_it_opens_on_the_footer() {
1412        let shell = Shell::new(vec![]).with_footer(prefs());
1413        assert_eq!(shell.current(), Some(Screen::Prefs));
1414    }
1415
1416    /// The action first, then eight screens of their own names, with the footer.
1417    fn crowded_shell() -> Shell<Screen> {
1418        const NAMES: [&str; 8] = ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7"];
1419        let mut items: Vec<RailItem<Screen>> =
1420            vec![Action::new("new", crate::icons::plus(), "new-entry").into()];
1421        items.extend(
1422            NAMES.iter().map(|&name| {
1423                Destination::new(Screen::Entries, crate::icons::settings(), name).into()
1424            }),
1425        );
1426        Shell::from_items(items).with_footer(prefs())
1427    }
1428
1429    /// `shell` on a `size` screen, with a 12-point foot, and a status bar when `status`.
1430    /// Returns the foot's rect.
1431    fn crowded(shell: &mut Shell<Screen>, size: [f32; 2], status: bool) -> egui::Rect {
1432        let mut foot = egui::Rect::NOTHING;
1433        render(size, |ui| {
1434            let dot = |ui: &mut egui::Ui| {
1435                foot = ui
1436                    .allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::click())
1437                    .0;
1438                None
1439            };
1440            if status {
1441                shell.ui_with_status(ui, dot, |ui| drop(ui.label("saved")), |ui, _| screen(ui));
1442            } else {
1443                shell.ui_with_foot(ui, dot, |ui, _| screen(ui));
1444            }
1445        });
1446        foot
1447    }
1448
1449    #[test]
1450    fn items_and_actions_mix_in_the_apps_order() {
1451        let mut shell = crowded_shell();
1452        assert_eq!(
1453            shell.current(),
1454            Some(Screen::Entries),
1455            "the first screen, not the action"
1456        );
1457        crowded(&mut shell, [914.0, 900.0], true);
1458        assert_eq!(shell.cell_rects.len(), 9);
1459        assert_eq!(
1460            shell.cell_rects[0], shell.action_rects[0],
1461            "the action on top"
1462        );
1463        assert!(!shell.footer_in_menu);
1464        assert!(shell.footer_rects[0].is_positive());
1465        assert_eq!(shell.more_rect, egui::Rect::NOTHING, "no menu when all fit");
1466    }
1467
1468    #[test]
1469    fn a_crowded_bar_puts_the_more_button_where_the_footer_was() {
1470        for width in [320.0, 360.0, 411.0] {
1471            let mut shell = crowded_shell();
1472            let foot = crowded(&mut shell, [width, 700.0], false);
1473            let (cells, more) = (&shell.cell_rects, shell.more_rect);
1474            assert!(
1475                !cells.is_empty() && cells.len() < 9,
1476                "{width}: {}",
1477                cells.len()
1478            );
1479            assert!(shell.footer_in_menu, "{width}");
1480            assert!(
1481                shell.footer_rects.is_empty(),
1482                "{width}: the footer is in the menu"
1483            );
1484            for rect in cells.iter().chain([&more, &foot]) {
1485                assert!(rect.is_positive(), "{width}: {rect:?}");
1486                assert!(
1487                    rect.left() >= 0.0 && rect.right() <= width,
1488                    "{width}: {rect:?}"
1489                );
1490            }
1491            // In order, and none on top of the next.
1492            let row: Vec<egui::Rect> = cells.iter().copied().chain([more, foot]).collect();
1493            for pair in row.windows(2) {
1494                assert!(pair[0].right() <= pair[1].left(), "{width}: {pair:?}");
1495            }
1496            // Every button keeps its full size, and they line up.
1497            for rect in cells.iter().chain([&more]) {
1498                assert_eq!(rect.size(), egui::vec2(BUTTON, BUTTON), "{width}: {rect:?}");
1499                assert_eq!(rect.center().y, more.center().y, "{width}: {rect:?}");
1500            }
1501        }
1502    }
1503
1504    #[test]
1505    fn a_low_rail_keeps_its_items_clear_of_the_more_button() {
1506        // A phone on its side (#29), a short desktop window, one shorter still.
1507        for (height, status) in [(411.0, true), (411.0, false), (300.0, true), (160.0, false)] {
1508            let mut shell = crowded_shell();
1509            let foot = crowded(&mut shell, [914.0, height], status);
1510            let (cells, more) = (&shell.cell_rects, shell.more_rect);
1511            let case = format!("{height} {status}");
1512            assert!(cells.len() < 9, "{case}: {}", cells.len());
1513            assert!(
1514                shell.footer_rects.is_empty(),
1515                "{case}: the footer is in the menu"
1516            );
1517            assert!(more.is_positive(), "{case}");
1518            let column: Vec<egui::Rect> = cells.iter().copied().chain([more]).collect();
1519            for pair in column.windows(2) {
1520                assert!(pair[0].bottom() <= pair[1].top(), "{case}: {pair:?}");
1521            }
1522            if !status {
1523                // The foot sits above the More button, at the rail's foot.
1524                let last = cells.last().copied().unwrap_or(egui::Rect::NOTHING);
1525                assert!(last.bottom() <= foot.top(), "{case}: {last:?} {foot:?}");
1526                assert!(foot.bottom() <= more.top(), "{case}: {foot:?} {more:?}");
1527            }
1528        }
1529    }
1530
1531    #[test]
1532    fn at_every_height_the_footer_or_the_more_button_holds_the_end() {
1533        let mut both = (false, false);
1534        for height in (200..=900).step_by(10) {
1535            let height = height as f32;
1536            let mut shell = crowded_shell();
1537            crowded(&mut shell, [914.0, height], true);
1538            let cells = &shell.cell_rects;
1539            let end = if shell.footer_in_menu {
1540                both.1 = true;
1541                assert!(cells.len() < 9, "{height}");
1542                assert!(shell.footer_rects.is_empty(), "{height}");
1543                shell.more_rect
1544            } else {
1545                both.0 = true;
1546                assert_eq!(cells.len(), 9, "{height}: all fit, or the footer goes");
1547                assert_eq!(shell.more_rect, egui::Rect::NOTHING, "{height}");
1548                shell.footer_rects[0]
1549            };
1550            assert!(end.is_positive(), "{height}");
1551            for pair in cells
1552                .iter()
1553                .copied()
1554                .chain([end])
1555                .collect::<Vec<_>>()
1556                .windows(2)
1557            {
1558                assert!(pair[0].bottom() <= pair[1].top(), "{height}: {pair:?}");
1559            }
1560        }
1561        assert_eq!(
1562            both,
1563            (true, true),
1564            "the sweep crosses from one to the other"
1565        );
1566    }
1567
1568    fn keys(shell: &Shell<Screen>) -> Vec<String> {
1569        shell.rail_order().order
1570    }
1571
1572    #[test]
1573    fn the_rail_order_round_trips_and_skips_names_it_does_not_know() {
1574        let mut shell = crowded_shell();
1575        assert_eq!(keys(&shell)[..3], ["new", "s0", "s1"]);
1576        let order = RailOrder {
1577            order: vec!["s3".into(), "gone".into(), "new".into(), "s1".into()],
1578            hidden: vec!["s1".into(), "gone".into()],
1579        };
1580        shell.set_rail_order(&order);
1581        // The named first, the action among them, then the rest in the app's order.
1582        assert_eq!(
1583            keys(&shell),
1584            ["s3", "new", "s1", "s0", "s2", "s4", "s5", "s6", "s7"]
1585        );
1586        assert_eq!(shell.rail_order().hidden, ["s1"]);
1587        let again = shell.rail_order();
1588        shell.set_rail_order(&again);
1589        assert_eq!(shell.rail_order(), again);
1590        shell.set_rail_order(&RailOrder::default());
1591        assert_eq!(
1592            keys(&shell)[..3],
1593            ["new", "s0", "s1"],
1594            "the default is the app's"
1595        );
1596    }
1597
1598    #[test]
1599    fn with_actions_still_puts_them_last() {
1600        let shell =
1601            shell().with_actions(vec![Action::new("new", crate::icons::plus(), "new-entry")]);
1602        assert_eq!(keys(&shell), ["entries", "summary", "new"]);
1603    }
1604
1605    #[test]
1606    fn a_hidden_item_sends_the_footer_into_the_menu() {
1607        let mut shell = shell().with_footer(prefs()).with_rail_order(&RailOrder {
1608            order: vec![],
1609            hidden: vec!["entries".into()],
1610        });
1611        for size in SIZES {
1612            render(size, |ui| shell.ui(ui, |ui, _| screen(ui)));
1613            assert_eq!(shell.on_rail, [1], "{size:?}");
1614            assert!(shell.footer_in_menu, "{size:?}");
1615            assert_eq!(
1616                shell.menu_rows(),
1617                [Row::Footer(0), Row::Slot(0)],
1618                "{size:?}: the footer first"
1619            );
1620        }
1621    }
1622
1623    /// Run `shell` on a 900 × 600 screen in `ctx`; clicking at `at` when given.
1624    fn frames(ctx: &egui::Context, shell: &mut Shell<Screen>, at: Option<egui::Pos2>) {
1625        let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0));
1626        let frame = |events: Vec<egui::Event>, shell: &mut Shell<Screen>| {
1627            let input = egui::RawInput {
1628                screen_rect: Some(rect),
1629                events,
1630                ..Default::default()
1631            };
1632            let mut output = ctx.run_ui(input, |ui| shell.ui(ui, |ui, _| screen(ui)));
1633            output.textures_delta.clear();
1634        };
1635        let Some(pos) = at else {
1636            frame(vec![], shell);
1637            frame(vec![], shell);
1638            return;
1639        };
1640        let button = |pressed| egui::Event::PointerButton {
1641            pos,
1642            button: egui::PointerButton::Primary,
1643            pressed,
1644            modifiers: egui::Modifiers::NONE,
1645        };
1646        frame(vec![egui::Event::PointerMoved(pos)], shell);
1647        frame(vec![button(true)], shell);
1648        frame(vec![button(false)], shell);
1649        frame(vec![], shell);
1650    }
1651
1652    #[test]
1653    fn the_menu_opens_what_is_off_the_rail() {
1654        let mut shell = shell().with_footer(prefs()).with_rail_order(&RailOrder {
1655            order: vec![],
1656            hidden: vec!["summary".into()],
1657        });
1658        let ctx = egui::Context::default();
1659        frames(&ctx, &mut shell, None);
1660        let at = shell.more_rect.center();
1661        frames(&ctx, &mut shell, Some(at));
1662        assert_eq!(
1663            shell.menu_rects.len(),
1664            2,
1665            "the footer and the hidden screen"
1666        );
1667        let at = shell.menu_rects[1].center();
1668        frames(&ctx, &mut shell, Some(at));
1669        assert_eq!(shell.current(), Some(Screen::Summary));
1670        let at = shell.more_rect.center();
1671        frames(&ctx, &mut shell, Some(at));
1672        let at = shell.menu_rects[0].center();
1673        frames(&ctx, &mut shell, Some(at));
1674        assert_eq!(shell.current(), Some(Screen::Prefs));
1675    }
1676
1677    #[test]
1678    fn the_shell_lists_its_items_and_takes_the_order_asked_for() {
1679        let ctx = egui::Context::default();
1680        let mut shell = crowded_shell();
1681        frames(&ctx, &mut shell, None);
1682        let listed = published(&ctx).unwrap();
1683        assert_eq!(listed.len(), 9);
1684        assert_eq!(listed[0].key, "new");
1685        request(
1686            &ctx,
1687            RailOrder {
1688                order: vec!["s7".into()],
1689                hidden: vec!["new".into()],
1690            },
1691        );
1692        frames(&ctx, &mut shell, None);
1693        assert_eq!(keys(&shell)[0], "s7");
1694        assert_eq!(shell.rail_order().hidden, ["new"]);
1695        assert_eq!(published(&ctx).unwrap()[0].key, "s7");
1696    }
1697
1698    #[cfg(feature = "serde")]
1699    #[test]
1700    fn the_rail_order_is_taken_from_the_store_and_saved_there() {
1701        use crate::store::{self, Store, Stored};
1702        let dir = tempfile::tempdir().unwrap();
1703        let file = Store::at(dir.path().join(store::FILE_NAME));
1704        let saved = RailOrder {
1705            order: vec!["summary".into(), "entries".into()],
1706            hidden: vec![],
1707        };
1708        file.save(&Stored {
1709            look: None,
1710            rail: Some(saved.clone()),
1711            side: None,
1712        })
1713        .unwrap();
1714        let ctx = egui::Context::default();
1715        store::attach(&ctx, file.clone());
1716        let mut shell = shell();
1717        frames(&ctx, &mut shell, None);
1718        assert_eq!(shell.rail_order(), saved);
1719
1720        request(
1721            &ctx,
1722            RailOrder {
1723                order: vec!["entries".into(), "summary".into()],
1724                hidden: vec![],
1725            },
1726        );
1727        frames(&ctx, &mut shell, None);
1728        assert_eq!(
1729            file.load().and_then(|s| s.rail).map(|r| r.order),
1730            Some(vec!["entries".to_owned(), "summary".to_owned()])
1731        );
1732    }
1733
1734    /// An app whose screens and side pane share its state: the draft an action begins, and
1735    /// what was drawn.
1736    #[derive(Default)]
1737    struct Notes {
1738        draft: Option<String>,
1739        screens: Vec<Screen>,
1740        sides: Vec<Screen>,
1741        status: bool,
1742    }
1743
1744    impl ShellApp<Screen> for Notes {
1745        fn screen_ui(&mut self, ui: &mut egui::Ui, screen: Screen, _: &mut Side) {
1746            self.screens.push(screen);
1747            egui::CentralPanel::default().show(ui, |ui| ui.label("screen"));
1748        }
1749
1750        fn side_ui(&mut self, ui: &mut egui::Ui, screen: Screen, side: &mut Side) {
1751            self.sides.push(screen);
1752            if let Some(draft) = &mut self.draft {
1753                ui.text_edit_singleline(draft);
1754                if ui.button("save").clicked() {
1755                    self.draft = None;
1756                    side.close();
1757                }
1758            }
1759        }
1760
1761        fn status_ui(&mut self, ui: &mut egui::Ui) {
1762            self.status = true;
1763            ui.label("ready");
1764        }
1765
1766        fn action(&mut self, id: &'static str, side: &mut Side) {
1767            assert_eq!(id, "new");
1768            self.draft = Some(String::new());
1769            side.open();
1770        }
1771    }
1772
1773    fn notes_shell() -> Shell<Screen> {
1774        Shell::from_items(vec![
1775            Action::new("new", crate::icons::plus(), "new-entry").into(),
1776            Destination::new(Screen::Entries, crate::icons::settings(), "entries").into(),
1777            Destination::new(Screen::Summary, crate::icons::arrow_left(), "summary").into(),
1778        ])
1779        .with_footer(prefs())
1780        .with_side_pane()
1781        .with_status_bar()
1782    }
1783
1784    /// Two frames of `app` in `shell` on a `size` screen in `ctx`, then a click at `at`.
1785    fn show(
1786        ctx: &egui::Context,
1787        shell: &mut Shell<Screen>,
1788        app: &mut Notes,
1789        size: [f32; 2],
1790        at: Option<egui::Pos2>,
1791    ) {
1792        let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(size[0], size[1]));
1793        let mut frame = |events: Vec<egui::Event>| {
1794            let input = egui::RawInput {
1795                screen_rect: Some(rect),
1796                events,
1797                ..Default::default()
1798            };
1799            let mut output = ctx.run_ui(input, |ui| shell.show(ui, app));
1800            output.textures_delta.clear();
1801        };
1802        frame(vec![]);
1803        frame(vec![]);
1804        if let Some(pos) = at {
1805            let button = |pressed| egui::Event::PointerButton {
1806                pos,
1807                button: egui::PointerButton::Primary,
1808                pressed,
1809                modifiers: egui::Modifiers::NONE,
1810            };
1811            frame(vec![egui::Event::PointerMoved(pos)]);
1812            frame(vec![button(true)]);
1813            frame(vec![button(false)]);
1814            frame(vec![]);
1815        }
1816    }
1817
1818    #[test]
1819    fn wide_the_side_pane_is_beside_every_screen() {
1820        let ctx = egui::Context::default();
1821        let (mut shell, mut app) = (notes_shell(), Notes::default());
1822        for screen in [Screen::Entries, Screen::Summary, Screen::Prefs] {
1823            app.sides.clear();
1824            shell.open(screen);
1825            show(&ctx, &mut shell, &mut app, SIZES[0], None);
1826            assert_eq!(app.sides.last(), Some(&screen), "{screen:?}");
1827            assert_eq!(app.screens.last(), Some(&screen), "{screen:?}");
1828        }
1829        assert!(app.status, "the status bar is drawn");
1830    }
1831
1832    #[test]
1833    fn an_action_begins_an_entry_in_the_side_pane() {
1834        // Wide, beside the screen; on a phone, in its place.
1835        for (size, in_place) in [(SIZES[0], false), (SIZES[1], true)] {
1836            let ctx = egui::Context::default();
1837            let (mut shell, mut app) = (notes_shell(), Notes::default());
1838            show(&ctx, &mut shell, &mut app, size, None);
1839            let at = shell.action_rects[0].center();
1840            show(&ctx, &mut shell, &mut app, size, Some(at));
1841            assert_eq!(app.draft.as_deref(), Some(""), "{size:?}");
1842            assert_eq!(
1843                shell.current(),
1844                Some(Screen::Entries),
1845                "{size:?}: the screen stays"
1846            );
1847            app.screens.clear();
1848            app.sides.clear();
1849            show(&ctx, &mut shell, &mut app, size, None);
1850            assert!(!app.sides.is_empty(), "{size:?}");
1851            assert_eq!(app.screens.is_empty(), in_place, "{size:?}");
1852        }
1853    }
1854
1855    #[test]
1856    fn without_a_side_pane_side_ui_is_never_called() {
1857        let ctx = egui::Context::default();
1858        let mut shell =
1859            shell().with_actions(vec![Action::new("new", crate::icons::plus(), "new-entry")]);
1860        let mut app = Notes::default();
1861        show(&ctx, &mut shell, &mut app, SIZES[0], None);
1862        let at = shell.action_rects[0].center();
1863        show(&ctx, &mut shell, &mut app, SIZES[0], Some(at));
1864        assert!(app.draft.is_some(), "the action still reaches the app");
1865        assert!(app.sides.is_empty());
1866        assert!(!app.status, "no status bar unless asked for");
1867    }
1868
1869    #[cfg(feature = "serde")]
1870    #[test]
1871    fn the_side_column_is_kept_in_the_store() {
1872        use crate::store::{self, Store};
1873        let dir = tempfile::tempdir().unwrap();
1874        let file = Store::at(dir.path().join(store::FILE_NAME));
1875        let ctx = egui::Context::default();
1876        store::attach(&ctx, file.clone());
1877        let (mut shell, mut app) = (notes_shell(), Notes::default());
1878        show(&ctx, &mut shell, &mut app, SIZES[0], None);
1879        assert_eq!(
1880            file.load().and_then(|s| s.side),
1881            None,
1882            "nothing changed yet"
1883        );
1884        shell.pane.as_mut().unwrap().set_column(false);
1885        show(&ctx, &mut shell, &mut app, SIZES[0], None);
1886        assert_eq!(
1887            file.load().and_then(|s| s.side).map(|s| s.column),
1888            Some(false)
1889        );
1890
1891        // The next start.
1892        let ctx = egui::Context::default();
1893        store::attach(&ctx, file);
1894        let (mut shell, mut app) = (notes_shell(), Notes::default());
1895        show(&ctx, &mut shell, &mut app, SIZES[0], None);
1896        assert!(!shell.pane.as_ref().unwrap().column());
1897    }
1898
1899    #[test]
1900    fn a_roomy_bar_keeps_full_size_buttons() {
1901        let mut shell = shell().with_footer(prefs());
1902        render(SIZES[1], |ui| shell.ui(ui, |ui, _| screen(ui)));
1903        assert_eq!(shell.cell_rects.len(), 2);
1904        for rect in &shell.cell_rects {
1905            assert_eq!(rect.size(), egui::vec2(BUTTON, BUTTON), "{rect:?}");
1906        }
1907    }
1908
1909    /// Render `shell` with a status bar at `size`: where the foot was drawn, and whether
1910    /// the status was.
1911    fn with_status(shell: &mut Shell<Screen>, size: [f32; 2]) -> (egui::Rect, bool) {
1912        let (mut foot, mut status) = (egui::Rect::NOTHING, false);
1913        render(size, |ui| {
1914            shell.ui_with_status(
1915                ui,
1916                |ui| {
1917                    foot = ui.label("dot").rect;
1918                    None
1919                },
1920                |ui| {
1921                    status = true;
1922                    ui.label("saved");
1923                },
1924                |ui, _| screen(ui),
1925            )
1926        });
1927        (foot, status)
1928    }
1929
1930    #[test]
1931    fn wide_the_status_bar_holds_the_status_and_the_foot() {
1932        let mut shell = shell().with_footer(prefs());
1933        let (foot, status) = with_status(&mut shell, SIZES[0]);
1934        assert!(status);
1935        // On the bar along the bottom, at its right end: not at the rail's foot.
1936        assert!(foot.bottom() > SIZES[0][1] - 30.0, "{foot:?}");
1937        assert!(foot.left() > SIZES[0][0] / 2.0, "{foot:?}");
1938        // The rail stops above the bar.
1939        assert!(
1940            shell.footer_rects[0].bottom() < foot.top(),
1941            "{:?}",
1942            shell.footer_rects
1943        );
1944    }
1945
1946    #[test]
1947    fn narrow_there_is_no_status_bar_and_the_foot_stays_on_the_bar() {
1948        let mut shell = shell().with_footer(prefs());
1949        let (foot, status) = with_status(&mut shell, SIZES[1]);
1950        assert!(!status);
1951        assert!(foot.is_positive());
1952        let bar_top = shell.footer_rects[0].top();
1953        assert!(foot.top() >= bar_top - 10.0, "{foot:?} beside {bar_top}");
1954    }
1955
1956    #[test]
1957    fn with_nothing_there_is_nothing_to_draw() {
1958        let mut shell: Shell<Screen> = Shell::new(vec![]);
1959        assert_eq!(shell.current(), None);
1960        for size in SIZES {
1961            render(size, |ui| shell.ui(ui, |_, s| panic!("{s:?} drawn")));
1962        }
1963    }
1964}