1pub const COMPACT_WIDTH: f32 = 600.0;
12
13const RAIL_WIDTH: f32 = 48.0;
15
16const STATUS_MARGIN: f32 = 3.0;
18
19pub struct Destination<D> {
21 pub id: D,
23 pub icon: egui::ImageSource<'static>,
26 pub label: &'static str,
29}
30
31impl<D> Destination<D> {
32 pub fn new(id: D, icon: egui::ImageSource<'static>, label: &'static str) -> Destination<D> {
34 Destination { id, icon, label }
35 }
36}
37
38pub struct Action {
41 pub id: &'static str,
43 pub icon: egui::ImageSource<'static>,
45 pub label: &'static str,
48}
49
50impl Action {
51 pub fn new(id: &'static str, icon: egui::ImageSource<'static>, label: &'static str) -> Action {
53 Action { id, icon, label }
54 }
55}
56
57pub struct Shell<D> {
91 destinations: Vec<Destination<D>>,
92 actions: Vec<Action>,
93 footer: Vec<Destination<D>>,
94 clicked: Option<&'static str>,
96 #[cfg(test)]
98 action_rects: Vec<egui::Rect>,
99 #[cfg(test)]
100 footer_rects: Vec<egui::Rect>,
101 #[cfg(test)]
103 cell_rects: Vec<egui::Rect>,
104 open: Option<D>,
105 foot_width: f32,
108 loaders: bool,
110}
111
112impl<D: Copy + PartialEq> Shell<D> {
113 pub fn new(destinations: Vec<Destination<D>>) -> Shell<D> {
115 let open = destinations.first().map(|d| d.id);
116 Shell {
117 destinations,
118 actions: Vec::new(),
119 footer: Vec::new(),
120 clicked: None,
121 #[cfg(test)]
122 action_rects: Vec::new(),
123 #[cfg(test)]
124 footer_rects: Vec::new(),
125 #[cfg(test)]
126 cell_rects: Vec::new(),
127 open,
128 foot_width: 0.0,
129 loaders: false,
130 }
131 }
132
133 pub fn with_actions(mut self, actions: Vec<Action>) -> Shell<D> {
135 self.actions = actions;
136 self
137 }
138
139 pub fn with_footer(mut self, footer: Vec<Destination<D>>) -> Shell<D> {
142 self.footer = footer;
143 if self.open.is_none() {
144 self.open = self.footer.first().map(|d| d.id);
145 }
146 self
147 }
148
149 pub fn take_action(&mut self) -> Option<&'static str> {
152 self.clicked.take()
153 }
154
155 pub fn current(&self) -> Option<D> {
157 self.open
158 }
159
160 pub fn open(&mut self, id: D) {
162 self.open = Some(id);
163 }
164
165 pub fn ui(&mut self, ui: &mut egui::Ui, content: impl FnOnce(&mut egui::Ui, D)) {
170 self.ui_with_foot(ui, |_| None, content);
171 }
172
173 pub fn ui_with_foot(
176 &mut self,
177 ui: &mut egui::Ui,
178 foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
179 content: impl FnOnce(&mut egui::Ui, D),
180 ) {
181 self.frame_ui(ui, foot, None::<fn(&mut egui::Ui)>, content);
182 }
183
184 pub fn ui_with_status(
191 &mut self,
192 ui: &mut egui::Ui,
193 foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
194 status: impl FnOnce(&mut egui::Ui),
195 content: impl FnOnce(&mut egui::Ui, D),
196 ) {
197 self.frame_ui(ui, foot, Some(status), content);
198 }
199
200 fn frame_ui(
201 &mut self,
202 ui: &mut egui::Ui,
203 foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
204 status: Option<impl FnOnce(&mut egui::Ui)>,
205 content: impl FnOnce(&mut egui::Ui, D),
206 ) {
207 if !std::mem::replace(&mut self.loaders, true) {
208 crate::icons::install_loaders(ui.ctx());
209 if !crate::theme::is_installed(ui.ctx()) {
211 crate::theme::install(ui.ctx(), &crate::theme::ThemeSettings::default());
212 }
213 if !crate::i18n::is_installed(ui.ctx()) {
215 crate::i18n::install(ui.ctx(), &crate::i18n::LanguageSettings::default());
216 }
217 }
218 let safe = unsafe_margin(ui);
219 let full = ui.max_rect();
220 let compact = full.width() - safe.sum().x < COMPACT_WIDTH;
221 let inner = if compact {
224 full - egui::epaint::MarginF32 {
225 bottom: 0.0,
226 ..safe
227 }
228 } else {
229 full - safe
230 };
231 if safe != egui::epaint::MarginF32::ZERO {
233 ui.painter().rect_filled(full, 0.0, ui.visuals().panel_fill);
234 }
235 if !compact && let Some(status) = status {
236 let height = ui.spacing().interact_size.y + 2.0 * STATUS_MARGIN;
238 let above = inner.with_max_y(inner.bottom() - height);
239 let strip = inner.with_min_y(above.bottom());
240 ui.scope_builder(egui::UiBuilder::new().max_rect(above), |ui| {
241 let opened = egui::Panel::left("shell_rail")
242 .resizable(false)
243 .exact_size(RAIL_WIDTH)
244 .show(ui, |ui| self.rail_ui(ui, |_| None))
245 .inner;
246 if let Some(id) = opened {
247 self.open = Some(id);
248 }
249 if let Some(id) = self.open {
250 content(ui, id);
251 }
252 });
253 if let Some(id) = status_bar_ui(ui, strip, foot, status) {
255 self.open = Some(id);
256 ui.ctx().request_repaint();
257 }
258 return;
259 }
260 ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| {
261 let opened = if compact {
262 let mut frame = egui::Frame::side_top_panel(ui.style());
263 frame.inner_margin.bottom = frame
264 .inner_margin
265 .bottom
266 .saturating_add(safe.bottom.round().min(127.0) as i8);
267 egui::Panel::bottom("shell_bar")
268 .frame(frame)
269 .show(ui, |ui| self.bar_ui(ui, foot))
270 .inner
271 } else {
272 egui::Panel::left("shell_rail")
273 .resizable(false)
274 .exact_size(RAIL_WIDTH)
275 .show(ui, |ui| self.rail_ui(ui, foot))
276 .inner
277 };
278 if let Some(id) = opened {
279 self.open = Some(id);
280 }
281 if let Some(id) = self.open {
282 content(ui, id);
283 }
284 });
285 }
286
287 fn rail_ui(
288 &mut self,
289 ui: &mut egui::Ui,
290 foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
291 ) -> Option<D> {
292 ui.vertical_centered(|ui| {
293 ui.add_space(4.0);
294 for i in 0..self.destinations.len() {
295 self.destination_ui(ui, i, BUTTON);
296 }
297 if !self.actions.is_empty() {
298 ui.add_space(4.0);
299 ui.separator();
300 ui.add_space(4.0);
301 for i in 0..self.actions.len() {
302 self.action_ui(ui, i, BUTTON);
303 }
304 }
305 });
306 ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| {
307 ui.add_space(4.0);
308 for i in 0..self.footer.len() {
309 self.footer_ui(ui, i, BUTTON);
310 }
311 foot(ui)
312 })
313 .inner
314 }
315
316 fn bar_ui(
327 &mut self,
328 ui: &mut egui::Ui,
329 foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
330 ) -> Option<D> {
331 let rect = ui.available_rect_before_wrap();
332 let gap = ui.spacing().item_spacing.x;
333 let end = rect.right() - self.foot_width.min(rect.width());
334 let screens = self.destinations.len();
335 let cells = screens + self.actions.len();
336 if cells > 0 {
337 let row = egui::Rect::from_min_max(rect.min, egui::pos2(end - gap, rect.bottom()));
338 ui.scope_builder(egui::UiBuilder::new().max_rect(row), |ui| {
339 ui.columns(cells, |columns| {
340 for (i, column) in columns.iter_mut().enumerate() {
341 column.vertical_centered(|ui| {
342 let side = ui.available_width().min(BUTTON);
343 let response = if i < screens {
344 self.destination_ui(ui, i, side)
345 } else {
346 self.action_ui(ui, i - screens, side)
347 };
348 #[cfg(test)]
349 {
350 self.cell_rects.resize(cells, egui::Rect::NOTHING);
351 self.cell_rects[i] = response.rect;
352 }
353 #[cfg(not(test))]
354 let _ = response;
355 });
356 }
357 });
358 });
359 }
360 let at_end = egui::Rect::from_min_max(egui::pos2(end, rect.top()), rect.max);
361 let end_row = ui
362 .scope_builder(egui::UiBuilder::new().max_rect(at_end), |ui| {
363 ui.horizontal(|ui| {
364 for i in 0..self.footer.len() {
365 self.footer_ui(ui, i, BUTTON);
366 }
367 foot(ui)
368 })
369 })
370 .inner;
371 let opened = end_row.inner;
372 let width = end_row.response.rect.width();
374 if (width - self.foot_width).abs() > 0.5 {
375 self.foot_width = width;
376 ui.ctx()
377 .request_discard("the shell's bar foot changed width");
378 }
379 opened
380 }
381
382 fn destination_ui(&mut self, ui: &mut egui::Ui, i: usize, side: f32) -> egui::Response {
383 let d = &self.destinations[i];
384 let open = self.open == Some(d.id);
385 let response = icon_button(ui, open, d.icon.clone(), side)
386 .on_hover_text(crate::i18n::tr(ui.ctx(), d.label));
387 if response.clicked() {
388 self.open = Some(d.id);
389 }
390 response
391 }
392
393 #[cfg(test)]
395 fn label_text(&self, ctx: &egui::Context, i: usize) -> String {
396 crate::i18n::tr(ctx, self.destinations[i].label)
397 }
398
399 fn footer_ui(&mut self, ui: &mut egui::Ui, i: usize, side: f32) {
400 let d = &self.footer[i];
401 let open = self.open == Some(d.id);
402 let response = icon_button(ui, open, d.icon.clone(), side)
403 .on_hover_text(crate::i18n::tr(ui.ctx(), d.label));
404 #[cfg(test)]
405 {
406 self.footer_rects
407 .resize(self.footer.len(), egui::Rect::NOTHING);
408 self.footer_rects[i] = response.rect;
409 }
410 if response.clicked() {
411 self.open = Some(self.footer[i].id);
412 }
413 }
414
415 fn action_ui(&mut self, ui: &mut egui::Ui, i: usize, side: f32) -> egui::Response {
416 let a = &self.actions[i];
417 let response = icon_button(ui, false, a.icon.clone(), side)
418 .on_hover_text(crate::i18n::tr(ui.ctx(), a.label));
419 #[cfg(test)]
420 {
421 self.action_rects
422 .resize(self.actions.len(), egui::Rect::NOTHING);
423 self.action_rects[i] = response.rect;
424 }
425 if response.clicked() {
426 self.clicked = Some(a.id);
427 }
428 response
429 }
430}
431
432fn status_bar_ui<D>(
435 ui: &mut egui::Ui,
436 strip: egui::Rect,
437 foot: impl FnOnce(&mut egui::Ui) -> Option<D>,
438 status: impl FnOnce(&mut egui::Ui),
439) -> Option<D> {
440 let visuals = ui.visuals();
441 ui.painter().rect_filled(strip, 0.0, visuals.panel_fill);
442 ui.painter().hline(
443 strip.x_range(),
444 strip.top(),
445 visuals.widgets.noninteractive.bg_stroke,
446 );
447 let row = strip.shrink2(egui::vec2(8.0, STATUS_MARGIN));
448 ui.scope_builder(
449 egui::UiBuilder::new()
450 .max_rect(row)
451 .layout(egui::Layout::right_to_left(egui::Align::Center)),
452 |ui| {
453 ui.set_clip_rect(row);
454 crate::detail::toggle_ui(ui);
455 let opened = foot(ui);
456 ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), status);
458 opened
459 },
460 )
461 .inner
462}
463
464fn unsafe_margin(ui: &egui::Ui) -> egui::epaint::MarginF32 {
467 let safe = ui.ctx().content_rect();
468 let rect = ui.max_rect();
469 egui::epaint::MarginF32 {
470 left: (safe.left() - rect.left()).max(0.0),
471 right: (rect.right() - safe.right()).max(0.0),
472 top: (safe.top() - rect.top()).max(0.0),
473 bottom: (rect.bottom() - safe.bottom()).max(0.0),
474 }
475}
476
477const BUTTON: f32 = 36.0;
479
480fn icon_button(
483 ui: &mut egui::Ui,
484 open: bool,
485 icon: egui::ImageSource<'static>,
486 side: f32,
487) -> egui::Response {
488 let pad = ui.spacing().button_padding.x;
491 let icon_size = (side - 2.0 * pad).clamp(12.0, 20.0);
492 ui.scope(|ui| {
493 let padding = &mut ui.spacing_mut().button_padding.x;
494 *padding = padding.min(((side - icon_size) / 2.0).max(0.0));
495 ui.add(
497 crate::icons::button(icon, icon_size)
498 .selected(open)
499 .frame_when_inactive(open)
500 .min_size(egui::vec2(side, BUTTON)),
501 )
502 })
503 .inner
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::test_support::render;
510
511 #[derive(Clone, Copy, PartialEq, Debug)]
512 enum Screen {
513 Entries,
514 Summary,
515 Prefs,
516 }
517
518 const SIZES: [[f32; 2]; 2] = [[900.0, 600.0], [400.0, 700.0]];
519
520 fn shell() -> Shell<Screen> {
521 Shell::new(vec![
522 Destination::new(Screen::Entries, crate::icons::settings(), "entries"),
523 Destination::new(Screen::Summary, crate::icons::arrow_left(), "summary"),
524 ])
525 }
526
527 fn prefs() -> Vec<Destination<Screen>> {
528 vec![Destination::new(
529 Screen::Prefs,
530 crate::icons::settings(),
531 "preferences",
532 )]
533 }
534
535 fn screen(ui: &mut egui::Ui) {
536 egui::CentralPanel::default().show(ui, |ui| ui.label("screen"));
537 }
538
539 fn click(shell: &mut Shell<Screen>, size: [f32; 2], at: impl Fn(&Shell<Screen>) -> egui::Pos2) {
541 let ctx = egui::Context::default();
542 let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(size[0], size[1]));
543 let frame = |events: Vec<egui::Event>, shell: &mut Shell<Screen>| {
544 let input = egui::RawInput {
545 screen_rect: Some(rect),
546 events,
547 ..Default::default()
548 };
549 let mut output = ctx.run_ui(input, |ui| shell.ui(ui, |ui, _| screen(ui)));
550 output.textures_delta.clear();
551 };
552 frame(vec![], shell);
553 frame(vec![], shell);
554 let pos = at(shell);
555 let button = |pressed| egui::Event::PointerButton {
556 pos,
557 button: egui::PointerButton::Primary,
558 pressed,
559 modifiers: egui::Modifiers::NONE,
560 };
561 frame(vec![egui::Event::PointerMoved(pos)], shell);
562 frame(vec![button(true)], shell);
563 frame(vec![button(false)], shell);
564 }
565
566 #[test]
567 fn it_opens_on_the_first_screen_and_draws_it() {
568 let mut shell = shell();
569 assert_eq!(shell.current(), Some(Screen::Entries));
570 for size in SIZES {
571 let mut drawn = Vec::new();
572 render(size, |ui| {
573 shell.ui(ui, |ui, s| {
574 drawn.push(s);
575 screen(ui);
576 })
577 });
578 assert_eq!(drawn, [Screen::Entries, Screen::Entries]);
579 }
580 }
581
582 #[test]
583 fn actions_are_drawn_in_both_layouts_and_report_nothing_unclicked() {
584 let mut shell =
585 shell().with_actions(vec![Action::new("new", crate::icons::plus(), "new-entry")]);
586 for size in SIZES {
587 shell.action_rects.clear();
588 render(size, |ui| shell.ui(ui, |ui, _| screen(ui)));
589 assert!(shell.action_rects[0].is_positive(), "{size:?}");
590 assert_eq!(shell.take_action(), None);
591 }
592 assert_eq!(shell.current(), Some(Screen::Entries));
593 }
594
595 #[test]
596 fn a_clicked_action_is_taken_once_and_opens_nothing() {
597 let mut shell =
598 shell().with_actions(vec![Action::new("new", crate::icons::plus(), "new-entry")]);
599 click(&mut shell, SIZES[0], |s| s.action_rects[0].center());
600 assert_eq!(shell.take_action(), Some("new"));
601 assert_eq!(shell.take_action(), None);
602 assert_eq!(shell.current(), Some(Screen::Entries));
603 }
604
605 #[test]
606 fn footer_and_foot_are_in_both_layouts() {
607 let mut shell = shell().with_footer(prefs());
608 for size in SIZES {
609 shell.footer_rects.clear();
610 let mut foot = egui::Rect::NOTHING;
611 render(size, |ui| {
612 shell.ui_with_foot(
613 ui,
614 |ui| {
615 foot = ui
616 .allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::click())
617 .0;
618 None
619 },
620 |ui, _| screen(ui),
621 )
622 });
623 assert!(shell.footer_rects[0].is_positive(), "{size:?}");
624 assert!(foot.is_positive(), "{size:?}");
625 }
626 }
627
628 #[test]
629 fn clicking_a_footer_destination_opens_it() {
630 for size in SIZES {
631 let mut shell = shell().with_footer(prefs());
632 click(&mut shell, size, |s| s.footer_rects[0].center());
633 assert_eq!(shell.current(), Some(Screen::Prefs), "{size:?}");
634 }
635 let mut shell = shell().with_footer(prefs());
636 shell.open(Screen::Prefs);
637 let mut drawn = Vec::new();
638 render([900.0, 600.0], |ui| {
639 shell.ui(ui, |ui, s| {
640 drawn.push(s);
641 screen(ui);
642 })
643 });
644 assert_eq!(drawn, [Screen::Prefs, Screen::Prefs]);
645 }
646
647 #[test]
648 fn a_destination_the_foot_returns_is_opened() {
649 let mut shell = shell();
650 let mut drawn = Vec::new();
651 render([900.0, 600.0], |ui| {
652 shell.ui_with_foot(
653 ui,
654 |_| Some(Screen::Summary),
655 |ui, s| {
656 drawn.push(s);
657 screen(ui);
658 },
659 )
660 });
661 assert_eq!(shell.current(), Some(Screen::Summary));
662 assert_eq!(drawn, [Screen::Summary, Screen::Summary]);
663 }
664
665 #[test]
666 fn the_shell_installs_the_default_theme() {
667 let ctx = egui::Context::default();
668 let mut shell = shell();
669 crate::test_support::render_in(&ctx, [900.0, 600.0], |ui| shell.ui(ui, |ui, _| screen(ui)));
670 assert!(crate::theme::is_installed(&ctx));
671 }
672
673 #[test]
674 fn the_shell_keeps_an_installed_theme() {
675 use crate::theme::{Contrast, ThemeSettings};
676 let ctx = egui::Context::default();
677 let settings = ThemeSettings {
678 preference: egui::ThemePreference::Dark,
679 contrast: Contrast::High,
680 ..Default::default()
681 };
682 crate::theme::install(&ctx, &settings);
683 let mut shell = shell();
684 crate::test_support::render_in(&ctx, [900.0, 600.0], |ui| shell.ui(ui, |ui, _| screen(ui)));
685 let high = crate::theme::Theme::new(
686 true,
687 Contrast::High,
688 crate::theme::Tint::Swatch(crate::theme::Swatch::Blue),
689 );
690 assert_eq!(*crate::theme::current(&ctx), high);
691 }
692
693 const T: crate::i18n::Resources = crate::i18n::Resources {
694 name: "shell-test",
695 files: &[
696 ("en", "t-entries = Entries\n"),
697 ("ja", "t-entries = 項目\n"),
698 ],
699 };
700
701 #[test]
702 fn the_shell_installs_the_default_language() {
703 let ctx = egui::Context::default();
704 let mut shell = shell();
705 crate::test_support::render_in(&ctx, [900.0, 600.0], |ui| shell.ui(ui, |ui, _| screen(ui)));
706 assert!(crate::i18n::is_installed(&ctx));
707 }
708
709 #[test]
710 fn the_shell_keeps_an_installed_language() {
711 use crate::i18n::{Language, LanguageSettings};
712 let ctx = egui::Context::default();
713 let ja = LanguageSettings {
714 language: Language::Explicit("ja".into()),
715 };
716 crate::i18n::install(&ctx, &ja);
717 let mut shell = shell();
718 crate::test_support::render_in(&ctx, [900.0, 600.0], |ui| shell.ui(ui, |ui, _| screen(ui)));
719 assert_eq!(crate::i18n::current(&ctx).to_string(), "ja");
720 }
721
722 #[test]
723 fn hover_text_follows_the_language() {
724 use crate::i18n::{Language, LanguageSettings};
725 let ctx = egui::Context::default();
726 crate::i18n::register(&ctx, T);
727 let shell = Shell::new(vec![Destination::new(
728 Screen::Entries,
729 crate::icons::settings(),
730 "t-entries",
731 )]);
732 let set = |tag: &str| {
733 crate::i18n::install(
734 &ctx,
735 &LanguageSettings {
736 language: Language::Explicit(tag.into()),
737 },
738 )
739 };
740 set("en");
741 assert_eq!(shell.label_text(&ctx, 0), "Entries");
742 set("ja");
743 assert_eq!(shell.label_text(&ctx, 0), "項目");
744 }
745
746 fn render_safe(
749 shell: &mut Shell<Screen>,
750 size: [f32; 2],
751 safe: egui::epaint::MarginF32,
752 own: egui::epaint::MarginF32,
753 ) -> egui::Rect {
754 let ctx = egui::Context::default();
755 let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(size[0], size[1]));
756 let mut drawn = egui::Rect::NOTHING;
757 for _ in 0..2 {
758 let input = egui::RawInput {
759 screen_rect: Some(rect),
760 safe_area_insets: Some(egui::SafeAreaInsets(safe)),
761 ..Default::default()
762 };
763 let mut output = ctx.run_ui(input, |ui| {
764 let inner = ui.max_rect() - own;
765 ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| {
766 shell.ui(ui, |ui, _| {
767 drawn = ui.available_rect_before_wrap();
768 screen(ui);
769 })
770 });
771 });
772 output.textures_delta.clear();
773 }
774 drawn
775 }
776
777 fn margin(left: f32, top: f32, right: f32, bottom: f32) -> egui::epaint::MarginF32 {
778 egui::epaint::MarginF32 {
779 left,
780 right,
781 top,
782 bottom,
783 }
784 }
785
786 #[test]
787 fn narrow_it_keeps_clear_of_the_status_bar_and_the_home_indicator() {
788 let mut shell = shell().with_footer(prefs());
790 let drawn = render_safe(
791 &mut shell,
792 [400.0, 800.0],
793 margin(0.0, 62.0, 0.0, 34.0),
794 egui::epaint::MarginF32::ZERO,
795 );
796 assert_eq!(drawn.top(), 62.0);
797 let button = shell.footer_rects[0];
798 assert!(button.bottom() <= 800.0 - 34.0, "{button:?}");
799 assert!(button.bottom() > 800.0 - 34.0 - 12.0, "{button:?}");
801 assert!(drawn.bottom() <= button.top(), "{drawn:?} {button:?}");
802 }
803
804 #[test]
805 fn wide_it_keeps_clear_of_the_notch_and_the_home_indicator() {
806 let mut shell = shell().with_footer(prefs());
808 let drawn = render_safe(
809 &mut shell,
810 [900.0, 400.0],
811 margin(59.0, 0.0, 59.0, 21.0),
812 egui::epaint::MarginF32::ZERO,
813 );
814 let button = shell.footer_rects[0];
815 assert!(button.left() >= 59.0, "{button:?}");
816 assert!(button.bottom() <= 400.0 - 21.0, "{button:?}");
817 assert!(drawn.right() <= 900.0 - 59.0, "{drawn:?}");
818 assert!(drawn.bottom() <= 400.0 - 21.0, "{drawn:?}");
819 }
820
821 #[test]
822 fn a_margin_the_app_already_keeps_is_not_doubled() {
823 let safe = margin(0.0, 62.0, 0.0, 34.0);
824 let mut shell = shell().with_footer(prefs());
825 let drawn = render_safe(&mut shell, [400.0, 800.0], safe, safe);
826 assert_eq!(drawn.top(), 62.0);
827 let button = shell.footer_rects[0];
828 assert!(button.bottom() > 800.0 - 34.0 - 12.0, "{button:?}");
829 }
830
831 #[test]
832 fn with_only_a_footer_it_opens_on_the_footer() {
833 let shell = Shell::new(vec![]).with_footer(prefs());
834 assert_eq!(shell.current(), Some(Screen::Prefs));
835 }
836
837 fn crowded(width: f32) -> (Vec<egui::Rect>, egui::Rect, egui::Rect) {
840 let screens = (0..8)
841 .map(|_| Destination::new(Screen::Entries, crate::icons::settings(), "entries"))
842 .collect();
843 let mut shell = Shell::new(screens)
844 .with_actions(vec![Action::new("new", crate::icons::plus(), "new-entry")])
845 .with_footer(prefs());
846 let mut foot = egui::Rect::NOTHING;
847 render([width, 700.0], |ui| {
848 shell.ui_with_foot(
849 ui,
850 |ui| {
851 foot = ui
852 .allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::click())
853 .0;
854 None
855 },
856 |ui, _| screen(ui),
857 )
858 });
859 (shell.cell_rects, shell.footer_rects[0], foot)
860 }
861
862 #[test]
863 fn a_crowded_bar_keeps_the_footer_and_the_foot_on_the_screen() {
864 for width in [320.0, 360.0, 411.0] {
865 let (cells, footer, foot) = crowded(width);
866 assert_eq!(cells.len(), 9, "{width}");
867 for rect in cells.iter().chain([&footer, &foot]) {
868 assert!(rect.is_positive(), "{width}: {rect:?}");
869 assert!(
870 rect.left() >= 0.0 && rect.right() <= width,
871 "{width}: {rect:?}"
872 );
873 }
874 for pair in cells.windows(2) {
876 assert!(pair[0].right() <= pair[1].left(), "{width}: {pair:?}");
877 }
878 assert!(cells[8].right() <= footer.left(), "{width}");
879 assert!(footer.right() <= foot.left(), "{width}");
880 assert_eq!(footer.width(), BUTTON, "{width}");
882 for rect in &cells {
883 assert_eq!(rect.height(), BUTTON, "{width}: {rect:?}");
884 assert_eq!(rect.center().y, footer.center().y, "{width}: {rect:?}");
885 }
886 }
887 }
888
889 #[test]
890 fn a_roomy_bar_keeps_full_size_buttons() {
891 let mut shell = shell().with_footer(prefs());
892 render(SIZES[1], |ui| shell.ui(ui, |ui, _| screen(ui)));
893 assert_eq!(shell.cell_rects.len(), 2);
894 for rect in &shell.cell_rects {
895 assert_eq!(rect.size(), egui::vec2(BUTTON, BUTTON), "{rect:?}");
896 }
897 }
898
899 fn with_status(shell: &mut Shell<Screen>, size: [f32; 2]) -> (egui::Rect, bool) {
902 let (mut foot, mut status) = (egui::Rect::NOTHING, false);
903 render(size, |ui| {
904 shell.ui_with_status(
905 ui,
906 |ui| {
907 foot = ui.label("dot").rect;
908 None
909 },
910 |ui| {
911 status = true;
912 ui.label("saved");
913 },
914 |ui, _| screen(ui),
915 )
916 });
917 (foot, status)
918 }
919
920 #[test]
921 fn wide_the_status_bar_holds_the_status_and_the_foot() {
922 let mut shell = shell().with_footer(prefs());
923 let (foot, status) = with_status(&mut shell, SIZES[0]);
924 assert!(status);
925 assert!(foot.bottom() > SIZES[0][1] - 30.0, "{foot:?}");
927 assert!(foot.left() > SIZES[0][0] / 2.0, "{foot:?}");
928 assert!(
930 shell.footer_rects[0].bottom() < foot.top(),
931 "{:?}",
932 shell.footer_rects
933 );
934 }
935
936 #[test]
937 fn narrow_there_is_no_status_bar_and_the_foot_stays_on_the_bar() {
938 let mut shell = shell().with_footer(prefs());
939 let (foot, status) = with_status(&mut shell, SIZES[1]);
940 assert!(!status);
941 assert!(foot.is_positive());
942 let bar_top = shell.footer_rects[0].top();
943 assert!(foot.top() >= bar_top - 10.0, "{foot:?} beside {bar_top}");
944 }
945
946 #[test]
947 fn with_nothing_there_is_nothing_to_draw() {
948 let mut shell: Shell<Screen> = Shell::new(vec![]);
949 assert_eq!(shell.current(), None);
950 for size in SIZES {
951 render(size, |ui| shell.ui(ui, |_, s| panic!("{s:?} drawn")));
952 }
953 }
954}