1use crate::icons::{Icon, icons as embedded_icons};
6use crate::localization as l10n;
7use crate::styles::icons as icon_sizes;
8use crate::theme::ThemeExt;
9use crate::tokens::DESIGN_TOKENS;
10use crate::ui::Control;
11use egui::{Pos2, Rect, Sense, Ui, Vec2};
12use std::collections::{HashMap, HashSet};
13
14type PanelContent = Box<dyn FnMut(&mut Ui)>;
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum WidgetBarSection {
19 Top,
21 Bottom,
24}
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
32#[non_exhaustive]
33pub enum RightBarIcon {
34 Watchlist,
36 Alerts,
38 ObjectTree,
40 Highlights,
42 Screener,
44 Hotlists,
46 MarketHeatmap,
48 Calendar,
50 News,
52 Notifications,
54 Chats,
56 Products,
58 Help,
60}
61
62impl RightBarIcon {
63 pub fn icon(&self) -> &'static Icon {
65 match self {
66 RightBarIcon::Watchlist => &embedded_icons::WIDGET_BAR_WATCHLIST,
67 RightBarIcon::Alerts => &embedded_icons::WIDGET_BAR_ALERTS,
68 RightBarIcon::ObjectTree => &embedded_icons::WIDGET_BAR_LAYERS,
69 RightBarIcon::Highlights => &embedded_icons::WIDGET_BAR_HIGHLIGHTS,
70 RightBarIcon::Screener => &embedded_icons::WIDGET_BAR_SCREENER,
71 RightBarIcon::Hotlists => &embedded_icons::WIDGET_BAR_HOTLISTS,
72 RightBarIcon::MarketHeatmap => &embedded_icons::LAYOUT_GRID,
73 RightBarIcon::Calendar => &embedded_icons::WIDGET_BAR_CALENDAR,
74 RightBarIcon::News => &embedded_icons::WIDGET_BAR_NEWS,
75 RightBarIcon::Notifications => &embedded_icons::WIDGET_BAR_NOTIFICATIONS,
76 RightBarIcon::Chats => &embedded_icons::WIDGET_BAR_CHATS,
77 RightBarIcon::Products => &embedded_icons::WIDGET_BAR_PRODUCTS,
78 RightBarIcon::Help => &embedded_icons::WIDGET_BAR_HELP,
79 }
80 }
81
82 pub fn tooltip(&self) -> &'static str {
84 match self {
85 RightBarIcon::Watchlist => "Watchlist and details",
86 RightBarIcon::Alerts => "Alerts",
87 RightBarIcon::ObjectTree => "Object Tree",
88 RightBarIcon::Highlights => "Highlights",
89 RightBarIcon::Screener => "Screener",
90 RightBarIcon::Hotlists => "Hotlists",
91 RightBarIcon::MarketHeatmap => "Market heatmap",
92 RightBarIcon::Calendar => "Calendars",
93 RightBarIcon::News => "News",
94 RightBarIcon::Notifications => "Notifications",
95 RightBarIcon::Chats => "Chats",
96 RightBarIcon::Products => "Products",
97 RightBarIcon::Help => "Help",
98 }
99 }
100
101 pub fn section(&self) -> WidgetBarSection {
103 if *self <= RightBarIcon::ObjectTree {
104 WidgetBarSection::Top
105 } else {
106 WidgetBarSection::Bottom
107 }
108 }
109}
110
111#[derive(Clone, Debug)]
113pub struct WidgetBarConfig {
114 pub width: f32,
116 pub btn_size: f32,
118 pub icon_size: f32,
120 pub btn_spacing: f32,
122 pub hover_margin: f32,
124 pub hover_rounding: f32,
126 pub top_padding: f32,
128}
129
130impl Default for WidgetBarConfig {
131 fn default() -> Self {
132 Self {
133 width: 45.0,
134 btn_size: DESIGN_TOKENS.sizing.toolbar.right_btn_size,
135 icon_size: icon_sizes::TOOLBAR,
136 btn_spacing: crate::theme::Spacing::default().xs,
137 hover_margin: DESIGN_TOKENS.sizing.toolbar.right_hover_margin, hover_rounding: crate::theme::Radii::default().lg,
139 top_padding: crate::theme::Spacing::default().sm,
140 }
141 }
142}
143
144#[derive(Clone)]
147pub struct WidgetBarWidget {
148 pub id: u64,
150 pub icon: Icon,
152 pub tooltip: String,
154 pub section: WidgetBarSection,
156}
157
158#[derive(Clone, Debug, PartialEq)]
160#[non_exhaustive]
161pub enum WidgetBarAction {
162 None,
164 Clicked(RightBarIcon),
166 WidgetClicked(u64),
168}
169
170pub struct WidgetBar {
172 pub config: WidgetBarConfig,
174 pub icons: Vec<RightBarIcon>,
176 pub active_icon: Option<RightBarIcon>,
179 pub widgets: Vec<WidgetBarWidget>,
181 pub active_widget: Option<u64>,
183 pub notification_counts: std::collections::HashMap<RightBarIcon, u32>,
185 hidden: HashSet<Control>,
187 panels: HashMap<u64, PanelContent>,
188}
189
190fn control(icon: RightBarIcon) -> Option<Control> {
192 match icon {
193 RightBarIcon::Alerts => Some(Control::Alerts),
194 RightBarIcon::ObjectTree => Some(Control::ObjectTree),
195 RightBarIcon::Help => Some(Control::Help),
196 _ => None,
197 }
198}
199
200impl Default for WidgetBar {
201 fn default() -> Self {
202 Self {
203 config: WidgetBarConfig::default(),
204 icons: vec![
205 RightBarIcon::Alerts,
207 RightBarIcon::ObjectTree,
208 RightBarIcon::Help,
210 ],
211 active_icon: None,
212 widgets: Vec::new(),
213 active_widget: None,
214 notification_counts: std::collections::HashMap::new(),
215 hidden: HashSet::new(),
216 panels: HashMap::new(),
217 }
218 }
219}
220
221impl WidgetBar {
222 pub fn new() -> Self {
224 Self::default()
225 }
226
227 pub fn with_config(mut self, config: WidgetBarConfig) -> Self {
229 self.config = config;
230 self
231 }
232
233 pub fn register_panel(
252 &mut self,
253 widget: WidgetBarWidget,
254 content: impl FnMut(&mut Ui) + 'static,
255 ) {
256 self.panels.insert(widget.id, Box::new(content));
257 if let Some(existing) = self.widgets.iter_mut().find(|entry| entry.id == widget.id) {
258 *existing = widget;
259 } else {
260 self.widgets.push(widget);
261 }
262 }
263
264 pub fn show_panel(&mut self, ui: &mut Ui) {
270 let Some(id) = self.active_widget else {
271 return;
272 };
273 let Some(widget) = self.widgets.iter().find(|widget| widget.id == id) else {
274 self.active_widget = None;
275 return;
276 };
277 let Some(content) = self.panels.get_mut(&id) else {
278 return;
279 };
280 let mut close = false;
281 egui::Panel::right(ui.scope_id().with(("host_widget_panel", id)))
282 .resizable(true)
283 .default_size(DESIGN_TOKENS.sizing.toolbar.right_panel_width)
284 .show(ui, |ui| {
285 crate::ui_kit::PanelHeader::new(&widget.tooltip).show(ui, |ui| {
286 close = crate::ui_kit::buttons::dialog_close_button(ui).clicked();
287 });
288 if !close {
289 content(ui);
290 }
291 });
292 if close {
293 self.active_widget = None;
294 }
295 }
296
297 pub fn set_control_visible(&mut self, control: Control, visible: bool) {
300 crate::ui::app_state::set_control_visible(&mut self.hidden, control, visible);
301 if !visible && self.active_icon.and_then(self::control) == Some(control) {
302 self.active_icon = None;
303 }
304 }
305
306 pub fn set_notification_cnt(&mut self, icon: RightBarIcon, count: u32) {
308 self.notification_counts.insert(icon, count);
309 }
310
311 pub fn show(&mut self, ui: &mut Ui) -> WidgetBarAction {
314 let theme = ui.active_theme();
315 let defaults = WidgetBarConfig::default();
316 let spacing = if self.config.btn_spacing == defaults.btn_spacing {
317 theme.spacing.xs
318 } else {
319 self.config.btn_spacing
320 };
321 let padding = if self.config.top_padding == defaults.top_padding {
322 theme.spacing.sm
323 } else {
324 self.config.top_padding
325 };
326 let mut action = WidgetBarAction::None;
327 ui.scope(|ui| {
328 ui.spacing_mut().scroll.floating = true;
329 egui::ScrollArea::vertical()
330 .id_salt("widget_bar")
331 .auto_shrink([false, false])
332 .show(ui, |ui| {
333 let mut shown: Vec<RightBarIcon> = self
334 .icons
335 .iter()
336 .copied()
337 .filter(|&i| control(i).is_none_or(|c| !self.hidden.contains(&c)))
338 .collect();
339 shown.sort();
340 shown.dedup();
341 let count = shown.len() + self.widgets.len();
342 let pitch = self.config.btn_size + spacing;
343 let height = ui
344 .available_height()
345 .max(padding + count as f32 * pitch + ui.active_theme().spacing.lg);
346 let (rect, _) = ui
347 .allocate_exact_size(Vec2::new(self.config.width, height), Sense::hover());
348 let painter = ui.painter().clone();
349 painter.rect_filled(rect, ui.active_theme().radius.md, ui.visuals().panel_fill);
350 let mut y = rect.top() + padding;
351 for section in [WidgetBarSection::Top, WidgetBarSection::Bottom] {
352 let built_in = shown.iter().filter(|i| i.section() == section).count();
353 if section == WidgetBarSection::Bottom {
354 let count = built_in
356 + self.widgets.iter().filter(|w| w.section == section).count();
357 if count == 0 {
358 continue;
359 }
360 y = y.max(
361 rect.bottom() - count as f32 * pitch - ui.active_theme().spacing.md,
362 );
363 }
364 for widget in self.widgets.iter().filter(|w| w.section == section) {
365 let id = ui.scope_id().with(("host_widget", widget.id));
366 let response = self.render_icon_button(
367 ui,
368 id,
369 &widget.icon,
370 &widget.tooltip,
371 self.active_widget == Some(widget.id),
372 0,
373 false,
374 rect.center().x,
375 y,
376 );
377 if response.clicked() {
378 action = WidgetBarAction::WidgetClicked(widget.id);
379 }
380 y += pitch;
381 }
382 for icon in shown.iter().filter(|i| i.section() == section) {
383 if *icon == RightBarIcon::Help {
384 let sep = Rect::from_min_size(
385 Pos2::new(rect.left() + 8.0, y),
386 Vec2::new(self.config.width - 16.0, 1.0),
387 );
388 painter.rect_filled(sep, 0.0, ui.active_theme().ui.border);
389 y += 1.0 + ui.active_theme().spacing.md;
390 y = y.max(rect.bottom() - 19.0 - self.config.btn_size / 2.0);
392 }
393 let id = ui.scope_id().with(("built_in", icon));
394 let response = self.render_icon_button(
395 ui,
396 id,
397 icon.icon(),
398 icon.tooltip(),
399 self.active_icon == Some(*icon),
400 self.notification_counts.get(icon).copied().unwrap_or(0),
401 *icon == RightBarIcon::Products,
402 rect.center().x,
403 y,
404 );
405 if response.clicked() {
406 action = WidgetBarAction::Clicked(*icon);
407 }
408 y += pitch;
409 }
410 }
411 });
412 });
413 match action {
414 WidgetBarAction::Clicked(icon) => {
415 self.active_icon = if self.active_icon == Some(icon) {
416 None
417 } else {
418 Some(icon)
419 };
420 self.active_widget = None;
421 }
422 WidgetBarAction::WidgetClicked(id) => {
423 self.active_widget = if self.active_widget == Some(id) {
424 None
425 } else {
426 Some(id)
427 };
428 self.active_icon = None;
429 }
430 WidgetBarAction::None => {}
431 }
432 action
433 }
434
435 fn render_icon_button(
437 &self,
438 ui: &mut Ui,
439 id: egui::Id,
440 icon: &Icon,
441 tooltip: &str,
442 is_active: bool,
443 count: u32,
444 filled: bool,
445 center_x: f32,
446 y: f32,
447 ) -> egui::Response {
448 let painter = ui.painter().clone();
449 let theme = ui.active_theme();
450 let rounding = if self.config.hover_rounding == WidgetBarConfig::default().hover_rounding {
451 theme.radius.lg
452 } else {
453 self.config.hover_rounding
454 };
455 let icon_color = theme.ui.text;
456 let hover_bg = theme.ui.btn_bg_hover;
457 let pressed_bg = theme.ui.btn_bg_active;
458 let badge_color = theme.ui.danger_bg;
459 let btn_rect = Rect::from_center_size(
460 Pos2::new(center_x, y + self.config.btn_size / 2.0),
461 Vec2::splat(self.config.btn_size),
462 );
463
464 let hover_rect = Rect::from_min_max(
466 Pos2::new(
467 btn_rect.min.x + self.config.hover_margin,
468 btn_rect.min.y + self.config.hover_margin,
469 ),
470 Pos2::new(
471 btn_rect.max.x - self.config.hover_margin,
472 btn_rect.max.y - self.config.hover_margin,
473 ),
474 );
475
476 let btn_res = ui.interact(btn_rect, id, Sense::click());
478 crate::styles::focus::draw_focus_ring(ui, &btn_res, rounding);
479 let is_hovered = btn_res.hovered();
480 let is_pressed = btn_res.is_pointer_button_down_on();
481
482 if ui.is_rect_visible(hover_rect) {
484 if is_pressed {
485 painter.rect_filled(hover_rect, rounding, pressed_bg);
486 } else if is_active {
487 painter.rect_filled(
488 hover_rect,
489 rounding,
490 if theme.dark {
491 theme.ui.btn_bg_active
492 } else {
493 theme.ui.panel_bg_secondary
494 },
495 );
496 } else if is_hovered {
497 painter.rect_filled(hover_rect, rounding, hover_bg);
498 }
499 }
500
501 let icon_rect =
503 Rect::from_center_size(btn_rect.center(), Vec2::splat(self.config.icon_size));
504 if ui.is_rect_visible(icon_rect) {
505 let tint = if filled {
506 painter.circle_filled(btn_rect.center(), 17.0, icon_color);
508 ui.visuals().panel_fill
509 } else {
510 icon_color
511 };
512 icon.as_image_tinted(Vec2::splat(self.config.icon_size), tint)
513 .paint_at(ui, icon_rect);
514 }
515
516 if count > 0 {
518 let badge_radius = ui.active_theme().spacing.md;
519 let badge_pos = Pos2::new(btn_rect.max.x - badge_radius, btn_rect.min.y + badge_radius);
520 let badge_rect = Rect::from_center_size(badge_pos, Vec2::splat(badge_radius * 2.0));
521
522 if ui.is_rect_visible(badge_rect) {
523 painter.circle_filled(badge_pos, badge_radius, badge_color);
524
525 let cnt_text = if count > 9 {
526 "9+".to_string()
527 } else {
528 count.to_string()
529 };
530 let badge_galley = painter.layout_no_wrap(
531 l10n::text(ui.ctx(), cnt_text),
532 egui::FontId::proportional(ui.active_theme().text.tiny),
533 ui.active_theme().ui.danger_text,
534 );
535 painter.galley(
536 Pos2::new(
537 badge_pos.x - badge_galley.size().x / 2.0,
538 badge_pos.y - badge_galley.size().y / 2.0,
539 ),
540 badge_galley,
541 egui::Color32::TRANSPARENT,
542 );
543 }
544 }
545
546 let translated_label = l10n::text(ui.ctx(), tooltip);
549 btn_res.widget_info(|| {
550 egui::WidgetInfo::selected(
551 egui::Role::Button,
552 btn_res.enabled(),
553 is_active,
554 &translated_label,
555 )
556 });
557 let mut tip = egui::Tooltip::for_enabled(&btn_res);
558 tip.popup = tip.popup.align(egui::RectAlign::LEFT);
559 tip.show(|ui| ui.label(l10n::text(ui.ctx(), tooltip)));
560
561 btn_res
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568 use crate::theme::{Theme, apply_to_egui};
569 use egui::{Context, Event, PointerButton, RawInput, Shape};
570
571 #[test]
572 fn registered_panels_render_toggle_replace_and_close() {
573 for (theme, width) in [
574 (Theme::light(), 320.0),
575 (Theme::light(), 800.0),
576 (Theme::dark(), 320.0),
577 (Theme::dark(), 800.0),
578 ] {
579 let ctx = egui::Context::default();
580 egui_extras::install_image_loaders(&ctx);
581 crate::theme::apply_to_egui(&ctx, &theme);
582 let calls = std::rc::Rc::new(std::cell::Cell::new(0));
583 let mut bar = WidgetBar::new();
584 let widget = WidgetBarWidget {
585 id: 42,
586 icon: embedded_icons::WATCHLIST,
587 tooltip: "Host notes".into(),
588 section: WidgetBarSection::Top,
589 };
590 let captured = calls.clone();
591 bar.register_panel(widget.clone(), move |ui| {
592 captured.set(captured.get() + 1);
593 ui.label("Host panel content");
594 });
595 let paint = |bar: &mut WidgetBar, events| {
596 let output = ctx.run_ui(
597 RawInput {
598 screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(width, 600.0))),
599 events,
600 ..Default::default()
601 },
602 |ui| {
603 egui::Panel::right("rail")
604 .exact_size(45.0)
605 .frame(egui::Frame::NONE)
606 .show(ui, |ui| {
607 bar.show(ui);
608 });
609 bar.show_panel(ui);
610 egui::CentralPanel::default().show(ui, |ui| {
611 ui.label("Chart area");
612 });
613 },
614 );
615 let text = output
616 .shapes
617 .iter()
618 .filter_map(|shape| match &shape.shape {
619 Shape::Text(text) => Some(text.galley.text().to_owned()),
620 _ => None,
621 })
622 .collect::<Vec<_>>();
623 let icons = output
624 .shapes
625 .iter()
626 .filter_map(|shape| match &shape.shape {
627 Shape::Rect(rect) if rect.brush.is_some() => Some(rect.rect.center()),
628 _ => None,
629 })
630 .collect::<Vec<_>>();
631 output.drop_without_applying_deltas();
632 (text, icons)
633 };
634 let click = |bar: &mut WidgetBar, pos| {
635 paint(bar, vec![Event::PointerMoved(pos)]);
636 for pressed in [true, false] {
637 paint(
638 bar,
639 vec![Event::PointerButton {
640 pos,
641 button: PointerButton::Primary,
642 pressed,
643 modifiers: Default::default(),
644 }],
645 );
646 }
647 };
648 paint(&mut bar, vec![]);
649 let (_, icons) = paint(&mut bar, vec![]);
650 assert_eq!(calls.get(), 0, "closed callbacks do not run");
651 let host = icons[0];
652 let alerts = icons[1];
653 click(&mut bar, host);
654 let (text, _) = paint(&mut bar, vec![]);
655 assert!(text.iter().any(|label| label == "Host panel content"));
656 assert!(calls.get() > 0);
657 assert_eq!((bar.active_widget, bar.active_icon), (Some(42), None));
658 click(&mut bar, host);
659 let count = calls.get();
660 assert!(
661 !paint(&mut bar, vec![])
662 .0
663 .iter()
664 .any(|label| label == "Host panel content")
665 );
666 assert_eq!(calls.get(), count);
667 click(&mut bar, host);
668 click(&mut bar, alerts);
669 assert_eq!(
670 (bar.active_widget, bar.active_icon),
671 (None, Some(RightBarIcon::Alerts))
672 );
673 let count = calls.get();
674 paint(&mut bar, vec![]);
675 assert_eq!(calls.get(), count);
676 click(&mut bar, host);
677 bar.register_panel(widget, |ui| {
678 ui.label("Replacement content");
679 });
680 assert_eq!(
681 bar.widgets.len(),
682 1,
683 "replacement retains its strip position"
684 );
685 assert_eq!(bar.active_widget, Some(42));
686 let (text, icons) = paint(&mut bar, vec![]);
687 assert!(text.iter().any(|label| label == "Replacement content"));
688 assert!(!text.iter().any(|label| label == "Host panel content"));
689 let close = *icons.last().expect("panel close icon");
690 assert!(close.x >= 0.0 && close.x < width, "close stays reachable");
691 click(&mut bar, close);
692 assert_eq!(bar.active_widget, None);
693 assert!(
694 !paint(&mut bar, vec![])
695 .0
696 .iter()
697 .any(|label| label == "Replacement content")
698 );
699 bar.active_widget = Some(42);
700 bar.widgets.clear();
701 paint(&mut bar, vec![]);
702 assert_eq!(
703 bar.active_widget, None,
704 "removing metadata closes the panel"
705 );
706 }
707 }
708
709 #[test]
713 fn built_in_entries_keep_their_order_with_help_below_the_line() {
714 use RightBarIcon::*;
715 let all = [
716 Watchlist,
717 Alerts,
718 ObjectTree,
719 Highlights,
720 Screener,
721 Hotlists,
722 Calendar,
723 News,
724 Notifications,
725 Chats,
726 Products,
727 Help,
728 ];
729 for theme in [Theme::light(), Theme::dark()] {
730 let ctx = Context::default();
731 egui_extras::install_image_loaders(&ctx);
732 apply_to_egui(&ctx, &theme);
733 let mut bar = WidgetBar::new();
734 bar.icons = all.iter().rev().copied().chain([Alerts]).collect();
735 let frame = |bar: &mut WidgetBar, events| {
736 let mut action = WidgetBarAction::None;
737 let mut output = ctx.run_ui(
738 RawInput {
739 screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(45.0, 900.0))),
740 events,
741 ..Default::default()
742 },
743 |ui| action = bar.show(ui),
744 );
745 let shapes = std::mem::take(&mut output.shapes);
746 output.drop_without_applying_deltas();
747 (action, shapes)
748 };
749 frame(&mut bar, vec![]);
750 let (_, shapes) = frame(&mut bar, vec![]);
751 let icons: Vec<_> = shapes
752 .iter()
753 .filter_map(|s| match &s.shape {
754 Shape::Rect(r) if r.brush.is_some() => Some((r.rect, r.fill)),
755 _ => None,
756 })
757 .collect();
758 assert_eq!(icons.len(), all.len());
759 let mut clicked = Vec::new();
760 for (rect, _) in &icons {
761 let pos = rect.center();
762 frame(&mut bar, vec![Event::PointerMoved(pos)]);
763 for pressed in [true, false] {
764 let (action, _) = frame(
765 &mut bar,
766 vec![Event::PointerButton {
767 pos,
768 button: PointerButton::Primary,
769 pressed,
770 modifiers: Default::default(),
771 }],
772 );
773 if let WidgetBarAction::Clicked(icon) = action {
774 clicked.push(icon);
775 }
776 }
777 }
778 assert_eq!(clicked, all);
779 let lines: Vec<_> = shapes
780 .iter()
781 .filter_map(|s| match &s.shape {
782 Shape::Rect(r) if r.rect.height() == 1.0 => Some(r.rect),
783 _ => None,
784 })
785 .collect();
786 let (products, help) = (icons[10].0, icons[11].0);
787 assert!(
788 lines.len() == 1
789 && products.bottom() < lines[0].top()
790 && lines[0].bottom() < help.top(),
791 "{lines:?}"
792 );
793 assert_eq!(icons[10].1, ctx.global_style().visuals.panel_fill);
794 assert!(shapes.iter().any(|s| matches!(&s.shape, Shape::Circle(c)
795 if c.radius == 17.0
796 && c.center.distance(products.center()) <= 1.0
797 && c.fill == theme.ui.text)));
798 }
799 }
800
801 #[test]
804 fn host_widgets_share_the_grid_and_report_their_own_clicks() {
805 for theme in [Theme::light(), Theme::dark(), Theme::high_contrast()] {
806 for height in [900.0, 450.0] {
807 let ctx = Context::default();
808 egui_extras::install_image_loaders(&ctx);
809 apply_to_egui(&ctx, &theme);
810 let mut bar = WidgetBar::new();
811 assert_eq!(bar.config.width, 45.0);
812 bar.widgets = vec![
813 WidgetBarWidget {
814 id: 42,
815 icon: embedded_icons::WATCHLIST,
816 tooltip: "Watchlist".into(),
817 section: WidgetBarSection::Top,
818 },
819 WidgetBarWidget {
820 id: 7,
821 icon: embedded_icons::WIDGET_BAR_CALENDAR,
822 tooltip: "Calendar".into(),
823 section: WidgetBarSection::Bottom,
824 },
825 ];
826 let button = |pos, pressed| Event::PointerButton {
827 pos,
828 button: PointerButton::Primary,
829 pressed,
830 modifiers: Default::default(),
831 };
832 let frame = |bar: &mut WidgetBar, events| {
833 let mut origin = Pos2::ZERO;
834 let mut action = WidgetBarAction::None;
835 let mut output = ctx.run_ui(
836 RawInput {
837 screen_rect: Some(Rect::from_min_size(
838 Pos2::ZERO,
839 Vec2::new(45.0, height),
840 )),
841 events,
842 ..Default::default()
843 },
844 |ui| {
845 origin = ui.cursor().min;
846 action = bar.show(ui);
847 },
848 );
849 let shapes = std::mem::take(&mut output.shapes);
850 output.drop_without_applying_deltas();
851 (action, shapes, origin)
852 };
853 frame(&mut bar, vec![]);
854 let (_, shapes, origin) = frame(&mut bar, vec![]);
855 let icons: Vec<_> = shapes
856 .iter()
857 .filter_map(|s| match &s.shape {
858 Shape::Rect(r) if r.brush.is_some() => Some(r.rect),
859 _ => None,
860 })
861 .collect();
862 assert_eq!(icons.len(), 5);
863 for icon in &icons {
864 assert_eq!(icon.size(), Vec2::splat(28.0));
865 assert!(
866 (icon.center().x - origin.x - 22.5).abs() <= 0.5,
867 "icon {icon:?}, origin {origin:?}"
868 );
869 assert!(icon.top() >= 0.0 && icon.bottom() <= height);
870 }
871 assert_eq!(icons.last().unwrap().center().y, height - origin.y - 19.0);
872 assert_eq!(icons[0].center().y, origin.y + 26.0);
873 assert_eq!(icons[1].center().y - icons[0].center().y, 46.0);
874 let line = shapes
875 .iter()
876 .find_map(|s| match &s.shape {
877 Shape::Rect(r) if r.rect.height() == 1.0 => Some(r.rect),
878 _ => None,
879 })
880 .expect("bottom section line");
881 assert!(
882 icons[3].bottom() < line.top() && line.bottom() < icons[4].top(),
883 "host entry above the line, Help below it: {line:?}"
884 );
885 bar.active_icon = Some(RightBarIcon::Alerts);
886 bar.set_notification_cnt(RightBarIcon::Alerts, 3);
887 let pos = icons[1].center();
888 let selected_fill = if theme.dark {
889 theme.ui.btn_bg_active
890 } else {
891 theme.ui.panel_bg_secondary
892 };
893 for (events, fill) in [
894 (vec![], selected_fill),
895 (vec![Event::PointerMoved(pos)], selected_fill),
896 (vec![button(pos, true)], theme.ui.btn_bg_active),
897 ] {
898 let (_, shapes, _) = frame(&mut bar, events);
899 assert!(
900 shapes
901 .iter()
902 .any(|s| matches!(&s.shape, Shape::Rect(r) if r.fill == fill)),
903 "active/pressed rail fill"
904 );
905 assert!(
906 shapes.iter().any(
907 |s| matches!(&s.shape, Shape::Circle(c) if c.fill == theme.ui.danger_bg)
908 ),
909 "badge colour pair"
910 );
911 }
912 frame(&mut bar, vec![button(pos, false)]);
913 bar.active_icon = None;
914 for (index, action, active_icon, active_widget) in [
915 (0, WidgetBarAction::WidgetClicked(42), None, Some(42)),
916 (
917 1,
918 WidgetBarAction::Clicked(RightBarIcon::Alerts),
919 Some(RightBarIcon::Alerts),
920 None,
921 ),
922 (3, WidgetBarAction::WidgetClicked(7), None, Some(7)),
923 (3, WidgetBarAction::WidgetClicked(7), None, None),
924 ] {
925 let pos = icons[index].center();
926 frame(&mut bar, vec![Event::PointerMoved(pos)]);
927 frame(&mut bar, vec![button(pos, true)]);
928 let (got, ..) = frame(&mut bar, vec![button(pos, false)]);
929 assert_eq!(got, action);
930 assert_eq!(
931 (bar.active_icon, bar.active_widget),
932 (active_icon, active_widget)
933 );
934 }
935 let pos = icons[0].center();
936 frame(&mut bar, vec![Event::PointerMoved(pos)]);
937 frame(&mut bar, vec![button(pos, true)]);
938 bar.widgets[0].id = 99;
939 let (action, ..) = frame(&mut bar, vec![button(pos, false)]);
940 assert_eq!(
941 action,
942 WidgetBarAction::None,
943 "a replacement is not the pressed entry"
944 );
945
946 bar.widgets.clear();
948 for (icon, control) in [
949 (RightBarIcon::Alerts, Control::Alerts),
950 (RightBarIcon::ObjectTree, Control::ObjectTree),
951 (RightBarIcon::Help, Control::Help),
952 ] {
953 bar.active_icon = Some(icon);
954 bar.set_control_visible(control, false);
955 assert_eq!(bar.active_icon, None);
956 }
957 let (_, shapes, _) = frame(&mut bar, vec![]);
958 assert!(
959 !shapes
960 .iter()
961 .any(|s| matches!(&s.shape, Shape::Rect(r) if r.brush.is_some()))
962 );
963 for control in [Control::Alerts, Control::ObjectTree, Control::Help] {
964 bar.set_control_visible(control, true);
965 }
966 bar.widgets.extend((100..120).map(|id| WidgetBarWidget {
967 id,
968 icon: embedded_icons::WATCHLIST,
969 tooltip: format!("Panel {id}"),
970 section: WidgetBarSection::Top,
971 }));
972 frame(&mut bar, vec![]);
973 frame(
974 &mut bar,
975 vec![Event::PointerMoved(Pos2::new(22.0, height / 2.0))],
976 );
977 frame(
978 &mut bar,
979 vec![Event::MouseWheel {
980 unit: egui::MouseWheelUnit::Point,
981 delta: Vec2::new(0.0, -2000.0),
982 phase: egui::TouchPhase::Move,
983 source: egui::MouseWheelSource::Wheel,
984 modifiers: Default::default(),
985 }],
986 );
987 for _ in 0..30 {
988 frame(&mut bar, vec![]);
989 }
990 let (_, shapes, _) = frame(&mut bar, vec![]);
991 let help = shapes
992 .iter()
993 .rev()
994 .find_map(|s| match &s.shape {
995 Shape::Rect(r) if r.brush.is_some() => Some(r.rect),
996 _ => None,
997 })
998 .expect("help remains reachable by scrolling");
999 assert!(
1000 help.top() >= 0.0 && help.bottom() <= height,
1001 "help: {help:?}"
1002 );
1003 let pos = help.center();
1004 frame(&mut bar, vec![Event::PointerMoved(pos)]);
1005 for pressed in [true, false] {
1006 let (action, ..) = frame(&mut bar, vec![button(pos, pressed)]);
1007 if !pressed {
1008 assert_eq!(action, WidgetBarAction::Clicked(RightBarIcon::Help));
1009 }
1010 }
1011 }
1012 }
1013 }
1014}