Skip to main content

egui_charts/ui/floating_toolbar/
toolbar.rs

1//! Floating Selection Toolbar Implementation
2//!
3//! Floating toolbar for object customization.
4
5use crate::icons::icons;
6use crate::localization as l10n;
7use crate::model::LineStyle;
8use crate::theme::ThemeExt;
9use crate::tokens::DESIGN_TOKENS;
10use crate::ui::context_menu::drawing::{DrawingContextMenu, DrawingContextMenuAction};
11use crate::ui_kit::buttons::{ButtonSize, IconButton};
12use crate::ui_kit::ext::UiExt;
13use crate::ui_kit::{ColorPicker, ColorPickerState};
14use crate::widget::Chart;
15use egui::{Color32, Pos2, Rect, Sense, Ui, Vec2};
16
17/// Action that can be triggered from the floating toolbar
18#[derive(Clone, Debug, PartialEq)]
19#[non_exhaustive]
20pub enum FloatingToolbarAction {
21    /// Change a color slot (index, new color)
22    ChangeColor(usize, Color32),
23    /// Change the stroke width in pixels.
24    ChangeWidth(f32),
25    /// Change the stroke pattern.
26    ChangeStyle(LineStyle),
27    /// An entry from the drawing context menu.
28    DrawingMenu(DrawingContextMenuAction),
29    /// Open settings dialog
30    OpenSettings,
31    /// Add an alert on the selected object
32    AddAlert,
33    /// Duplicate the selected object
34    Duplicate,
35    /// Delete the selected object
36    Delete,
37    /// Toggle lock state
38    ToggleLock,
39    /// Toggle visibility
40    ToggleVisibility,
41    /// Close the toolbar
42    Close,
43}
44
45/// Configuration for color slots shown in toolbar
46#[derive(Clone, Debug)]
47pub struct ColorSlot {
48    /// Text displayed by the component.
49    pub label: &'static str,
50    /// Color.
51    pub color: Color32,
52}
53
54/// Floating selection toolbar state
55#[derive(Clone, Debug)]
56pub struct FloatingSelectionToolbar {
57    /// Whether toolbar is visible
58    visible: bool,
59    /// Current position
60    position: Pos2,
61    /// Color slots to display (mutable for color picker)
62    color_slots: Vec<ColorSlot>,
63    /// Palette state per colour slot
64    pickers: Vec<ColorPickerState>,
65    /// Line width and style of the selected drawing
66    stroke: Option<(f32, LineStyle)>,
67    /// Symbol and id of the drawing the toolbar follows
68    selected: Option<(String, usize)>,
69    /// Whether a custom colour gesture is still open as one undo step
70    colour_drag: bool,
71    /// The "More" menu
72    menu: DrawingContextMenu,
73    /// Whether object is locked
74    is_locked: bool,
75    /// Whether object is visible
76    is_visible: bool,
77    /// Whether to offer an alert on the object
78    alert_available: bool,
79    /// Last rendered rect (for hit testing)
80    last_rect: Rect,
81}
82
83impl Default for FloatingSelectionToolbar {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89impl FloatingSelectionToolbar {
90    /// Create a new hidden floating selection toolbar at the default position
91    pub fn new() -> Self {
92        Self {
93            visible: false,
94            position: Pos2::new(
95                DESIGN_TOKENS.sizing.floating_toolbar.default_x,
96                DESIGN_TOKENS.sizing.floating_toolbar.default_y,
97            ),
98            color_slots: Vec::new(),
99            pickers: Vec::new(),
100            stroke: None,
101            selected: None,
102            colour_drag: false,
103            menu: DrawingContextMenu::new(),
104            is_locked: false,
105            is_visible: true,
106            alert_available: false,
107            last_rect: Rect::NOTHING,
108        }
109    }
110
111    /// Show the toolbar at a specific position
112    pub fn show_at(&mut self, position: Pos2) {
113        self.visible = true;
114        self.position = position;
115    }
116
117    /// Hide the toolbar
118    pub fn hide(&mut self) {
119        self.visible = false;
120        self.selected = None;
121        self.pickers.clear();
122        self.menu.close();
123    }
124
125    /// Check if toolbar is visible
126    #[allow(clippy::misnamed_getters)]
127    pub fn is_visible(&self) -> bool {
128        self.visible
129    }
130
131    /// Check if a point is inside the toolbar area (for hit testing)
132    pub fn contains_point(&self, point: Pos2) -> bool {
133        self.visible && self.last_rect.contains(point)
134    }
135
136    /// Set the color slots to display
137    pub fn set_colors(&mut self, slots: Vec<ColorSlot>) {
138        self.color_slots = slots;
139    }
140
141    /// Get mutable reference to color slots (for updating from outside)
142    pub fn color_slots_mut(&mut self) -> &mut Vec<ColorSlot> {
143        &mut self.color_slots
144    }
145
146    /// Set locked state
147    pub fn set_locked(&mut self, locked: bool) {
148        self.is_locked = locked;
149    }
150
151    /// Set visibility state
152    pub fn set_object_visible(&mut self, visible: bool) {
153        self.is_visible = visible;
154    }
155
156    /// Show the Add alert button, for an object an alert can follow, such as a drawing with
157    /// [`alert_levels`](crate::drawings::Drawing::alert_levels). Hidden by default.
158    pub fn set_alert_available(&mut self, available: bool) {
159        self.alert_available = available;
160    }
161
162    /// Follow the selected drawing, applying quick style edits as undoable chart actions.
163    /// Returns menu actions for the host's dialogs, clipboard and drawing commands.
164    /// Call each frame, including after deselection, to finish a continuous colour edit.
165    pub fn show_and_apply(
166        &mut self,
167        ui: &mut Ui,
168        chart: &mut Chart,
169    ) -> Option<DrawingContextMenuAction> {
170        if self.colour_drag && !ui.input(|i| i.pointer.any_down()) {
171            chart.user_action(|chart| chart.drawings_mut().end_drag_handle());
172            self.colour_drag = false;
173        }
174        let drawing = chart.drawings().sel_drawing().and_then(|id| {
175            chart
176                .drawings()
177                .drawings
178                .iter()
179                .find(|d| d.id == id && d.visible && d.completed)
180        });
181        let Some(drawing) = drawing else {
182            self.hide();
183            return None;
184        };
185        let id = drawing.id;
186        let selected = (chart.symbol().to_owned(), id);
187        if self.selected.as_ref() != Some(&selected) {
188            self.hide();
189            let anchor = drawing
190                .points
191                .first()
192                .copied()
193                .unwrap_or(chart.get_rendered_price_rect().center());
194            // Sits just above and right of the first anchor, clear of the line it styles.
195            self.show_at(anchor + Vec2::new(12.0, -52.0));
196            self.selected = Some(selected);
197        }
198        self.set_locked(drawing.locked);
199        self.set_object_visible(drawing.visible);
200        self.set_colors(vec![ColorSlot {
201            label: if drawing.tool_type.is_text_annotation() {
202                "Text color"
203            } else {
204                "Line color"
205            },
206            color: drawing.color32(),
207        }]);
208        if drawing.tool_type.supports_fill() {
209            self.color_slots.push(ColorSlot {
210                label: "Fill color",
211                color: drawing.fill_color32().unwrap_or(Color32::TRANSPARENT),
212            });
213        }
214        self.stroke = Some((drawing.stroke_width, drawing.line_style));
215        // Refresh context without reopening the menu or resetting its dismiss state.
216        if !self.menu.is_open() {
217            self.menu.open_on(ui.ctx(), chart, self.position, id);
218            self.menu.close();
219        }
220        let action = self.show_with_shortcuts(ui, chart.shortcuts())?;
221        match action {
222            FloatingToolbarAction::ChangeColor(slot, color) => {
223                let down = ui.input(|i| i.pointer.any_down());
224                chart.user_action(|chart| {
225                    let edit = |drawing: &mut crate::drawings::Drawing| {
226                        if slot == 0 {
227                            drawing.color = color.to_srgba_unmultiplied();
228                        } else {
229                            drawing.fill_color = Some(color.to_srgba_unmultiplied());
230                        }
231                    };
232                    if down {
233                        chart.drawings_mut().drag_edit_drawing(id, edit);
234                    } else {
235                        chart.drawings_mut().edit_drawing(id, edit);
236                    }
237                });
238                self.colour_drag = down;
239            }
240            FloatingToolbarAction::ChangeWidth(width) => chart.user_action(|chart| {
241                chart
242                    .drawings_mut()
243                    .edit_drawing(id, |d| d.stroke_width = width);
244            }),
245            FloatingToolbarAction::ChangeStyle(style) => chart.user_action(|chart| {
246                chart
247                    .drawings_mut()
248                    .edit_drawing(id, |d| d.line_style = style);
249            }),
250            FloatingToolbarAction::OpenSettings => return Some(DrawingContextMenuAction::Edit),
251            FloatingToolbarAction::Duplicate => return Some(DrawingContextMenuAction::Clone),
252            FloatingToolbarAction::ToggleLock => return Some(DrawingContextMenuAction::Lock),
253            FloatingToolbarAction::ToggleVisibility => return Some(DrawingContextMenuAction::Hide),
254            FloatingToolbarAction::Delete => return Some(DrawingContextMenuAction::Delete),
255            FloatingToolbarAction::AddAlert => {
256                return self
257                    .menu
258                    .alert
259                    .clone()
260                    .map(|alert| DrawingContextMenuAction::AddAlert(Box::new(alert)));
261            }
262            FloatingToolbarAction::DrawingMenu(action) => return Some(action),
263            _ => {}
264        }
265        ui.ctx().request_repaint();
266        None
267    }
268
269    /// Render the toolbar.
270    pub fn show(&mut self, ui: &mut Ui) -> Option<FloatingToolbarAction> {
271        self.show_with_shortcuts(ui, &crate::widget::ShortcutMap::default())
272    }
273
274    fn show_with_shortcuts(
275        &mut self,
276        ui: &mut Ui,
277        shortcuts: &crate::widget::ShortcutMap,
278    ) -> Option<FloatingToolbarAction> {
279        if !self.visible {
280            return None;
281        }
282        let bounds = ui.ctx().content_rect();
283        let size = if self.last_rect.is_finite() {
284            self.last_rect.size()
285        } else {
286            // Typical width until egui has measured the bar once.
287            Vec2::new(240.0, DESIGN_TOKENS.sizing.floating_toolbar.height)
288        };
289        self.position.x = self
290            .position
291            .x
292            .min(bounds.right() - size.x)
293            .max(bounds.left());
294        self.position.y = self
295            .position
296            .y
297            .min(bounds.bottom() - size.y)
298            .max(bounds.top());
299        self.pickers
300            .resize_with(self.color_slots.len(), ColorPickerState::default);
301        let mut action = None;
302        let area = egui::Area::new(egui::Id::unique("floating_selection_toolbar"))
303            .fixed_pos(self.position)
304            .default_size(size)
305            .movable(false)
306            .constrain_to(bounds)
307            .order(egui::Order::Foreground)
308            .show(ui.ctx(), |ui| {
309                egui::Frame::popup(ui.style())
310                    .corner_radius(ui.active_theme().radius.md)
311                    .inner_margin(egui::Margin::symmetric(
312                        ui.active_theme().spacing.md as i8,
313                        ui.active_theme().spacing.sm as i8,
314                    ))
315                    .show(ui, |ui| {
316                        ui.horizontal(|ui| {
317                            ui.spacing_mut().item_spacing.x = ui.active_theme().spacing.sm;
318                            let drag = Self::render_drag_handle(ui);
319                            if drag.dragged() {
320                                self.position += drag.drag_delta();
321                            }
322                            Self::render_separator(ui);
323                            ui.add_enabled_ui(!self.is_locked, |ui| {
324                                ui.horizontal(|ui| {
325                                    for (idx, slot) in self.color_slots.iter_mut().enumerate() {
326                                        let picker = &mut self.pickers[idx];
327                                        picker.sel_color = slot.color;
328                                        let custom = picker.custom_mode;
329                                        let picked =
330                                            ColorPicker::new(picker, ("selection_color", idx))
331                                                .show(ui);
332                                        picked
333                                            .response
334                                            .on_hover_text(l10n::text(ui.ctx(), slot.label));
335                                        if let Some(mut color) = picked.inner {
336                                            if idx > 0
337                                                && !custom
338                                                && !picker.custom_mode
339                                                && slot.color.a() > 0
340                                            {
341                                                let [r, g, b, _] = color.to_srgba_unmultiplied();
342                                                color = Color32::from_rgba_unmultiplied(
343                                                    r,
344                                                    g,
345                                                    b,
346                                                    slot.color.a(),
347                                                );
348                                            }
349                                            if color != slot.color {
350                                                slot.color = color;
351                                                action = Some(FloatingToolbarAction::ChangeColor(
352                                                    idx, color,
353                                                ));
354                                            }
355                                        }
356                                    }
357                                    if let Some((width, style)) = &mut self.stroke {
358                                        if ui.combo_select_width(
359                                            "selection_width",
360                                            width,
361                                            [1.0, 2.0, 3.0, 4.0],
362                                            |v| format!("{v} px"),
363                                            48.0,
364                                        ) {
365                                            action =
366                                                Some(FloatingToolbarAction::ChangeWidth(*width));
367                                        }
368                                        let ctx = ui.ctx().clone();
369                                        if ui.combo_select_width(
370                                            "selection_style",
371                                            style,
372                                            LineStyle::all().iter().copied(),
373                                            |v| l10n::text(&ctx, v.name()),
374                                            72.0,
375                                        ) {
376                                            action =
377                                                Some(FloatingToolbarAction::ChangeStyle(*style));
378                                        }
379                                    }
380                                });
381                            });
382                            Self::render_separator(ui);
383                            if IconButton::new(&icons::SETTINGS)
384                                .size(ButtonSize::SM)
385                                .tooltip("Settings")
386                                .show(ui)
387                                .clicked()
388                            {
389                                action = Some(FloatingToolbarAction::OpenSettings);
390                            }
391                            if self.alert_available
392                                && IconButton::new(&icons::ALERTS)
393                                    .size(ButtonSize::SM)
394                                    .tooltip("Add alert")
395                                    .show(ui)
396                                    .clicked()
397                            {
398                                action = Some(FloatingToolbarAction::AddAlert);
399                            }
400                            if IconButton::new(if self.is_locked {
401                                &icons::LOCK
402                            } else {
403                                &icons::UNLOCK
404                            })
405                            .size(ButtonSize::SM)
406                            .tooltip(if self.is_locked { "Unlock" } else { "Lock" })
407                            .show(ui)
408                            .clicked()
409                            {
410                                action = Some(FloatingToolbarAction::ToggleLock);
411                            }
412                            if ui
413                                .add_enabled_ui(!self.is_locked, |ui| {
414                                    IconButton::new(&icons::TRASH)
415                                        .size(ButtonSize::SM)
416                                        .tooltip("Delete")
417                                        .show(ui)
418                                })
419                                .inner
420                                .clicked()
421                            {
422                                action = Some(FloatingToolbarAction::Delete);
423                            }
424                            Self::render_separator(ui);
425                            let more = IconButton::new(&icons::UI_MORE_HORIZONTAL)
426                                .size(ButtonSize::SM)
427                                .tooltip("More")
428                                .show(ui);
429                            if more.clicked() {
430                                self.menu.set_context(
431                                    self.menu.drawing_type.clone(),
432                                    self.is_locked,
433                                    self.is_visible,
434                                );
435                                self.menu.open(
436                                    more.rect.left_bottom(),
437                                    self.menu.drawing_id.unwrap_or_default(),
438                                );
439                            }
440                        });
441                    });
442            });
443        self.last_rect = area.response.rect;
444        // Keep the next frame's anchor in step with egui's measured, constrained surface.
445        if !ui.input(|i| i.pointer.any_down()) {
446            self.position = self.last_rect.min;
447        }
448        self.menu
449            .show_with_shortcuts(ui.ctx(), &ui.active_theme(), shortcuts);
450        let menu_action = self.menu.take_action();
451        if menu_action != DrawingContextMenuAction::None {
452            action = Some(match menu_action {
453                DrawingContextMenuAction::Edit => FloatingToolbarAction::OpenSettings,
454                DrawingContextMenuAction::Clone => FloatingToolbarAction::Duplicate,
455                DrawingContextMenuAction::Lock => FloatingToolbarAction::ToggleLock,
456                DrawingContextMenuAction::Hide => FloatingToolbarAction::ToggleVisibility,
457                DrawingContextMenuAction::Delete => FloatingToolbarAction::Delete,
458                action => FloatingToolbarAction::DrawingMenu(action),
459            });
460        }
461        action
462    }
463
464    fn render_drag_handle(ui: &mut Ui) -> egui::Response {
465        let size = Vec2::new(
466            ui.active_theme().spacing.lg + ui.active_theme().spacing.md,
467            DESIGN_TOKENS.sizing.floating_toolbar.drag_handle_height,
468        );
469        let (rect, response) = ui.allocate_exact_size(size, Sense::DRAG);
470
471        let painter = ui.painter();
472        let dot_color = ui.style().visuals.text_color().gamma_multiply(0.4);
473        let dot_size = DESIGN_TOKENS.sizing.floating_toolbar.dot_size;
474        let dot_spacing = DESIGN_TOKENS.sizing.floating_toolbar.dot_spacing;
475
476        // Draw 6-dot drag handle pattern (2 columns, 3 rows)
477        let start_x = rect.center().x - dot_spacing / 2.0;
478        let start_y = rect.center().y - dot_spacing;
479
480        for col in 0..2 {
481            for row in 0..3 {
482                let x = start_x + col as f32 * dot_spacing;
483                let y = start_y + row as f32 * dot_spacing;
484                painter.circle_filled(Pos2::new(x, y), dot_size, dot_color);
485            }
486        }
487        response.on_hover_cursor(egui::CursorIcon::Grab)
488    }
489
490    fn render_separator(ui: &mut Ui) {
491        let height = DESIGN_TOKENS.sizing.floating_toolbar.separator_height;
492        let (rect, _) = ui.allocate_exact_size(
493            Vec2::new(DESIGN_TOKENS.stroke.hairline, height),
494            Sense::hover(),
495        );
496        let painter = ui.painter();
497        let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
498        painter.line_segment(
499            [
500                Pos2::new(rect.center().x, rect.min.y),
501                Pos2::new(rect.center().x, rect.max.y),
502            ],
503            stroke,
504        );
505        ui.space_xs();
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    /// Keyboard navigation announces each action, including the current lock and visibility state
514    /// and the alert when it is available.
515    #[test]
516    fn action_buttons_have_accessible_names() {
517        for locked in [false, true] {
518            let ctx = egui::Context::default();
519            let mut toolbar = FloatingSelectionToolbar::new();
520            toolbar.show_at(Pos2::new(100.0, 100.0));
521            toolbar.set_locked(locked);
522            toolbar.set_object_visible(!locked);
523            toolbar.set_alert_available(locked);
524            let mut frame = |tab| {
525                ctx.run_ui(
526                    egui::RawInput {
527                        screen_rect: Some(Rect::from_min_size(
528                            Pos2::ZERO,
529                            egui::vec2(800.0, 600.0),
530                        )),
531                        events: if tab {
532                            vec![egui::Event::Key {
533                                key: egui::Key::Tab,
534                                physical_key: None,
535                                pressed: true,
536                                repeat: false,
537                                modifiers: egui::Modifiers::NONE,
538                            }]
539                        } else {
540                            vec![]
541                        },
542                        ..Default::default()
543                    },
544                    |ui| {
545                        toolbar.show(ui);
546                    },
547                )
548            };
549            frame(false).drop_without_applying_deltas();
550            frame(false).drop_without_applying_deltas();
551            let alert = locked.then_some("Add alert");
552            for label in ["Settings"]
553                .into_iter()
554                .chain(alert)
555                .chain([if locked { "Unlock" } else { "Lock" }])
556                .chain((!locked).then_some("Delete"))
557                .chain(["More"])
558            {
559                let mut output = frame(true);
560                let events = std::mem::take(&mut output.platform_output.events);
561                output.drop_without_applying_deltas();
562                assert!(
563                    events
564                        .iter()
565                        .any(|event| { event.widget_info().label.as_deref() == Some(label) }),
566                    "missing accessible name: {label}"
567                );
568            }
569        }
570    }
571
572    /// The colour of a text annotation is its text's; a label on a line does not make the line's
573    /// colour a text colour, although the colour paints both.
574    #[test]
575    fn colour_slot_names_what_the_colour_paints() {
576        use crate::drawings::{Drawing, DrawingToolType as Tool};
577        for (tool, label) in [
578            (Tool::TrendLine, "Line color"),
579            (Tool::TextLabel, "Text color"),
580        ] {
581            let mut chart = Chart::new(crate::model::BarData::new());
582            let mut drawing = Drawing::new(7, tool);
583            drawing.completed = true;
584            drawing.points = vec![Pos2::new(100.0, 100.0)];
585            chart.drawings_mut().drawings = vec![drawing];
586            chart.drawings_mut().select(7);
587            let mut toolbar = FloatingSelectionToolbar::new();
588            egui::Context::default()
589                .run_ui(Default::default(), |ui| {
590                    toolbar.show_and_apply(ui, &mut chart);
591                })
592                .drop_without_applying_deltas();
593            assert_eq!(toolbar.color_slots[0].label, label, "{tool:?}");
594        }
595    }
596
597    /// Floating surfaces and group rules follow each preset's popup and separator visuals.
598    #[test]
599    fn surface_and_separators_follow_the_popup_theme() {
600        for (_, theme) in crate::theme::presets() {
601            let ctx = egui::Context::default();
602            crate::theme::apply_to_egui(&ctx, &theme);
603            ctx.global_style_mut(|style| style.animation_time = 0.0);
604            let style = ctx.global_style();
605            let mut toolbar = FloatingSelectionToolbar::new();
606            toolbar.show_at(Pos2::new(100.0, 100.0));
607            let raw = egui::RawInput {
608                screen_rect: Some(Rect::from_min_size(Pos2::ZERO, egui::vec2(800.0, 600.0))),
609                ..Default::default()
610            };
611            ctx.run_ui(raw.clone(), |ui| {
612                toolbar.show(ui);
613            })
614            .drop_without_applying_deltas();
615            let mut output = ctx.run_ui(raw, |ui| {
616                toolbar.show(ui);
617            });
618            let shapes = std::mem::take(&mut output.shapes);
619            output.drop_without_applying_deltas();
620            let shapes: Vec<_> = shapes
621                .into_iter()
622                .flat_map(|shape| match shape.shape {
623                    egui::Shape::Vec(shapes) => shapes,
624                    shape => vec![shape],
625                })
626                .collect();
627            assert!(shapes.iter().any(|shape| matches!(shape,
628                egui::Shape::Rect(rect) if rect.fill == style.visuals.window_fill()
629                    && rect.stroke == style.visuals.window_stroke
630            )));
631            let shadow = style.visuals.popup_shadow;
632            if shadow != egui::epaint::Shadow::NONE {
633                assert!(shapes.iter().any(|shape| matches!(shape,
634                    egui::Shape::Rect(rect) if rect.fill == shadow.color
635                        && rect.blur_width == f32::from(shadow.blur)
636                )));
637            }
638            let separators = shapes.iter().filter(|shape| matches!(shape,
639                egui::Shape::LineSegment { points, stroke }
640                    if points[0].x == points[1].x
641                        && (points[1].y - points[0].y) == DESIGN_TOKENS.sizing.floating_toolbar.separator_height
642                        && *stroke == style.visuals.widgets.noninteractive.bg_stroke
643            )).count();
644            assert_eq!(separators, 3);
645        }
646    }
647
648    /// A viewport smaller than the toolbar (204 x 32 px without colour slots) must not panic and
649    /// pins the toolbar to the viewport's min edge on each axis that does not fit.
650    #[test]
651    fn show_survives_viewport_smaller_than_toolbar() {
652        for (w, h, expected) in [
653            (100.0, 100.0, Pos2::new(0.0, 10.0)),
654            (800.0, 20.0, Pos2::new(10.0, 0.0)),
655            (100.0, 20.0, Pos2::ZERO),
656        ] {
657            let ctx = egui::Context::default();
658            let mut toolbar = FloatingSelectionToolbar::new();
659            toolbar.show_at(Pos2::new(10.0, 10.0));
660            let raw = egui::RawInput {
661                screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(w, h))),
662                ..Default::default()
663            };
664            // egui asserts that a frame's texture delta is consumed before the next frame.
665            ctx.run_ui(raw.clone(), |_| {}).textures_delta.clear();
666            ctx.run_ui(raw, |ui| {
667                toolbar.show(ui);
668            })
669            .textures_delta
670            .clear();
671            assert_eq!(toolbar.position, expected, "viewport {w}x{h}");
672        }
673    }
674}