Skip to main content

egui_charts/ui/widget_bar/
toolbar.rs

1//! Right Edge Icon Bar
2//!
3//! Vertical strip of icon btns on the far right edge of the screen.
4
5use 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/// Section grouping for widget bar icons
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum WidgetBarSection {
19    /// Top section: host entries, then Watchlist, Alerts and ObjectTree
20    Top,
21    /// Bottom section, at the foot of the bar: host entries, then the market and communication
22    /// entries, then a line, then Help
23    Bottom,
24}
25
26/// Built-in entries of the right edge bar, in the order the bar draws them.
27///
28/// [`WidgetBar::default`] lists Alerts, ObjectTree and Help. Add the entry of each panel whose
29/// feed the host supplies to [`WidgetBar::icons`]; entries draw in this order whatever their
30/// order in that list.
31#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
32#[non_exhaustive]
33pub enum RightBarIcon {
34    /// The watchlist and the details of the chart's symbol.
35    Watchlist,
36    /// Alerts panel.
37    Alerts,
38    /// The object tree, with the data window.
39    ObjectTree,
40    /// News highlights panel.
41    Highlights,
42    /// Instrument screener.
43    Screener,
44    /// Ranked instrument lists.
45    Hotlists,
46    /// A treemap of symbols sized and coloured by host-supplied values.
47    MarketHeatmap,
48    /// The economic and corporate event calendars.
49    Calendar,
50    /// News headlines panel.
51    News,
52    /// The alert and event log.
53    Notifications,
54    /// Host chat rooms.
55    Chats,
56    /// The products launcher, drawn as a filled round button.
57    Products,
58    /// Host help action.
59    Help,
60}
61
62impl RightBarIcon {
63    /// Icon representing this value.
64    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    /// Help text for this value.
83    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    /// Get the section this icon belongs to
102    pub fn section(&self) -> WidgetBarSection {
103        if *self <= RightBarIcon::ObjectTree {
104            WidgetBarSection::Top
105        } else {
106            WidgetBarSection::Bottom
107        }
108    }
109}
110
111/// Configuration for the right icon bar (exact dimensions)
112#[derive(Clone, Debug)]
113pub struct WidgetBarConfig {
114    /// Bar width, 45px by default.
115    pub width: f32,
116    /// Square button size, 44px by default.
117    pub btn_size: f32,
118    /// Icon size inside the button
119    pub icon_size: f32,
120    /// Button spacing - 2px gap between buttons
121    pub btn_spacing: f32,
122    /// Hover margin - 3px inset for hover rect
123    pub hover_margin: f32,
124    /// Hover rounding - 8px corners on hover
125    pub hover_rounding: f32,
126    /// Top padding
127    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, // 3px
138            hover_rounding: crate::theme::Radii::default().lg,
139            top_padding: crate::theme::Spacing::default().sm,
140        }
141    }
142}
143
144/// A host-supplied widget entry. Register a content callback with [`WidgetBar::register_panel`],
145/// or render its panel yourself after receiving its id.
146#[derive(Clone)]
147pub struct WidgetBarWidget {
148    /// Stable host id, unique within the bar.
149    pub id: u64,
150    /// Icon drawn using the same size and tint as the built-in entries.
151    pub icon: Icon,
152    /// Tooltip and accessible name.
153    pub tooltip: String,
154    /// Host entries precede the built-in entries in their section.
155    pub section: WidgetBarSection,
156}
157
158/// Action returned by the right icon bar
159#[derive(Clone, Debug, PartialEq)]
160#[non_exhaustive]
161pub enum WidgetBarAction {
162    /// No action requested.
163    None,
164    /// A built-in bar entry was clicked.
165    Clicked(RightBarIcon),
166    /// A host widget was clicked, including when it was toggled closed.
167    WidgetClicked(u64),
168}
169
170/// Right Edge Icon Bar
171pub struct WidgetBar {
172    /// Display and layout options for this component.
173    pub config: WidgetBarConfig,
174    /// Built-in entries shown, drawn in [`RightBarIcon`] order.
175    pub icons: Vec<RightBarIcon>,
176    /// Currently open built-in panel. Clicks toggle this value; the host must keep it
177    /// in step with panels opened or closed elsewhere before calling `show`.
178    pub active_icon: Option<RightBarIcon>,
179    /// Host widget entries, empty until the host supplies them.
180    pub widgets: Vec<WidgetBarWidget>,
181    /// Active host widget. Selecting a built-in entry clears it, and vice versa.
182    pub active_widget: Option<u64>,
183    /// Badge counts keyed by built-in bar entry.
184    pub notification_counts: std::collections::HashMap<RightBarIcon, u32>,
185    /// Controls the host hid.
186    hidden: HashSet<Control>,
187    panels: HashMap<u64, PanelContent>,
188}
189
190/// The host control that hides `icon`, if any.
191fn 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                // Top section
206                RightBarIcon::Alerts,
207                RightBarIcon::ObjectTree,
208                // Bottom section
209                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    /// Create a [`WidgetBar`] with default settings.
223    pub fn new() -> Self {
224        Self::default()
225    }
226
227    /// Use the supplied display and layout options.
228    pub fn with_config(mut self, config: WidgetBarConfig) -> Self {
229        self.config = config;
230        self
231    }
232
233    /// Register a host panel, using the widget tooltip as its title and accessible name.
234    ///
235    /// Replacing an existing id preserves its position in the strip and its active state.
236    /// The callback may capture host state and runs only while this panel is open. Call
237    /// [`Self::show_panel`] after [`Self::show`] and before the central chart panel.
238    /// Hosts own the contents, data sources and persistence.
239    ///
240    /// ```no_run
241    /// use egui_charts::icons::icons;
242    /// use egui_charts::ui::widget_bar::{WidgetBar, WidgetBarSection, WidgetBarWidget};
243    /// let mut bar = WidgetBar::new();
244    /// let mut notes = String::new();
245    /// bar.register_panel(WidgetBarWidget {
246    ///     id: 42, icon: icons::WATCHLIST, tooltip: "Session notes".into(),
247    ///     section: WidgetBarSection::Top,
248    /// }, move |ui| { ui.text_edit_multiline(&mut notes); });
249    /// // In each frame: bar.show(rail_ui); bar.show_panel(root_ui); then show the chart.
250    /// ```
251    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    /// Show the active registered panel beside the strip, with a themed title and close button.
265    ///
266    /// Clicking the same strip entry or the close button closes it. Built-in selections clear
267    /// the active host panel. Widget entries without a registered callback remain host-rendered.
268    /// Panel width is resizable and retained by egui under the widget's stable id.
269    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    /// Shows or hides Alerts, ObjectTree or Help and clears a hidden active entry.
298    /// Other controls are ignored. Hosts close any corresponding panel they render.
299    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    /// Set the badge count for a built-in bar entry.
307    pub fn set_notification_cnt(&mut self, icon: RightBarIcon, count: u32) {
308        self.notification_counts.insert(icon, count);
309    }
310
311    /// Show the bar, keeping the bottom section at the foot and scrolling if it cannot fit.
312    /// Host widget clicks return their id; the host owns the corresponding panel contents.
313    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                            // Host entries sit just above the line, the built-in entries below it.
355                            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                                // Centre Help on the adjacent 38 px bottom row.
391                                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    /// Render a single icon button with hover rect, `filled` drawing the icon cut out of a disc.
436    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        // Inner hover rect with margins
465        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        // Check for clicks
477        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        // Button background on inner rect only - with visibility check
483        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        // Render icon centered in button rect (with visibility check)
502        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                // A 34 px disc in the icon colour, the glyph in the bar's fill.
507                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        // Notification badge (with visibility check)
517        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        // The bar is at the window's right edge, so the tooltip opens to the left, clear of the
547        // next button. Unlike `on_hover_text`, `Tooltip` does not name the button.
548        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    /// Built-in entries draw in their fixed order whatever the order of `icons`, a duplicate draws
710    /// once, the line sits between the second group and Help, and Products is a disc in the icon
711    /// colour with its glyph in the bar's fill.
712    #[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    /// Host entries share the rail's grid and report their ids, without leaving a built-in
802    /// entry selected. Clicks stay with the pressed id, and an active entry toggles closed.
803    #[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                // Hidden built-ins leave no painted button and clear their active panel id.
947                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}