Skip to main content

egui_charts/ui/drawing_toolbar/components/
pair_button.rs

1//! Pair button component: [icon button | arrow button]
2//!
3//! Used for: cursors, tool categories, magnet, hide, remove btns.
4
5use crate::icons::Icon;
6use crate::localization as l10n;
7use crate::theme::ThemeExt;
8use egui::{Color32, Pos2, Rect, Sense, Stroke, Ui, Vec2};
9
10use super::svg_helpers::{rail_icon_color, rail_icon_size, render_svg_at_rect_themed};
11use crate::tokens::DESIGN_TOKENS;
12
13/// Response from a pair button interaction
14#[derive(Debug, Clone)]
15pub struct PairButtonResponse {
16    /// Whether the icon (left) part was clicked
17    pub icon_clicked: bool,
18    /// Whether the arrow (right) part was clicked
19    pub arrow_clicked: bool,
20    /// Whether icon part is hovered
21    pub icon_hovered: bool,
22    /// Whether arrow part is hovered
23    pub arrow_hovered: bool,
24    /// Rect of the icon button (for anchoring submenus)
25    pub icon_rect: Rect,
26    /// Rect of the arrow button (for anchoring submenus)
27    pub arrow_rect: Rect,
28}
29
30/// Direction for arrow indicators
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ArrowDirection {
33    /// An upward direction.
34    Up,
35    /// A downward direction.
36    Down,
37    /// A leftward direction.
38    Left,
39    /// A rightward arrow.
40    Right,
41}
42
43/// Pair button: [icon button | arrow button]
44///
45/// Layout: [left_padding | icon centered | arrow]
46/// - Total width: responsive (default 48px)
47/// - Total height: icon_size + padding*2 (default 48px)
48/// - Arrow width: padding (10px)
49/// - Left padding: padding (10px)
50///
51/// # Example
52///
53/// ```
54/// # use egui_charts::icons::icons;
55/// # use egui_charts::ui::drawing_toolbar::PairButton;
56/// # egui::__run_test_ui(|ui| {
57/// let btn = PairButton {
58///     icon: &icons::ERASER,
59///     tooltip: "Cross cursor",
60///     arrow_tooltip: "More cursors",
61///     is_icon_active: false,
62///     is_expanded: false,
63/// };
64/// let response = btn.show(ui, 48.0, 48.0, 28.0, 10.0);
65/// if response.icon_clicked {
66///     // Handle icon click
67/// }
68/// if response.arrow_clicked {
69///     // Handle arrow click (expand submenu)
70/// }
71/// # });
72/// ```
73pub struct PairButton<'a> {
74    /// Icon to display
75    pub icon: &'a Icon,
76    /// Tooltip for the icon button
77    pub tooltip: &'a str,
78    /// Tooltip for the arrow button
79    pub arrow_tooltip: &'a str,
80    /// Whether the icon is in active state
81    pub is_icon_active: bool,
82    /// Whether the category is expanded (affects arrow direction)
83    pub is_expanded: bool,
84}
85
86impl<'a> PairButton<'a> {
87    /// Render the pair button and return interaction response
88    pub fn show(
89        &self,
90        ui: &mut Ui,
91        width: f32,
92        height: f32,
93        icon_size: f32,
94        padding: f32,
95    ) -> PairButtonResponse {
96        let cursor_pos = ui.cursor().min;
97
98        // Full button rect
99        let pair_rect = Rect::from_min_size(cursor_pos, Vec2::new(width, height));
100
101        // Layout: [left_padding | icon | arrow]
102        let arrow_width = padding;
103        let _left_padding = arrow_width;
104
105        // Icon button (centered in full width for visual balance)
106        let icon_rect = Rect::from_center_size(pair_rect.center(), Vec2::splat(icon_size));
107        let icon_res = ui.allocate_rect(icon_rect, Sense::click());
108        let translated_label = l10n::text(ui.ctx(), self.tooltip);
109        icon_res.widget_info(|| {
110            egui::WidgetInfo::selected(
111                egui::Role::Button,
112                icon_res.enabled(),
113                self.is_icon_active,
114                &translated_label,
115            )
116        });
117        crate::styles::focus::draw_focus_ring(ui, &icon_res, ui.active_theme().radius.sm);
118
119        // Arrow button (right edge)
120        let arrow_rect = Rect::from_min_size(
121            Pos2::new(pair_rect.max.x - arrow_width, pair_rect.min.y),
122            Vec2::new(arrow_width, height),
123        );
124        let arrow_res = ui.allocate_rect(arrow_rect, Sense::click());
125        let translated_label = l10n::text(ui.ctx(), self.arrow_tooltip);
126        arrow_res.widget_info(|| {
127            egui::WidgetInfo::labeled(egui::Role::Button, arrow_res.enabled(), &translated_label)
128        });
129        ui.ctx()
130            .accesskit_node_builder(arrow_res.id, |node| node.set_expanded(self.is_expanded));
131        crate::styles::focus::draw_focus_ring(ui, &arrow_res, ui.active_theme().radius.sm);
132
133        // Draw icon button background (with visibility check)
134        if ui.is_rect_visible(icon_rect) {
135            let icon_bg = if self.is_icon_active && icon_res.hovered() {
136                super::active_hover_color(ui)
137            } else if self.is_icon_active {
138                ui.visuals().widgets.active.bg_fill
139            } else if icon_res.hovered() {
140                ui.visuals().widgets.hovered.bg_fill
141            } else {
142                Color32::TRANSPARENT // Let parent Frame's background show through
143            };
144            ui.painter()
145                .rect_filled(icon_rect, ui.active_theme().radius.sm, icon_bg);
146        }
147
148        // Draw icon centered in interactive area (excluding left padding and arrow section)
149        let interactive_width = width - padding - arrow_width;
150        let icon_center_x = pair_rect.min.x + padding + interactive_width / 2.0;
151        let icon_rect_center = Rect::from_center_size(
152            Pos2::new(icon_center_x, pair_rect.center().y),
153            Vec2::splat(icon_size),
154        );
155        if ui.is_rect_visible(icon_rect_center) {
156            render_svg_at_rect_themed(
157                ui,
158                self.icon,
159                icon_rect_center,
160                icon_res.hovered(),
161                self.is_icon_active,
162            );
163        }
164
165        // Draw arrow (with visibility check)
166        if ui.is_rect_visible(arrow_rect) {
167            let arrow_stroke = Stroke::new(
168                1.2_f32,
169                if arrow_res.hovered() {
170                    ui.visuals().widgets.active.fg_stroke.color
171                } else {
172                    ui.visuals().widgets.noninteractive.fg_stroke.color
173                },
174            );
175
176            let arrow_center = arrow_rect.center();
177            draw_arrow(
178                ui,
179                arrow_center,
180                if self.is_expanded {
181                    ArrowDirection::Up
182                } else {
183                    ArrowDirection::Down
184                },
185                arrow_stroke,
186            );
187        }
188
189        // Extract clicked state before consuming with on_hover_text
190        let icon_clicked = icon_res.clicked();
191        let arrow_clicked = arrow_res.clicked();
192        let icon_hovered = icon_res.hovered();
193        let arrow_hovered = arrow_res.hovered();
194
195        // Tooltips (consumes the responses)
196        icon_res.on_hover_text(l10n::text(ui.ctx(), self.tooltip));
197        arrow_res.on_hover_text(l10n::text(ui.ctx(), self.arrow_tooltip));
198
199        PairButtonResponse {
200            icon_clicked,
201            arrow_clicked,
202            icon_hovered,
203            arrow_hovered,
204            icon_rect,
205            arrow_rect,
206        }
207    }
208
209    /// Render the pair button in styled layout.
210    ///
211    /// Uses exact dimensions:
212    /// - Outer rect: 52×38px
213    /// - Inner hover rect: 9px horizontal margin, 2px vertical margin
214    /// - Inner rounding: the theme's medium radius
215    /// - Icon: optically sized and centered
216    pub fn show_styled(&self, ui: &mut Ui) -> PairButtonResponse {
217        let tokens = &DESIGN_TOKENS.sizing.toolbar;
218        let btn_width = tokens.button_width;
219        let btn_height = tokens.button_height;
220        let margin_h = tokens.hover_margin_h;
221        let margin_v = tokens.hover_margin_v;
222        let inner_rounding = ui.active_theme().radius.md;
223
224        let cursor_pos = ui.cursor().min;
225
226        // Full button rect (52×38)
227        let outer_rect = Rect::from_min_size(cursor_pos, Vec2::new(btn_width, btn_height));
228
229        // Inner hover rect with margins
230        let inner_rect = Rect::from_min_max(
231            Pos2::new(outer_rect.min.x + margin_h, outer_rect.min.y + margin_v),
232            Pos2::new(outer_rect.max.x - margin_h, outer_rect.max.y - margin_v),
233        );
234
235        // Arrow detection zone (right edge of outer rect)
236        let arrow_width = margin_h + 2.0;
237        let arrow_rect = Rect::from_min_max(
238            Pos2::new(outer_rect.max.x - arrow_width, outer_rect.min.y),
239            outer_rect.max,
240        );
241
242        // Icon zone (left part of outer rect)
243        let icon_zone_rect = Rect::from_min_max(
244            outer_rect.min,
245            Pos2::new(outer_rect.max.x - arrow_width, outer_rect.max.y),
246        );
247
248        // Use Response-based interactions instead of manual pointer polling
249        let icon_res = ui.allocate_rect(icon_zone_rect, Sense::click());
250        let translated_label = l10n::text(ui.ctx(), self.tooltip);
251        icon_res.widget_info(|| {
252            egui::WidgetInfo::selected(
253                egui::Role::Button,
254                icon_res.enabled(),
255                self.is_icon_active,
256                &translated_label,
257            )
258        });
259        crate::styles::focus::draw_focus_ring(ui, &icon_res, inner_rounding);
260        let arrow_res = ui.allocate_rect(arrow_rect, Sense::click());
261        let translated_label = l10n::text(ui.ctx(), self.arrow_tooltip);
262        arrow_res.widget_info(|| {
263            egui::WidgetInfo::labeled(egui::Role::Button, arrow_res.enabled(), &translated_label)
264        });
265        ui.ctx()
266            .accesskit_node_builder(arrow_res.id, |node| node.set_expanded(self.is_expanded));
267        crate::styles::focus::draw_focus_ring(ui, &arrow_res, inner_rounding);
268
269        let icon_hovered = icon_res.hovered();
270        let arrow_hovered = arrow_res.hovered();
271        let icon_clicked = icon_res.clicked();
272        let arrow_clicked = arrow_res.clicked();
273
274        // Combined hover state for background
275        let any_hovered = icon_hovered || arrow_hovered;
276
277        // Draw inner hover rect only on hover or active (with visibility check)
278        let show_bg = any_hovered || self.is_icon_active;
279        if show_bg && ui.is_rect_visible(inner_rect) {
280            let bg = if self.is_icon_active && icon_hovered {
281                super::active_hover_color(ui)
282            } else if self.is_icon_active {
283                ui.visuals().widgets.active.bg_fill
284            } else if any_hovered {
285                ui.visuals().widgets.hovered.bg_fill
286            } else {
287                Color32::TRANSPARENT
288            };
289            ui.painter().rect_filled(inner_rect, inner_rounding, bg);
290        }
291
292        // Draw icon centered in inner rect (with visibility check)
293        let icon_rect = Rect::from_center_size(inner_rect.center(), rail_icon_size(self.icon));
294        if ui.is_rect_visible(icon_rect) {
295            self.icon
296                .as_image_tinted(icon_rect.size(), rail_icon_color(ui))
297                .paint_at(ui, icon_rect);
298        }
299
300        // Draw arrow at right edge (with visibility check)
301        if ui.is_rect_visible(arrow_rect) {
302            let arrow_stroke = Stroke::new(
303                1.2_f32,
304                if arrow_hovered {
305                    ui.visuals().widgets.active.fg_stroke.color
306                } else {
307                    ui.visuals().widgets.noninteractive.fg_stroke.color
308                },
309            );
310
311            let arrow_center = Pos2::new(outer_rect.max.x - margin_h / 2.0, outer_rect.center().y);
312            draw_arrow(
313                ui,
314                arrow_center,
315                if self.is_expanded {
316                    ArrowDirection::Up
317                } else {
318                    ArrowDirection::Down
319                },
320                arrow_stroke,
321            );
322        }
323
324        // Tooltips using Response-based interactions
325        icon_res.on_hover_text(l10n::text(ui.ctx(), self.tooltip));
326        arrow_res.on_hover_text(l10n::text(ui.ctx(), self.arrow_tooltip));
327
328        PairButtonResponse {
329            icon_clicked,
330            arrow_clicked,
331            icon_hovered,
332            arrow_hovered,
333            icon_rect,
334            arrow_rect,
335        }
336    }
337}
338
339/// Draw an arrow indicator at the given position
340pub fn draw_arrow(ui: &mut Ui, center: Pos2, direction: ArrowDirection, stroke: Stroke) {
341    let arrow_x = center.x;
342    let arrow_y = center.y;
343
344    match direction {
345        ArrowDirection::Down => {
346            // Arrow down: v
347            ui.painter().line_segment(
348                [
349                    Pos2::new(arrow_x - 2.0, arrow_y - 1.0),
350                    Pos2::new(arrow_x, arrow_y + 1.0),
351                ],
352                stroke,
353            );
354            ui.painter().line_segment(
355                [
356                    Pos2::new(arrow_x, arrow_y + 1.0),
357                    Pos2::new(arrow_x + 2.0, arrow_y - 1.0),
358                ],
359                stroke,
360            );
361        }
362        ArrowDirection::Up => {
363            // Arrow up: ^
364            ui.painter().line_segment(
365                [
366                    Pos2::new(arrow_x - 2.0, arrow_y + 1.0),
367                    Pos2::new(arrow_x, arrow_y - 1.0),
368                ],
369                stroke,
370            );
371            ui.painter().line_segment(
372                [
373                    Pos2::new(arrow_x, arrow_y - 1.0),
374                    Pos2::new(arrow_x + 2.0, arrow_y + 1.0),
375                ],
376                stroke,
377            );
378        }
379        ArrowDirection::Right => {
380            // Arrow right: >
381            ui.painter().line_segment(
382                [
383                    Pos2::new(arrow_x - 1.0, arrow_y - 2.0),
384                    Pos2::new(arrow_x + 1.0, arrow_y),
385                ],
386                stroke,
387            );
388            ui.painter().line_segment(
389                [
390                    Pos2::new(arrow_x + 1.0, arrow_y),
391                    Pos2::new(arrow_x - 1.0, arrow_y + 2.0),
392                ],
393                stroke,
394            );
395        }
396        ArrowDirection::Left => {
397            // Arrow left: <
398            ui.painter().line_segment(
399                [
400                    Pos2::new(arrow_x + 1.0, arrow_y - 2.0),
401                    Pos2::new(arrow_x - 1.0, arrow_y),
402                ],
403                stroke,
404            );
405            ui.painter().line_segment(
406                [
407                    Pos2::new(arrow_x - 1.0, arrow_y),
408                    Pos2::new(arrow_x + 1.0, arrow_y + 2.0),
409                ],
410                stroke,
411            );
412        }
413    }
414}