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