drafftink_widgets/
buttons.rs

1//! Button components: icon buttons, toggle buttons, multi-state toggles.
2
3use egui::{
4    vec2, Align2, Color32, CornerRadius, CursorIcon, Image, ImageSource, Pos2, Rect, Sense, Stroke,
5    StrokeKind, Ui, Vec2,
6};
7
8use crate::{sizing, theme};
9
10/// Style configuration for icon buttons.
11#[derive(Clone)]
12pub struct IconButtonStyle {
13    /// Button size
14    pub size: Vec2,
15    /// Icon size (should be smaller than button size)
16    pub icon_size: Vec2,
17    /// Corner radius
18    pub corner_radius: u8,
19    /// Background color when not selected
20    pub bg_color: Color32,
21    /// Background color when hovered
22    pub hover_color: Color32,
23    /// Background color when selected/active
24    pub selected_color: Color32,
25    /// Icon tint when not selected (None = no tint)
26    pub icon_tint: Option<Color32>,
27    /// Icon tint when selected
28    pub selected_icon_tint: Option<Color32>,
29    /// Whether to use solid fill style (like toolbar tools)
30    pub solid_selected: bool,
31}
32
33impl Default for IconButtonStyle {
34    fn default() -> Self {
35        Self {
36            size: vec2(sizing::MEDIUM, sizing::MEDIUM),
37            icon_size: vec2(18.0, 18.0),
38            corner_radius: sizing::CORNER_RADIUS,
39            bg_color: Color32::TRANSPARENT,
40            hover_color: theme::HOVER_BG,
41            selected_color: theme::ACCENT,
42            icon_tint: Some(Color32::from_gray(80)),
43            selected_icon_tint: Some(Color32::WHITE),
44            solid_selected: true,
45        }
46    }
47}
48
49impl IconButtonStyle {
50    /// Create a small icon button style (24x24 button, 16x16 icon) for bottom toolbar
51    pub fn small() -> Self {
52        Self {
53            size: vec2(24.0, 24.0),
54            icon_size: vec2(16.0, 16.0),
55            corner_radius: sizing::CORNER_RADIUS,
56            bg_color: Color32::TRANSPARENT,
57            hover_color: Color32::TRANSPARENT,
58            selected_color: Color32::TRANSPARENT,
59            icon_tint: Some(Color32::from_gray(100)),
60            selected_icon_tint: Some(theme::ACCENT),
61            solid_selected: false,
62        }
63    }
64
65    /// Create a toolbar tool button style (32x32, solid blue when selected)
66    pub fn tool() -> Self {
67        Self {
68            size: vec2(32.0, 32.0),
69            icon_size: vec2(18.0, 18.0),
70            corner_radius: 6,
71            bg_color: Color32::TRANSPARENT,
72            hover_color: Color32::from_gray(235),
73            selected_color: theme::ACCENT,
74            icon_tint: Some(Color32::from_gray(80)),
75            selected_icon_tint: Some(Color32::WHITE),
76            solid_selected: true,
77        }
78    }
79
80    /// Create a large button style (36x36)
81    pub fn large() -> Self {
82        Self {
83            size: vec2(sizing::LARGE, sizing::LARGE),
84            icon_size: vec2(24.0, 24.0),
85            ..Default::default()
86        }
87    }
88}
89
90/// An icon button that displays an image/SVG.
91pub struct IconButton<'a> {
92    icon: ImageSource<'a>,
93    tooltip: &'a str,
94    shortcut: Option<&'a str>,
95    selected: bool,
96    style: IconButtonStyle,
97}
98
99impl<'a> IconButton<'a> {
100    /// Create a new icon button.
101    pub fn new(icon: ImageSource<'a>, tooltip: &'a str) -> Self {
102        Self {
103            icon,
104            tooltip,
105            shortcut: None,
106            selected: false,
107            style: IconButtonStyle::default(),
108        }
109    }
110
111    /// Set whether the button is selected/active.
112    pub fn selected(mut self, selected: bool) -> Self {
113        self.selected = selected;
114        self
115    }
116
117    /// Set the button style.
118    pub fn style(mut self, style: IconButtonStyle) -> Self {
119        self.style = style;
120        self
121    }
122
123    /// Use small style for bottom toolbar.
124    pub fn small(mut self) -> Self {
125        self.style = IconButtonStyle::small();
126        self
127    }
128
129    /// Use tool style for left toolbar.
130    pub fn tool(mut self) -> Self {
131        self.style = IconButtonStyle::tool();
132        self
133    }
134
135    /// Set keyboard shortcut (shown in hover tooltip).
136    pub fn shortcut(mut self, shortcut: &'a str) -> Self {
137        self.shortcut = Some(shortcut);
138        self
139    }
140
141    /// Show the button and return true if clicked.
142    pub fn show(self, ui: &mut Ui) -> bool {
143        let (rect, response) = ui.allocate_exact_size(self.style.size, Sense::click());
144
145        if ui.is_rect_visible(rect) {
146            let bg_color = if self.selected && self.style.solid_selected {
147                self.style.selected_color
148            } else if response.hovered() {
149                self.style.hover_color
150            } else {
151                self.style.bg_color
152            };
153
154            // Draw background
155            ui.painter().rect_filled(
156                rect,
157                CornerRadius::same(self.style.corner_radius),
158                bg_color,
159            );
160
161            // Determine icon tint
162            let icon_tint = if self.selected {
163                self.style.selected_icon_tint
164            } else if response.hovered() {
165                Some(Color32::from_gray(40))
166            } else {
167                self.style.icon_tint
168            };
169
170            // Draw icon centered
171            let icon_rect = Rect::from_center_size(rect.center(), self.style.icon_size);
172            let mut image = Image::new(self.icon).fit_to_exact_size(self.style.icon_size);
173            if let Some(tint) = icon_tint {
174                image = image.tint(tint);
175            }
176            image.paint_at(ui, icon_rect);
177        }
178
179        let clicked = response.clicked();
180        // Show tooltip with optional shortcut
181        if let Some(shortcut) = self.shortcut {
182            response.clone().on_hover_ui(|ui| {
183                ui.horizontal(|ui| {
184                    ui.label(self.tooltip);
185                    ui.label(
186                        egui::RichText::new(format!("({})", shortcut))
187                            .color(Color32::from_gray(128))
188                            .small(),
189                    );
190                });
191            });
192        } else {
193            response.clone().on_hover_text(self.tooltip);
194        }
195        response.on_hover_cursor(CursorIcon::PointingHand);
196        clicked
197    }
198}
199
200/// A toggle button with text label.
201/// Uses solid blue background when selected (Excalidraw style).
202pub struct ToggleButton<'a> {
203    label: &'a str,
204    selected: bool,
205    min_width: Option<f32>,
206    height: f32,
207    font_size: f32,
208}
209
210impl<'a> ToggleButton<'a> {
211    /// Create a new toggle button.
212    pub fn new(label: &'a str, selected: bool) -> Self {
213        Self {
214            label,
215            selected,
216            min_width: None,
217            height: 24.0,
218            font_size: 11.0,
219        }
220    }
221
222    /// Set minimum width.
223    pub fn min_width(mut self, width: f32) -> Self {
224        self.min_width = Some(width);
225        self
226    }
227
228    /// Set the button height.
229    pub fn height(mut self, height: f32) -> Self {
230        self.height = height;
231        self
232    }
233
234    /// Set the font size.
235    pub fn font_size(mut self, size: f32) -> Self {
236        self.font_size = size;
237        self
238    }
239
240    /// Show the button and return true if clicked.
241    pub fn show(self, ui: &mut Ui) -> bool {
242        // Calculate text size for proper button width
243        let font_id = egui::FontId::proportional(self.font_size);
244        let galley = ui.painter().layout_no_wrap(
245            self.label.to_string(),
246            font_id.clone(),
247            Color32::PLACEHOLDER, // Color doesn't matter for sizing
248        );
249        let text_width = galley.size().x;
250        let width = self.min_width.unwrap_or(text_width + 16.0).max(text_width + 16.0);
251        let size = vec2(width, self.height);
252
253        let (rect, response) = ui.allocate_exact_size(size, Sense::click());
254
255        if ui.is_rect_visible(rect) {
256            let bg_color = if self.selected {
257                theme::ACCENT
258            } else if response.hovered() {
259                Color32::from_gray(235)
260            } else {
261                Color32::from_gray(245)
262            };
263
264            let text_color = if self.selected {
265                Color32::WHITE
266            } else {
267                Color32::from_gray(80)
268            };
269
270            ui.painter()
271                .rect_filled(rect, CornerRadius::same(sizing::CORNER_RADIUS), bg_color);
272
273            // Draw text centered
274            ui.painter().text(
275                rect.center(),
276                Align2::CENTER_CENTER,
277                self.label,
278                font_id,
279                text_color,
280            );
281        }
282
283        let clicked = response.clicked();
284        response.on_hover_cursor(CursorIcon::PointingHand);
285        clicked
286    }
287}
288
289/// State for a multi-toggle option.
290pub struct MultiToggleState<'a, T: Clone + PartialEq> {
291    /// The value this state represents
292    pub value: T,
293    /// Icon to display (SVG or image)
294    pub icon: ImageSource<'a>,
295    /// Tooltip text
296    pub tooltip: &'a str,
297}
298
299impl<'a, T: Clone + PartialEq> MultiToggleState<'a, T> {
300    /// Create a new toggle state.
301    pub fn new(value: T, icon: ImageSource<'a>, tooltip: &'a str) -> Self {
302        Self {
303            value,
304            icon,
305            tooltip,
306        }
307    }
308}
309
310/// A multi-state toggle button that cycles through states.
311/// Each state has an icon and tooltip.
312pub struct MultiToggle<'a, T: Clone + PartialEq> {
313    states: &'a [MultiToggleState<'a, T>],
314    current: &'a T,
315    style: IconButtonStyle,
316}
317
318impl<'a, T: Clone + PartialEq> MultiToggle<'a, T> {
319    /// Create a new multi-toggle with the given states.
320    pub fn new(states: &'a [MultiToggleState<'a, T>], current: &'a T) -> Self {
321        Self {
322            states,
323            current,
324            style: IconButtonStyle::default(),
325        }
326    }
327
328    /// Set the button style.
329    pub fn style(mut self, style: IconButtonStyle) -> Self {
330        self.style = style;
331        self
332    }
333
334    /// Use small style.
335    pub fn small(mut self) -> Self {
336        self.style = IconButtonStyle::small();
337        self
338    }
339
340    /// Show the toggle and return the new value if clicked.
341    pub fn show(self, ui: &mut Ui) -> Option<T> {
342        // Find current state index
343        let current_idx = self
344            .states
345            .iter()
346            .position(|s| &s.value == self.current)
347            .unwrap_or(0);
348
349        let state = &self.states[current_idx];
350        let (rect, response) = ui.allocate_exact_size(self.style.size, Sense::click());
351
352        if ui.is_rect_visible(rect) {
353            let bg_color = if response.hovered() {
354                self.style.hover_color
355            } else {
356                self.style.bg_color
357            };
358
359            ui.painter().rect_filled(
360                rect,
361                CornerRadius::same(self.style.corner_radius),
362                bg_color,
363            );
364
365            // Draw icon centered
366            let icon_rect = Rect::from_center_size(rect.center(), self.style.icon_size);
367            Image::new(state.icon.clone()).paint_at(ui, icon_rect);
368        }
369
370        let clicked = response.clicked();
371        response.on_hover_text(state.tooltip).on_hover_cursor(CursorIcon::PointingHand);
372
373        if clicked {
374            // Cycle to next state
375            let next_idx = (current_idx + 1) % self.states.len();
376            Some(self.states[next_idx].value.clone())
377        } else {
378            None
379        }
380    }
381}
382
383/// A text button with optional shortcut hint.
384pub struct TextButton<'a> {
385    label: &'a str,
386    shortcut: Option<&'a str>,
387}
388
389impl<'a> TextButton<'a> {
390    /// Create a new text button.
391    pub fn new(label: &'a str) -> Self {
392        Self {
393            label,
394            shortcut: None,
395        }
396    }
397
398    /// Add a shortcut hint.
399    pub fn shortcut(mut self, shortcut: &'a str) -> Self {
400        self.shortcut = Some(shortcut);
401        self
402    }
403
404    /// Show the button and return true if clicked.
405    pub fn show(self, ui: &mut Ui) -> bool {
406        let size = vec2(0.0, 24.0);
407        let (rect, response) = ui.allocate_at_least(size, Sense::click());
408
409        if ui.is_rect_visible(rect) {
410            let bg_color = if response.hovered() {
411                theme::HOVER_BG
412            } else {
413                Color32::TRANSPARENT
414            };
415
416            ui.painter()
417                .rect_filled(rect, CornerRadius::same(sizing::CORNER_RADIUS), bg_color);
418
419            // Draw label
420            ui.painter().text(
421                Pos2::new(rect.left() + 8.0, rect.center().y),
422                egui::Align2::LEFT_CENTER,
423                self.label,
424                egui::FontId::proportional(12.0),
425                theme::TEXT,
426            );
427
428            // Draw shortcut if present
429            if let Some(shortcut) = self.shortcut {
430                ui.painter().text(
431                    Pos2::new(rect.right() - 8.0, rect.center().y),
432                    egui::Align2::RIGHT_CENTER,
433                    shortcut,
434                    egui::FontId::proportional(11.0),
435                    theme::TEXT_MUTED,
436                );
437            }
438        }
439
440        let clicked = response.clicked();
441        response.on_hover_cursor(CursorIcon::PointingHand);
442        clicked
443    }
444}
445
446/// A stroke width button showing a horizontal line.
447pub struct StrokeWidthButton<'a> {
448    width: f32,
449    tooltip: &'a str,
450    selected: bool,
451}
452
453impl<'a> StrokeWidthButton<'a> {
454    /// Create a new stroke width button.
455    pub fn new(width: f32, tooltip: &'a str, selected: bool) -> Self {
456        Self {
457            width,
458            tooltip,
459            selected,
460        }
461    }
462
463    /// Show the button and return true if clicked.
464    pub fn show(self, ui: &mut Ui) -> bool {
465        let size = vec2(28.0, 20.0);
466        let (rect, response) = ui.allocate_exact_size(size, Sense::click());
467
468        if ui.is_rect_visible(rect) {
469            let bg_color = if self.selected {
470                theme::ACCENT
471            } else if response.hovered() {
472                Color32::from_gray(235)
473            } else {
474                Color32::from_gray(250)
475            };
476
477            let line_color = if self.selected {
478                Color32::WHITE
479            } else {
480                Color32::from_gray(60)
481            };
482
483            // Background
484            ui.painter().rect_filled(rect, CornerRadius::same(sizing::CORNER_RADIUS), bg_color);
485
486            // Border
487            if !self.selected {
488                ui.painter().rect_stroke(
489                    rect,
490                    CornerRadius::same(sizing::CORNER_RADIUS),
491                    Stroke::new(1.0, Color32::from_gray(200)),
492                    StrokeKind::Inside,
493                );
494            }
495
496            // Line representing the width
497            let line_y = rect.center().y;
498            let line_start = Pos2::new(rect.left() + 6.0, line_y);
499            let line_end = Pos2::new(rect.right() - 6.0, line_y);
500            ui.painter().line_segment(
501                [line_start, line_end],
502                Stroke::new(self.width.min(4.0), line_color),
503            );
504        }
505
506        let clicked = response.clicked();
507        response.on_hover_text(self.tooltip).on_hover_cursor(CursorIcon::PointingHand);
508        clicked
509    }
510}
511
512/// A font size button (S/M/L/XL style).
513pub struct FontSizeButton<'a> {
514    label: &'a str,
515    size_px: f32,
516    selected: bool,
517}
518
519impl<'a> FontSizeButton<'a> {
520    /// Create a new font size button.
521    pub fn new(label: &'a str, size_px: f32, selected: bool) -> Self {
522        Self {
523            label,
524            size_px,
525            selected,
526        }
527    }
528
529    /// Show the button and return true if clicked.
530    pub fn show(self, ui: &mut Ui) -> bool {
531        // XL needs more width for the extra letter
532        let width = if self.label == "XL" { 36.0 } else { 28.0 };
533        let size = vec2(width, 24.0);
534        let (rect, response) = ui.allocate_exact_size(size, Sense::click());
535
536        if ui.is_rect_visible(rect) {
537            let bg_color = if self.selected {
538                theme::ACCENT
539            } else if response.hovered() {
540                Color32::from_gray(230)
541            } else {
542                Color32::from_gray(245)
543            };
544
545            let text_color = if self.selected {
546                Color32::WHITE
547            } else {
548                Color32::from_gray(60)
549            };
550
551            ui.painter().rect_filled(rect, CornerRadius::same(sizing::CORNER_RADIUS), bg_color);
552
553            // Draw the label letter with size proportional to actual font size
554            let display_size = match self.label {
555                "S" => 10.0,
556                "M" => 12.0,
557                "L" => 14.0,
558                "XL" => 14.0,
559                _ => 12.0,
560            };
561
562            ui.painter().text(
563                rect.center(),
564                Align2::CENTER_CENTER,
565                self.label,
566                egui::FontId::proportional(display_size),
567                text_color,
568            );
569        }
570
571        let clicked = response.clicked();
572        response.on_hover_text(format!("{} px", self.size_px as i32)).on_hover_cursor(CursorIcon::PointingHand);
573        clicked
574    }
575}