1use 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#[derive(Clone, Debug, PartialEq)]
19#[non_exhaustive]
20pub enum FloatingToolbarAction {
21 ChangeColor(usize, Color32),
23 ChangeWidth(f32),
25 ChangeStyle(LineStyle),
27 DrawingMenu(DrawingContextMenuAction),
29 OpenSettings,
31 AddAlert,
33 Duplicate,
35 Delete,
37 ToggleLock,
39 ToggleVisibility,
41 Close,
43}
44
45#[derive(Clone, Debug)]
47pub struct ColorSlot {
48 pub label: &'static str,
50 pub color: Color32,
52}
53
54#[derive(Clone, Debug)]
56pub struct FloatingSelectionToolbar {
57 visible: bool,
59 position: Pos2,
61 color_slots: Vec<ColorSlot>,
63 pickers: Vec<ColorPickerState>,
65 stroke: Option<(f32, LineStyle)>,
67 selected: Option<(String, usize)>,
69 colour_drag: bool,
71 menu: DrawingContextMenu,
73 is_locked: bool,
75 is_visible: bool,
77 alert_available: bool,
79 last_rect: Rect,
81}
82
83impl Default for FloatingSelectionToolbar {
84 fn default() -> Self {
85 Self::new()
86 }
87}
88
89impl FloatingSelectionToolbar {
90 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 pub fn show_at(&mut self, position: Pos2) {
113 self.visible = true;
114 self.position = position;
115 }
116
117 pub fn hide(&mut self) {
119 self.visible = false;
120 self.selected = None;
121 self.pickers.clear();
122 self.menu.close();
123 }
124
125 #[allow(clippy::misnamed_getters)]
127 pub fn is_visible(&self) -> bool {
128 self.visible
129 }
130
131 pub fn contains_point(&self, point: Pos2) -> bool {
133 self.visible && self.last_rect.contains(point)
134 }
135
136 pub fn set_colors(&mut self, slots: Vec<ColorSlot>) {
138 self.color_slots = slots;
139 }
140
141 pub fn color_slots_mut(&mut self) -> &mut Vec<ColorSlot> {
143 &mut self.color_slots
144 }
145
146 pub fn set_locked(&mut self, locked: bool) {
148 self.is_locked = locked;
149 }
150
151 pub fn set_object_visible(&mut self, visible: bool) {
153 self.is_visible = visible;
154 }
155
156 pub fn set_alert_available(&mut self, available: bool) {
159 self.alert_available = available;
160 }
161
162 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 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}