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