1use crate::localization as l10n;
6use std::sync::Arc;
7
8use crate::icons::Icon;
9use crate::styles::{icons as icon_sizes, stroke};
10use egui::{Color32, Galley, Pos2, Rect, Response, Role, Sense, Ui, Vec2, WidgetInfo};
11
12use super::TopToolbarConfig;
13use crate::theme::{Theme, ThemeExt};
14use crate::tokens::DESIGN_TOKENS;
15use crate::ui::dialogs::keyboard_shortcuts::shortcut_key_labels;
16use crate::ui_kit::ext::UiExt;
17
18pub const MENU_ICON: Icon = Icon::new("top-toolbar-menu.svg", include_bytes!("menu.svg"));
20
21pub struct ToolbarIconButton<'a> {
28 pub icon: &'a Icon,
30 pub tooltip: &'static str,
32 pub config: TopToolbarConfig,
34}
35
36impl<'a> ToolbarIconButton<'a> {
37 pub fn new(icon: &'a Icon, tooltip: &'static str, config: &TopToolbarConfig) -> Self {
39 Self {
40 icon,
41 tooltip,
42 config: config.clone(),
43 }
44 }
45
46 pub fn show(self, ui: &mut Ui) -> Response {
48 let icon_size = match self.icon.name() {
50 "ui/settings.svg" => Vec2::new(24.0, 22.0),
51 "ui/camera.svg" | "ui/compare-add.svg" => Vec2::splat(23.0),
52 _ => Vec2::splat(24.0),
53 } * (self.config.small_icon_size / 24.0);
54 let btn_size = self.config.btn_size;
55 let (rect, response) = ui.allocate_exact_size(Vec2::splat(btn_size), Sense::click());
56 crate::styles::focus::draw_focus_ring(ui, &response, self.config.rounding);
57
58 let bg_color = if response.is_pointer_button_down_on() {
60 ui.visuals().widgets.active.bg_fill
61 } else if response.hovered() {
62 ui.visuals().widgets.hovered.bg_fill
63 } else {
64 Color32::TRANSPARENT
65 };
66
67 ui.painter()
68 .rect_filled(rect, self.config.rounding, bg_color);
69
70 let is_hovered = response.hovered();
72 let is_active = response.is_pointer_button_down_on();
73
74 let icon_pos = rect.center() - icon_size / 2.0;
76 let icon_rect = Rect::from_min_size(icon_pos, icon_size);
77 if ui.is_rect_visible(icon_rect) {
78 let icon_color = if !ui.is_enabled() {
79 ui.active_theme().ui.text_disabled
80 } else if is_active || is_hovered {
81 ui.visuals().widgets.hovered.fg_stroke.color
82 } else {
83 ui.visuals().text_color()
84 };
85 self.icon
86 .as_image_tinted(icon_size, icon_color)
87 .paint_at(ui, icon_rect);
88 }
89
90 let translated_label = l10n::text(ui.ctx(), self.tooltip);
91 response
92 .widget_info(|| WidgetInfo::labeled(Role::Button, ui.is_enabled(), &translated_label));
93 toolbar_tooltip(response, self.tooltip, None)
94 }
95}
96
97pub struct IconTextButton<'a> {
101 pub icon: &'a Icon,
103 pub label: &'static str,
105 pub config: TopToolbarConfig,
107}
108
109impl<'a> IconTextButton<'a> {
110 pub fn new(icon: &'a Icon, label: &'static str, config: &TopToolbarConfig) -> Self {
112 Self {
113 icon,
114 label,
115 config: config.clone(),
116 }
117 }
118
119 pub(crate) fn width(&self, ui: &Ui) -> f32 {
120 let galley = ui.painter().layout_no_wrap(
121 l10n::text(ui.ctx(), self.label),
122 egui::FontId::proportional(ui.active_theme().text.title),
123 Color32::PLACEHOLDER,
124 );
125 self.config.small_icon_size + self.config.padding * 3.0 + galley.size().x
126 }
127
128 pub fn show(self, ui: &mut Ui) -> Response {
130 let icon_size = self.config.small_icon_size;
131 let padding = self.config.padding;
132 let total_width = self.width(ui);
134 let height = self.config.btn_size;
135
136 let (rect, response) =
137 ui.allocate_exact_size(Vec2::new(total_width, height), Sense::click());
138 crate::styles::focus::draw_focus_ring(ui, &response, self.config.rounding);
139
140 if ui.is_rect_visible(rect) {
142 let bg_color = if response.is_pointer_button_down_on() {
144 ui.visuals().widgets.active.bg_fill
145 } else if response.hovered() {
146 ui.visuals().widgets.hovered.bg_fill
147 } else {
148 Color32::TRANSPARENT
149 };
150
151 ui.painter()
152 .rect_filled(rect, self.config.rounding, bg_color);
153
154 let is_hovered = response.hovered();
156 let is_active = response.is_pointer_button_down_on();
157
158 let icon_pos = Pos2::new(rect.min.x + padding, rect.center().y - icon_size / 2.0);
160 let icon_rect = Rect::from_min_size(icon_pos, Vec2::splat(icon_size));
161 let icon_color = if is_active || is_hovered {
162 ui.visuals().widgets.hovered.fg_stroke.color
163 } else {
164 ui.visuals().text_color()
165 };
166 self.icon
167 .as_image_tinted(Vec2::splat(icon_size), icon_color)
168 .paint_at(ui, icon_rect);
169
170 let color = if is_hovered || is_active {
172 ui.visuals().widgets.hovered.fg_stroke.color
173 } else {
174 ui.visuals().text_color()
175 };
176
177 ui.painter().text(
179 Pos2::new(rect.min.x + padding + icon_size + padding, rect.center().y),
180 egui::Align2::LEFT_CENTER,
181 l10n::text(ui.ctx(), self.label),
182 egui::FontId::proportional(ui.active_theme().text.title),
183 color,
184 );
185 }
186
187 let translated_label = l10n::text(ui.ctx(), self.label);
188 response
189 .widget_info(|| WidgetInfo::labeled(Role::Button, ui.is_enabled(), &translated_label));
190 toolbar_tooltip(
191 response,
192 if self.label == "Alert" {
193 "Create alert"
194 } else {
195 self.label
196 },
197 None,
198 )
199 }
200}
201
202pub struct TextButton {
206 pub text: String,
208 pub tooltip: Option<&'static str>,
210 pub config: TopToolbarConfig,
212}
213
214impl TextButton {
215 pub fn new(text: String, config: &TopToolbarConfig) -> Self {
217 Self {
218 text,
219 tooltip: None,
220 config: config.clone(),
221 }
222 }
223
224 pub fn with_tooltip(mut self, tooltip: &'static str) -> Self {
226 self.tooltip = Some(tooltip);
227 self
228 }
229
230 pub(crate) fn width(&self, ui: &Ui) -> f32 {
231 let galley = ui.painter().layout_no_wrap(
232 l10n::text(ui.ctx(), &self.text),
233 egui::FontId::proportional(ui.active_theme().text.title),
234 Color32::PLACEHOLDER,
235 );
236 galley.size().x + self.config.padding * 2.0
237 }
238
239 pub fn show(self, ui: &mut Ui) -> Response {
241 let total_width = self.width(ui);
242 let height = self.config.btn_size;
243
244 let (rect, response) =
245 ui.allocate_exact_size(Vec2::new(total_width, height), Sense::click());
246 crate::styles::focus::draw_focus_ring(ui, &response, self.config.rounding);
247
248 let bg_color = if response.is_pointer_button_down_on() {
250 ui.visuals().widgets.active.bg_fill
251 } else if response.hovered() {
252 ui.visuals().widgets.hovered.bg_fill
253 } else {
254 Color32::TRANSPARENT
255 };
256
257 ui.painter()
258 .rect_filled(rect, self.config.rounding, bg_color);
259
260 ui.painter().text(
262 rect.center(),
263 egui::Align2::CENTER_CENTER,
264 l10n::text(ui.ctx(), &self.text),
265 egui::FontId::proportional(ui.active_theme().text.title),
266 ui.visuals().text_color(),
267 );
268
269 let translated_label = l10n::text(ui.ctx(), self.tooltip.unwrap_or(&self.text));
270 response.widget_info(|| {
271 egui::WidgetInfo::labeled(egui::Role::Button, response.enabled(), &translated_label)
272 });
273 if let Some(tooltip) = self.tooltip {
274 toolbar_tooltip(response, tooltip, None)
275 } else {
276 response
277 }
278 }
279}
280
281pub fn separator(ui: &mut Ui) {
283 ui.space_lg();
284 let rect = ui
285 .allocate_exact_size(Vec2::new(stroke::HAIRLINE, icon_sizes::MD), Sense::hover())
286 .0;
287 ui.painter().rect_filled(
288 rect,
289 DESIGN_TOKENS.rounding.none,
290 ui.visuals().widgets.noninteractive.bg_stroke.color,
291 );
292 ui.space_lg();
293}
294
295pub struct TextIconButton<'a> {
297 pub text: &'static str,
299 pub icon: &'a crate::icons::Icon,
301 pub tooltip: &'static str,
303 pub config: TopToolbarConfig,
305}
306
307impl<'a> TextIconButton<'a> {
308 pub fn new(
310 text: &'static str,
311 icon: &'a crate::icons::Icon,
312 tooltip: &'static str,
313 config: &TopToolbarConfig,
314 ) -> Self {
315 Self {
316 text,
317 icon,
318 tooltip,
319 config: config.clone(),
320 }
321 }
322
323 pub(crate) fn width(&self, ui: &Ui) -> f32 {
324 if self.text.is_empty() {
325 return self.config.padding * 3.0 + icon_sizes::XS;
326 }
327 let font_id = egui::FontId::proportional(ui.active_theme().text.title);
328 let galley = ui.painter().layout_no_wrap(
329 l10n::text(ui.ctx(), self.text),
330 font_id,
331 Color32::PLACEHOLDER,
332 );
333 galley.rect.width() + self.config.padding * 3.0 + icon_sizes::XS
334 }
335
336 pub fn show(self, ui: &mut Ui) -> Response {
338 let padding = self.config.padding;
339 let icon_size = icon_sizes::XS;
340 let font_id = egui::FontId::proportional(ui.active_theme().text.title);
341 let size = Vec2::new(self.width(ui), self.config.btn_size);
342 let (rect, response) = ui.allocate_exact_size(size, Sense::click());
343 crate::styles::focus::draw_focus_ring(ui, &response, self.config.rounding);
344
345 if ui.is_rect_visible(rect) {
346 let bg_color = if response.is_pointer_button_down_on() {
348 ui.visuals().widgets.active.bg_fill
349 } else if response.hovered() {
350 ui.visuals().widgets.hovered.bg_fill
351 } else {
352 Color32::TRANSPARENT
353 };
354
355 ui.painter()
356 .rect_filled(rect, self.config.rounding, bg_color);
357
358 let text_color = if response.hovered() {
360 ui.visuals().widgets.hovered.fg_stroke.color
361 } else {
362 ui.visuals().text_color()
363 };
364 if !self.text.is_empty() {
365 let text_x = rect.min.x + padding;
366 ui.painter().text(
367 Pos2::new(text_x, rect.center().y),
368 egui::Align2::LEFT_CENTER,
369 l10n::text(ui.ctx(), self.text),
370 font_id,
371 text_color,
372 );
373 }
374
375 let icon_x = rect.max.x - padding - icon_size;
377 let icon_pos = Pos2::new(icon_x, rect.center().y - icon_size / 2.0);
378 let icon_rect = Rect::from_min_size(icon_pos, Vec2::splat(icon_size));
379 let icon_color = if response.hovered() {
380 ui.visuals().widgets.hovered.fg_stroke.color
381 } else {
382 ui.visuals().text_color()
383 };
384 self.icon
385 .as_image_tinted(Vec2::splat(icon_size), icon_color)
386 .paint_at(ui, icon_rect);
387 }
388
389 let translated_label = l10n::text(ui.ctx(), self.tooltip);
390 response
391 .widget_info(|| WidgetInfo::labeled(Role::Button, ui.is_enabled(), &translated_label));
392 toolbar_tooltip(response, self.tooltip, None)
393 }
394}
395
396pub(crate) fn rule_color(theme: &Theme) -> Color32 {
398 if theme.dark {
399 theme.ui.border.lerp_to_gamma(theme.ui.text, 0.16)
400 } else {
401 theme.ui.panel_bg.lerp_to_gamma(theme.ui.text, 0.085)
402 }
403}
404
405pub(crate) struct PillButton<'a> {
408 pub label: &'a str,
409 pub tooltip: Option<&'a str>,
410 pub filled: bool,
411}
412
413impl PillButton<'_> {
414 pub(crate) fn width(&self, ui: &Ui) -> f32 {
415 let font_id = egui::FontId::proportional(ui.active_theme().text.title);
416 let galley = ui.painter().layout_no_wrap(
417 l10n::text(ui.ctx(), self.label),
418 font_id,
419 Color32::PLACEHOLDER,
420 );
421 galley.size().x + 2.0 * ui.active_theme().spacing.xl
422 }
423
424 pub(crate) fn show(self, ui: &mut Ui) -> Response {
425 let theme = ui.active_theme();
426 let size = Vec2::new(self.width(ui), DESIGN_TOKENS.sizing.button_md);
427 let (rect, response) = ui.allocate_exact_size(size, Sense::click());
428 let radius = rect.height() / 2.0;
429 crate::styles::focus::draw_focus_ring(ui, &response, radius);
430
431 if ui.is_rect_visible(rect) {
432 let pressed = response.is_pointer_button_down_on();
433 let hovered = response.hovered();
434 let widgets = &ui.visuals().widgets;
435 let (fill, text) = if self.filled {
436 let t = if pressed {
439 0.3
440 } else if hovered {
441 0.2
442 } else {
443 0.0
444 };
445 (
446 theme.ui.text.lerp_to_gamma(theme.ui.panel_bg, t),
447 theme.ui.panel_bg,
448 )
449 } else if pressed {
450 (widgets.active.bg_fill, theme.ui.text)
451 } else if hovered {
452 (widgets.hovered.bg_fill, theme.ui.text)
453 } else {
454 (Color32::TRANSPARENT, theme.ui.text)
455 };
456 ui.painter().rect_filled(rect, radius, fill);
457 if !self.filled {
458 ui.painter().rect_stroke(
459 rect,
460 radius,
461 egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, rule_color(&theme)),
462 egui::StrokeKind::Inside,
463 );
464 }
465 ui.painter().text(
466 rect.center(),
467 egui::Align2::CENTER_CENTER,
468 l10n::text(ui.ctx(), self.label),
469 egui::FontId::proportional(theme.text.title),
470 text,
471 );
472 }
473
474 let translated_label = l10n::text(ui.ctx(), self.label);
475 response
476 .widget_info(|| WidgetInfo::labeled(Role::Button, ui.is_enabled(), &translated_label));
477 match self.tooltip {
478 Some(tooltip) => toolbar_tooltip(response, tooltip, None),
479 None => response,
480 }
481 }
482}
483
484pub(crate) struct Trigger<'a> {
486 pub icon: Option<&'a Icon>,
487 pub label: String,
488 pub name: String,
489 pub min_width: f32,
491 pub filled: bool,
493}
494
495impl Trigger<'_> {
496 fn galley(&self, ui: &Ui, theme: &Theme) -> Arc<Galley> {
497 let mut format = egui::text::TextFormat::simple(
498 egui::FontId::proportional(theme.text.title),
499 theme.ui.text,
500 );
501 if self.filled {
502 format.coords.push("wght", 600.0);
503 }
504 ui.painter()
505 .layout_job(egui::text::LayoutJob::single_section(
506 l10n::text(ui.ctx(), self.label.clone()),
507 format,
508 ))
509 }
510
511 fn width_for(&self, theme: &Theme, label_width: f32) -> f32 {
512 let icon = self.icon.map_or(0.0, |_| icon_sizes::LG);
513 let gap = if icon > 0.0 && !self.label.is_empty() {
514 theme.spacing.sm
515 } else {
516 0.0
517 };
518 (icon + gap + label_width + theme.spacing.lg * 2.0).max(self.min_width)
519 }
520
521 pub(crate) fn show(self, ui: &mut Ui, open: bool) -> Response {
522 let theme = ui.active_theme();
523 let galley = self.galley(ui, &theme);
524 let size = Vec2::new(
525 self.width_for(&theme, galley.size().x),
526 if self.filled {
527 28.0
528 } else {
529 super::config::BUTTON_SIZE
530 },
531 );
532 let (rect, response) = ui.allocate_exact_size(size, Sense::click());
533 crate::styles::focus::draw_focus_ring(
534 ui,
535 &response,
536 if self.filled {
537 rect.height() / 2.0
538 } else {
539 theme.radius.md
540 },
541 );
542
543 if ui.is_rect_visible(rect) {
544 let widgets = &ui.visuals().widgets;
545 let pressed = open || response.is_pointer_button_down_on();
546 let hovered = response.hovered();
547 let fill = if pressed {
548 widgets.active.bg_fill
549 } else if hovered {
550 widgets.hovered.bg_fill
551 } else if self.filled {
552 theme.ui.panel_bg.lerp_to_gamma(theme.ui.text, 0.085)
553 } else {
554 Color32::TRANSPARENT
555 };
556 let icon_color = if pressed || hovered {
557 widgets.hovered.fg_stroke.color
558 } else {
559 theme.ui.text
560 };
561 ui.painter().rect_filled(
562 rect,
563 if self.filled {
564 rect.height() / 2.0
565 } else {
566 theme.radius.md
567 },
568 fill,
569 );
570
571 let paint_icon = |icon: &Icon, x: f32, size: f32| {
572 let min = Pos2::new(x, rect.center().y - size / 2.0);
573 icon.as_image_tinted(Vec2::splat(size), icon_color)
574 .paint_at(ui, Rect::from_min_size(min, Vec2::splat(size)));
575 };
576 let content_width = galley.size().x
577 + self.icon.map_or(0.0, |_| icon_sizes::LG)
578 + if self.icon.is_some() && !self.label.is_empty() {
579 theme.spacing.sm
580 } else {
581 0.0
582 };
583 let x = if self.filled {
584 rect.min.x + theme.spacing.lg
585 } else {
586 rect.center().x - content_width / 2.0
587 };
588 let label_x = x + self.icon.map_or(0.0, |_| icon_sizes::LG + theme.spacing.sm);
589 let label_pos = Pos2::new(label_x, rect.center().y - galley.size().y / 2.0);
590 ui.painter().galley(label_pos, galley, theme.ui.text);
591 if let Some(icon) = self.icon {
592 paint_icon(icon, x, icon_sizes::LG);
593 }
594 }
595
596 let translated_label = l10n::text(ui.ctx(), &self.name);
597 response
598 .widget_info(|| WidgetInfo::labeled(Role::Button, ui.is_enabled(), &translated_label));
599 toolbar_tooltip(response, &self.name, None)
600 }
601}
602
603const HOVER_LABEL_FILL: Color32 = Color32::from_gray(28);
605const HOVER_LABEL_TEXT: Color32 = Color32::from_gray(240);
607const HOVER_LABEL_KEY: Color32 = Color32::from_gray(99);
609const HOVER_LABEL_ROW: f32 = 18.0;
611const HOVER_LABEL_TEXT_SIZE: f32 = 13.0;
613
614fn hover_label_run(ui: &mut Ui, text: String, padding: f32, fill: Color32, color: Color32) {
616 let font = egui::FontId::proportional(HOVER_LABEL_TEXT_SIZE);
617 let galley = ui.painter().layout_no_wrap(text, font, color);
618 let size = Vec2::new(galley.size().x + 2.0 * padding, HOVER_LABEL_ROW);
619 let (rect, response) = ui.allocate_exact_size(size, Sense::hover());
620 response.widget_info(|| WidgetInfo::labeled(Role::Label, true, galley.text()));
621 ui.painter()
622 .rect_filled(rect, ui.active_theme().radius.xs, fill);
623 ui.painter()
624 .galley(rect.center() - galley.size() / 2.0, galley, color);
625}
626
627pub fn toolbar_tooltip(
631 response: Response,
632 text: &str,
633 shortcut: Option<egui::KeyboardShortcut>,
634) -> Response {
635 let shortcut = shortcut.or_else(|| {
636 let action = match text {
637 "Quick Search" | "Quick search" => crate::config::KeyboardAction::OpenQuickSearch,
638 "Indicators" => crate::config::KeyboardAction::OpenIndicatorSearch,
639 "Create alert" => crate::config::KeyboardAction::CreateAlert,
640 _ => return None,
641 };
642 response
643 .ctx
644 .data(|data| {
645 data.get_temp::<crate::widget::ShortcutMap>(egui::Id::NULL.with("chart_shortcuts"))
646 })
647 .unwrap_or_default()
648 .shortcut(action)
649 });
650 egui::Popup::from_response(&response)
651 .id(response.id.with("toolbar_tooltip"))
652 .kind(egui::PopupKind::Tooltip)
653 .align(egui::RectAlign::BOTTOM)
654 .gap(9.0)
655 .sense(Sense::hover())
656 .interactable(false)
657 .open(response.enabled() && egui::Tooltip::should_show_tooltip(&response, false))
658 .frame(
659 egui::Frame::new()
660 .fill(HOVER_LABEL_FILL)
661 .corner_radius(response.ctx.active_theme().radius.sm)
662 .inner_margin(egui::Margin::symmetric(9, 3)),
663 )
664 .show(|ui| {
665 ui.spacing_mut().item_spacing.x = 4.0;
666 ui.spacing_mut().interact_size.y = HOVER_LABEL_ROW;
667 ui.horizontal(|ui| {
668 let label = l10n::text(ui.ctx(), text);
669 hover_label_run(ui, label, 0.0, Color32::TRANSPARENT, HOVER_LABEL_TEXT);
670 if let Some(shortcut) = shortcut {
671 ui.add_space(5.0);
673 let (rule, _) =
674 ui.allocate_exact_size(Vec2::new(1.0, HOVER_LABEL_ROW), Sense::hover());
675 ui.painter().rect_filled(rule, 0.0, HOVER_LABEL_KEY);
676 ui.add_space(4.0);
677 let font = egui::FontId::proportional(HOVER_LABEL_TEXT_SIZE);
678 let keys = shortcut_key_labels(ui.ctx(), &font, &shortcut);
679 for (index, key) in keys.iter().enumerate() {
680 if index > 0 {
681 let plus = "+".to_owned();
682 hover_label_run(ui, plus, 0.0, Color32::TRANSPARENT, Color32::WHITE);
683 }
684 let key = l10n::text(ui.ctx(), key);
685 hover_label_run(ui, key, 4.0, HOVER_LABEL_KEY, Color32::WHITE);
686 }
687 }
688 });
689 let x = response
690 .rect
691 .center()
692 .x
693 .clamp(ui.min_rect().left(), ui.min_rect().right());
694 let y = ui.min_rect().top() - 4.0;
695 ui.painter()
696 .with_clip_rect(egui::Rect::EVERYTHING)
697 .add(egui::Shape::convex_polygon(
698 vec![
699 egui::pos2(x - 5.0, y),
700 egui::pos2(x, y - 5.0),
701 egui::pos2(x + 5.0, y),
702 ],
703 HOVER_LABEL_FILL,
704 egui::Stroke::NONE,
705 ));
706 });
707 response
708}