1mod order;
18
19pub use order::RailOrder;
20
21use crate::detail::Part;
22pub(crate) use order::{RailEntry, move_to, published, request};
23
24pub const COMPACT_WIDTH: f32 = 600.0;
27
28const RAIL_WIDTH: f32 = 48.0;
30
31const STATUS_MARGIN: f32 = 3.0;
33
34const RAIL_END: f32 = 4.0;
36
37pub struct Destination<D> {
39 pub id: D,
41 pub icon: egui::ImageSource<'static>,
44 pub label: &'static str,
47}
48
49impl<D> Destination<D> {
50 pub fn new(id: D, icon: egui::ImageSource<'static>, label: &'static str) -> Destination<D> {
52 Destination { id, icon, label }
53 }
54}
55
56pub struct Action {
59 pub id: &'static str,
61 pub icon: egui::ImageSource<'static>,
63 pub label: &'static str,
66}
67
68impl Action {
69 pub fn new(id: &'static str, icon: egui::ImageSource<'static>, label: &'static str) -> Action {
71 Action { id, icon, label }
72 }
73}
74
75pub 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
94pub struct Shell<D> {
146 destinations: Vec<Destination<D>>,
147 actions: Vec<Action>,
148 footer: Vec<Destination<D>>,
149 defaults: Vec<Item>,
151 slots: Vec<Slot>,
154 on_rail: Vec<usize>,
156 footer_in_menu: bool,
158 clicked: Option<&'static str>,
160 #[cfg(test)]
162 action_rects: Vec<egui::Rect>,
163 #[cfg(test)]
164 footer_rects: Vec<egui::Rect>,
165 #[cfg(test)]
167 cell_rects: Vec<egui::Rect>,
168 #[cfg(test)]
170 more_rect: egui::Rect,
171 #[cfg(test)]
172 menu_rects: Vec<egui::Rect>,
173 open: Option<D>,
174 foot_size: egui::Vec2,
176 pane: crate::detail::DetailPane,
178 side_column: Option<bool>,
180 loaders: bool,
182}
183
184pub trait ShellApp<D> {
233 fn screen_ui(&mut self, ui: &mut egui::Ui, screen: D, side: &mut Side);
237
238 fn side_ui(&mut self, ui: &mut egui::Ui, screen: D, side: &mut Side) {
242 let _ = (ui, screen, side);
243 }
244
245 fn foot_ui(&mut self, ui: &mut egui::Ui) -> Option<D> {
248 let _ = ui;
249 None
250 }
251
252 fn status_ui(&mut self, ui: &mut egui::Ui) {
254 let _ = ui;
255 }
256
257 fn action(&mut self, id: &'static str, side: &mut Side) {
260 let _ = (id, side);
261 }
262}
263
264#[derive(Debug)]
267pub struct Side {
268 pane: bool,
269 open: bool,
270 asked: Option<bool>,
271}
272
273impl Side {
274 pub fn open(&mut self) {
277 self.asked = Some(true);
278 self.open = self.pane;
279 }
280
281 pub fn close(&mut self) {
283 self.asked = Some(false);
284 self.open = false;
285 }
286
287 pub fn is_open(&self) -> bool {
289 self.open
290 }
291}
292
293struct 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#[derive(Clone, Copy, PartialEq, Debug)]
326enum Item {
327 Screen(usize),
328 Action(usize),
329}
330
331#[derive(Clone, Copy, PartialEq, Debug)]
333struct Slot {
334 item: Item,
335 shown: bool,
336}
337
338#[derive(Clone, Copy, PartialEq, Debug)]
340struct Layout {
341 status: bool,
343 side: bool,
345}
346
347impl Layout {
348 const FULL: Layout = Layout {
350 status: true,
351 side: true,
352 };
353 const BARE: Layout = Layout {
355 status: false,
356 side: false,
357 };
358}
359
360#[derive(Clone, Copy, PartialEq, Debug)]
362enum Row {
363 Footer(usize),
364 Slot(usize),
365}
366
367impl<D: Copy + PartialEq> Shell<D> {
368 pub fn new(destinations: Vec<Destination<D>>) -> Shell<D> {
370 Shell::from_items(destinations.into_iter().map(RailItem::from).collect())
371 }
372
373 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 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 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 pub fn with_rail_order(mut self, order: &RailOrder) -> Shell<D> {
446 self.set_rail_order(order);
447 self
448 }
449
450 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 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 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 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 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 #[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 pub fn current(&self) -> Option<D> {
544 self.open
545 }
546
547 pub fn open(&mut self, id: D) {
549 self.open = Some(id);
550 }
551
552 #[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 #[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 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 #[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 #[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 #[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 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 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 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 if !crate::theme::is_installed(ui.ctx()) {
711 crate::theme::install(ui.ctx(), &crate::theme::ThemeSettings::default());
712 }
713 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 let inner = if compact {
730 full - egui::epaint::MarginF32 {
731 bottom: 0.0,
732 ..safe
733 }
734 } else {
735 full - safe
736 };
737 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 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 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 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 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 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 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 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 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 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 fn shown(&self) -> Vec<usize> {
936 (0..self.slots.len())
937 .filter(|&s| self.slots[s].shown)
938 .collect()
939 }
940
941 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 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 fn more_ui(&mut self, ui: &mut egui::Ui) {
964 let rows = self.menu_rows();
965 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 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 #[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
1070fn 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 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
1099fn 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
1112const BUTTON: f32 = 36.0;
1114
1115fn icon_button(
1118 ui: &mut egui::Ui,
1119 open: bool,
1120 icon: egui::ImageSource<'static>,
1121 side: f32,
1122) -> egui::Response {
1123 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 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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 })
1773 .unwrap();
1774 let ctx = egui::Context::default();
1775 store::attach(&ctx, file.clone());
1776 let mut shell = shell();
1777 frames(&ctx, &mut shell, None);
1778 assert_eq!(shell.rail_order(), saved);
1779
1780 request(
1781 &ctx,
1782 RailOrder {
1783 order: vec!["entries".into(), "summary".into()],
1784 hidden: vec![],
1785 },
1786 );
1787 frames(&ctx, &mut shell, None);
1788 assert_eq!(
1789 file.load().and_then(|s| s.rail).map(|r| r.order),
1790 Some(vec!["entries".to_owned(), "summary".to_owned()])
1791 );
1792 }
1793
1794 #[derive(Default)]
1797 struct Notes {
1798 draft: Option<String>,
1799 screens: Vec<Screen>,
1800 sides: Vec<Screen>,
1801 status: bool,
1802 }
1803
1804 impl ShellApp<Screen> for Notes {
1805 fn screen_ui(&mut self, ui: &mut egui::Ui, screen: Screen, _: &mut Side) {
1806 self.screens.push(screen);
1807 egui::CentralPanel::default().show(ui, |ui| ui.label("screen"));
1808 }
1809
1810 fn side_ui(&mut self, ui: &mut egui::Ui, screen: Screen, side: &mut Side) {
1811 self.sides.push(screen);
1812 if let Some(draft) = &mut self.draft {
1813 ui.text_edit_singleline(draft);
1814 if ui.button("save").clicked() {
1815 self.draft = None;
1816 side.close();
1817 }
1818 }
1819 }
1820
1821 fn status_ui(&mut self, ui: &mut egui::Ui) {
1822 self.status = true;
1823 ui.label("ready");
1824 }
1825
1826 fn action(&mut self, id: &'static str, side: &mut Side) {
1827 assert_eq!(id, "new");
1828 self.draft = Some(String::new());
1829 side.open();
1830 }
1831 }
1832
1833 fn notes_shell() -> Shell<Screen> {
1834 Shell::from_items(vec![
1835 Action::new("new", crate::icons::plus(), "new-entry").into(),
1836 Destination::new(Screen::Entries, crate::icons::settings(), "entries").into(),
1837 Destination::new(Screen::Summary, crate::icons::arrow_left(), "summary").into(),
1838 ])
1839 .with_footer(prefs())
1840 }
1841
1842 fn show(
1844 ctx: &egui::Context,
1845 shell: &mut Shell<Screen>,
1846 app: &mut Notes,
1847 size: [f32; 2],
1848 at: Option<egui::Pos2>,
1849 ) {
1850 let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(size[0], size[1]));
1851 let mut frame = |events: Vec<egui::Event>| {
1852 let input = egui::RawInput {
1853 screen_rect: Some(rect),
1854 events,
1855 ..Default::default()
1856 };
1857 let mut output = ctx.run_ui(input, |ui| shell.show(ui, app));
1858 output.textures_delta.clear();
1859 };
1860 frame(vec![]);
1861 frame(vec![]);
1862 if let Some(pos) = at {
1863 let button = |pressed| egui::Event::PointerButton {
1864 pos,
1865 button: egui::PointerButton::Primary,
1866 pressed,
1867 modifiers: egui::Modifiers::NONE,
1868 };
1869 frame(vec![egui::Event::PointerMoved(pos)]);
1870 frame(vec![button(true)]);
1871 frame(vec![button(false)]);
1872 frame(vec![]);
1873 }
1874 }
1875
1876 #[test]
1877 fn wide_the_side_pane_is_beside_every_screen() {
1878 let ctx = egui::Context::default();
1879 let (mut shell, mut app) = (notes_shell(), Notes::default());
1880 for screen in [Screen::Entries, Screen::Summary, Screen::Prefs] {
1881 app.sides.clear();
1882 shell.open(screen);
1883 show(&ctx, &mut shell, &mut app, SIZES[0], None);
1884 assert_eq!(app.sides.last(), Some(&screen), "{screen:?}");
1885 assert_eq!(app.screens.last(), Some(&screen), "{screen:?}");
1886 }
1887 assert!(app.status, "the status bar is drawn");
1888 }
1889
1890 #[test]
1891 fn an_action_begins_an_entry_in_the_side_pane() {
1892 for (size, in_place) in [(SIZES[0], false), (SIZES[1], true)] {
1894 let ctx = egui::Context::default();
1895 let (mut shell, mut app) = (notes_shell(), Notes::default());
1896 show(&ctx, &mut shell, &mut app, size, None);
1897 let at = shell.action_rects[0].center();
1898 show(&ctx, &mut shell, &mut app, size, Some(at));
1899 assert_eq!(app.draft.as_deref(), Some(""), "{size:?}");
1900 assert_eq!(
1901 shell.current(),
1902 Some(Screen::Entries),
1903 "{size:?}: the screen stays"
1904 );
1905 app.screens.clear();
1906 app.sides.clear();
1907 show(&ctx, &mut shell, &mut app, size, None);
1908 assert!(!app.sides.is_empty(), "{size:?}");
1909 assert_eq!(app.screens.is_empty(), in_place, "{size:?}");
1910 }
1911 }
1912
1913 #[test]
1914 fn every_shell_shows_a_side_pane_and_a_status_bar() {
1915 let ctx = egui::Context::default();
1916 let mut shell = shell();
1917 let mut app = Notes::default();
1918 show(&ctx, &mut shell, &mut app, SIZES[0], None);
1919 assert_eq!(app.sides.last(), Some(&Screen::Entries));
1920 assert!(app.status);
1921 }
1922
1923 #[test]
1924 fn the_deprecated_closures_draw_no_side_pane() {
1925 let ctx = egui::Context::default();
1926 let mut shell = shell();
1927 let mut toggle = None;
1928 crate::test_support::render_in(&ctx, SIZES[0], |ui| {
1929 shell.ui(ui, |ui, _| screen(ui));
1930 toggle = crate::detail::toggle_ui(ui).map(|r| r.rect);
1931 });
1932 assert_eq!(toggle, None, "no pane drawn, so no toggle offered");
1933 }
1934
1935 #[cfg(feature = "serde")]
1936 #[test]
1937 fn the_side_column_is_kept_in_the_store() {
1938 use crate::store::{self, Store};
1939 let dir = tempfile::tempdir().unwrap();
1940 let file = Store::at(dir.path().join(store::FILE_NAME));
1941 let ctx = egui::Context::default();
1942 store::attach(&ctx, file.clone());
1943 let (mut shell, mut app) = (notes_shell(), Notes::default());
1944 show(&ctx, &mut shell, &mut app, SIZES[0], None);
1945 assert_eq!(
1946 file.load().and_then(|s| s.side),
1947 None,
1948 "nothing changed yet"
1949 );
1950 shell.pane.set_column(false);
1951 show(&ctx, &mut shell, &mut app, SIZES[0], None);
1952 assert_eq!(
1953 file.load().and_then(|s| s.side).map(|s| s.column),
1954 Some(false)
1955 );
1956
1957 let ctx = egui::Context::default();
1959 store::attach(&ctx, file);
1960 let (mut shell, mut app) = (notes_shell(), Notes::default());
1961 show(&ctx, &mut shell, &mut app, SIZES[0], None);
1962 assert!(!shell.pane.column());
1963 }
1964
1965 #[test]
1966 fn a_roomy_bar_keeps_full_size_buttons() {
1967 let mut shell = shell().with_footer(prefs());
1968 render(SIZES[1], |ui| shell.ui(ui, |ui, _| screen(ui)));
1969 assert_eq!(shell.cell_rects.len(), 2);
1970 for rect in &shell.cell_rects {
1971 assert_eq!(rect.size(), egui::vec2(BUTTON, BUTTON), "{rect:?}");
1972 }
1973 }
1974
1975 fn with_status(shell: &mut Shell<Screen>, size: [f32; 2]) -> (egui::Rect, bool) {
1978 let (mut foot, mut status) = (egui::Rect::NOTHING, false);
1979 render(size, |ui| {
1980 shell.ui_with_status(
1981 ui,
1982 |ui| {
1983 foot = ui.label("dot").rect;
1984 None
1985 },
1986 |ui| {
1987 status = true;
1988 ui.label("saved");
1989 },
1990 |ui, _| screen(ui),
1991 )
1992 });
1993 (foot, status)
1994 }
1995
1996 #[test]
1997 fn wide_the_status_bar_holds_the_status_and_the_foot() {
1998 let mut shell = shell().with_footer(prefs());
1999 let (foot, status) = with_status(&mut shell, SIZES[0]);
2000 assert!(status);
2001 assert!(foot.bottom() > SIZES[0][1] - 30.0, "{foot:?}");
2003 assert!(foot.left() > SIZES[0][0] / 2.0, "{foot:?}");
2004 assert!(
2006 shell.footer_rects[0].bottom() < foot.top(),
2007 "{:?}",
2008 shell.footer_rects
2009 );
2010 }
2011
2012 #[test]
2013 fn narrow_there_is_no_status_bar_and_the_foot_stays_on_the_bar() {
2014 let mut shell = shell().with_footer(prefs());
2015 let (foot, status) = with_status(&mut shell, SIZES[1]);
2016 assert!(!status);
2017 assert!(foot.is_positive());
2018 let bar_top = shell.footer_rects[0].top();
2019 assert!(foot.top() >= bar_top - 10.0, "{foot:?} beside {bar_top}");
2020 }
2021
2022 #[test]
2023 fn with_nothing_there_is_nothing_to_draw() {
2024 let mut shell: Shell<Screen> = Shell::new(vec![]);
2025 assert_eq!(shell.current(), None);
2026 for size in SIZES {
2027 render(size, |ui| shell.ui(ui, |_, s| panic!("{s:?} drawn")));
2028 }
2029 }
2030}