Skip to main content

revue/widget/input_widgets/
button.rs

1//! Button widget for clickable actions
2
3use crate::event::{Key, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
4use crate::layout::Rect;
5use crate::render::Cell;
6use crate::style::Color;
7use crate::widget::traits::{
8    EventResult, Interactive, RenderContext, View, WidgetProps, WidgetState,
9};
10use crate::{impl_styled_view, impl_widget_builders};
11
12/// Button style presets
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14pub enum ButtonVariant {
15    /// Default button style
16    #[default]
17    Default,
18    /// Primary action button (highlighted)
19    Primary,
20    /// Danger/destructive action button
21    Danger,
22    /// Ghost button (minimal styling)
23    Ghost,
24    /// Success button
25    Success,
26}
27
28/// A clickable button widget
29#[derive(Clone, Debug)]
30pub struct Button {
31    label: String,
32    /// Optional icon before the label
33    icon: Option<char>,
34    variant: ButtonVariant,
35    /// Common widget state (focused, disabled, pressed, hovered, colors)
36    state: WidgetState,
37    /// CSS styling properties (id, classes)
38    props: WidgetProps,
39    width: Option<u16>,
40}
41
42impl Button {
43    /// Create a new button with a label
44    pub fn new(label: impl Into<String>) -> Self {
45        Self {
46            label: label.into(),
47            icon: None,
48            variant: ButtonVariant::Default,
49            state: WidgetState::new(),
50            props: WidgetProps::new(),
51            width: None,
52        }
53    }
54
55    /// Set an icon to display before the label
56    ///
57    /// # Example
58    ///
59    /// ```rust,ignore
60    /// use revue::prelude::*;
61    ///
62    /// let btn = Button::new("Save")
63    ///     .icon('💾')
64    ///     .variant(ButtonVariant::Primary);
65    ///
66    /// // Using Nerd Font icons
67    /// let btn = Button::new("Settings")
68    ///     .icon('\u{f013}');  // Gear icon
69    /// ```
70    pub fn icon(mut self, icon: char) -> Self {
71        self.icon = Some(icon);
72        self
73    }
74
75    /// Create a primary button
76    pub fn primary(label: impl Into<String>) -> Self {
77        Self::new(label).variant(ButtonVariant::Primary)
78    }
79
80    /// Create a danger button
81    pub fn danger(label: impl Into<String>) -> Self {
82        Self::new(label).variant(ButtonVariant::Danger)
83    }
84
85    /// Create a ghost button
86    pub fn ghost(label: impl Into<String>) -> Self {
87        Self::new(label).variant(ButtonVariant::Ghost)
88    }
89
90    /// Create a success button
91    pub fn success(label: impl Into<String>) -> Self {
92        Self::new(label).variant(ButtonVariant::Success)
93    }
94
95    /// Set button variant
96    pub fn variant(mut self, variant: ButtonVariant) -> Self {
97        self.variant = variant;
98        self
99    }
100
101    /// Set minimum width
102    pub fn width(mut self, width: u16) -> Self {
103        self.width = Some(width);
104        self
105    }
106
107    /// Handle key input, returns true if button was "clicked"
108    pub fn handle_key(&mut self, key: &Key) -> bool {
109        if self.state.disabled {
110            return false;
111        }
112
113        matches!(key, Key::Enter | Key::Char(' '))
114    }
115
116    /// Handle mouse input, returns (needs_render, was_clicked)
117    ///
118    /// The `area` parameter should be the button's rendered area.
119    ///
120    /// # Example
121    /// ```ignore
122    /// let (needs_render, clicked) = button.handle_mouse(&mouse, button_area);
123    /// if clicked {
124    ///     // Button was clicked
125    /// }
126    /// ```
127    pub fn handle_mouse(&mut self, event: &MouseEvent, area: Rect) -> (bool, bool) {
128        if self.state.disabled {
129            return (false, false);
130        }
131
132        let inside = area.contains(event.x, event.y);
133        let mut needs_render = false;
134        let mut was_clicked = false;
135
136        match event.kind {
137            MouseEventKind::Down(MouseButton::Left) if inside => {
138                if !self.state.pressed {
139                    self.state.pressed = true;
140                    needs_render = true;
141                }
142            }
143            MouseEventKind::Up(MouseButton::Left) => {
144                if self.state.pressed {
145                    self.state.pressed = false;
146                    needs_render = true;
147                    if inside {
148                        was_clicked = true;
149                    }
150                }
151            }
152            MouseEventKind::Move => {
153                let was_hovered = self.state.hovered;
154                self.state.hovered = inside;
155                if was_hovered != self.state.hovered {
156                    needs_render = true;
157                }
158            }
159            _ => {}
160        }
161
162        (needs_render, was_clicked)
163    }
164
165    /// Check if button is pressed
166    pub fn is_pressed(&self) -> bool {
167        self.state.is_pressed()
168    }
169
170    /// Check if button is hovered
171    pub fn is_hovered(&self) -> bool {
172        self.state.is_hovered()
173    }
174
175    /// Get base colors for the variant (without state effects)
176    fn get_variant_base_colors(&self) -> (Color, Color) {
177        match self.variant {
178            ButtonVariant::Default => (Color::WHITE, Color::rgb(60, 60, 60)),
179            ButtonVariant::Primary => (Color::WHITE, Color::rgb(37, 99, 235)),
180            ButtonVariant::Danger => (Color::WHITE, Color::rgb(220, 38, 38)),
181            ButtonVariant::Ghost => (Color::rgb(200, 200, 200), Color::rgb(30, 30, 30)),
182            ButtonVariant::Success => (Color::WHITE, Color::rgb(22, 163, 74)),
183        }
184    }
185
186    /// Get colors with CSS cascade support
187    ///
188    /// Uses WidgetState::resolve_colors_interactive for standard cascade:
189    /// 1. Disabled state (grayed out)
190    /// 2. Widget inline override (via .fg()/.bg())
191    /// 3. CSS computed style from context
192    /// 4. Variant-based default colors
193    /// 5. Apply pressed/hover/focus interaction effects
194    fn get_colors_from_ctx(&self, ctx: &RenderContext) -> (Color, Color) {
195        let (variant_fg, variant_bg) = self.get_variant_base_colors();
196        self.state
197            .resolve_colors_interactive(ctx.style, variant_fg, variant_bg)
198    }
199}
200
201impl Default for Button {
202    fn default() -> Self {
203        Self::new("")
204    }
205}
206
207impl View for Button {
208    fn render(&self, ctx: &mut RenderContext) {
209        let area = ctx.area;
210        if area.width == 0 || area.height == 0 {
211            return;
212        }
213
214        // Get colors: prefer CSS if available, otherwise use variant colors
215        let (fg, bg) = self.get_colors_from_ctx(ctx);
216
217        // Calculate content width (icon + space + label)
218        let icon_width = if self.icon.is_some() { 2u16 } else { 0 }; // icon + space
219        let label_width = self.label.chars().count() as u16;
220        let content_width = icon_width + label_width;
221        let padding = 2; // 1 space on each side
222        let min_width = self.width.unwrap_or(0);
223        let button_width = (content_width + padding * 2).max(min_width).min(area.width);
224
225        // Render button background
226        for x in 0..button_width {
227            let mut cell = Cell::new(' ');
228            cell.bg = Some(bg);
229            ctx.buffer.set(area.x + x, area.y, cell);
230        }
231
232        // Calculate content start position for centering
233        let content_start = (button_width.saturating_sub(content_width)) / 2;
234        let mut x = area.x + content_start;
235
236        // Render icon if present
237        if let Some(icon) = self.icon {
238            if x < area.x + button_width {
239                let mut cell = Cell::new(icon);
240                cell.fg = Some(fg);
241                cell.bg = Some(bg);
242                if self.state.focused && !self.state.disabled {
243                    cell.modifier = crate::render::Modifier::BOLD;
244                }
245                ctx.buffer.set(x, area.y, cell);
246                x += 1;
247
248                // Space after icon
249                if x < area.x + button_width {
250                    let mut space = Cell::new(' ');
251                    space.bg = Some(bg);
252                    ctx.buffer.set(x, area.y, space);
253                    x += 1;
254                }
255            }
256        }
257
258        // Render label
259        if self.state.focused && !self.state.disabled {
260            ctx.draw_text_bg_bold(x, area.y, &self.label, fg, bg);
261        } else {
262            ctx.draw_text_bg(x, area.y, &self.label, fg, bg);
263        }
264
265        // Render focus indicator
266        if self.state.focused && !self.state.disabled {
267            // Add brackets around button when focused
268            if area.x > 0 {
269                let mut left = Cell::new('[');
270                left.fg = Some(Color::CYAN);
271                ctx.buffer.set(area.x.saturating_sub(1), area.y, left);
272            }
273
274            let right_x = area.x + button_width;
275            if right_x < area.x + area.width {
276                let mut right = Cell::new(']');
277                right.fg = Some(Color::CYAN);
278                ctx.buffer.set(right_x, area.y, right);
279            }
280        }
281    }
282
283    crate::impl_view_meta!("Button");
284}
285
286impl Interactive for Button {
287    fn handle_key(&mut self, event: &KeyEvent) -> EventResult {
288        if self.state.disabled {
289            return EventResult::Ignored;
290        }
291
292        match event.key {
293            Key::Enter | Key::Char(' ') => EventResult::ConsumedAndRender,
294            _ => EventResult::Ignored,
295        }
296    }
297
298    fn handle_mouse(&mut self, event: &MouseEvent, area: Rect) -> EventResult {
299        if self.state.disabled {
300            return EventResult::Ignored;
301        }
302
303        let inside = area.contains(event.x, event.y);
304
305        match event.kind {
306            MouseEventKind::Down(MouseButton::Left) if inside => {
307                if !self.state.pressed {
308                    self.state.pressed = true;
309                    return EventResult::ConsumedAndRender;
310                }
311                EventResult::Consumed
312            }
313            MouseEventKind::Up(MouseButton::Left) => {
314                if self.state.pressed {
315                    self.state.pressed = false;
316                    // Click event is signaled by ConsumedAndRender when inside
317                    return if inside {
318                        EventResult::ConsumedAndRender
319                    } else {
320                        EventResult::Consumed
321                    };
322                }
323                EventResult::Ignored
324            }
325            MouseEventKind::Move => {
326                let was_hovered = self.state.hovered;
327                self.state.hovered = inside;
328                if was_hovered != self.state.hovered {
329                    EventResult::ConsumedAndRender
330                } else {
331                    EventResult::Ignored
332                }
333            }
334            _ => EventResult::Ignored,
335        }
336    }
337
338    fn focusable(&self) -> bool {
339        !self.state.disabled
340    }
341
342    fn on_focus(&mut self) {
343        self.state.focused = true;
344    }
345
346    fn on_blur(&mut self) {
347        self.state.focused = false;
348        self.state.reset_transient();
349    }
350}
351
352/// Create a button
353pub fn button(label: impl Into<String>) -> Button {
354    Button::new(label)
355}
356
357impl_styled_view!(Button);
358impl_widget_builders!(Button);
359
360// Most tests moved to tests/widget_tests.rs
361// Tests below access private fields and must stay inline
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn test_button_new() {
369        let btn = Button::new("Click");
370        assert_eq!(btn.label, "Click");
371        assert!(!btn.is_focused());
372        assert!(!btn.is_disabled());
373    }
374
375    #[test]
376    fn test_button_variants() {
377        let primary = Button::primary("Primary");
378        assert_eq!(primary.variant, ButtonVariant::Primary);
379
380        let danger = Button::danger("Danger");
381        assert_eq!(danger.variant, ButtonVariant::Danger);
382
383        let ghost = Button::ghost("Ghost");
384        assert_eq!(ghost.variant, ButtonVariant::Ghost);
385
386        let success = Button::success("Success");
387        assert_eq!(success.variant, ButtonVariant::Success);
388    }
389
390    #[test]
391    fn test_button_builder() {
392        let btn = Button::new("Test")
393            .variant(ButtonVariant::Primary)
394            .focused(true)
395            .disabled(false)
396            .width(20);
397
398        assert_eq!(btn.variant, ButtonVariant::Primary);
399        assert!(btn.is_focused());
400        assert!(!btn.is_disabled());
401        assert_eq!(btn.width, Some(20));
402    }
403
404    #[test]
405    fn test_button_handle_key() {
406        let mut btn = Button::new("Test");
407
408        assert!(btn.handle_key(&Key::Enter));
409        assert!(btn.handle_key(&Key::Char(' ')));
410        assert!(!btn.handle_key(&Key::Char('a')));
411
412        btn.state.disabled = true;
413        assert!(!btn.handle_key(&Key::Enter));
414    }
415
416    #[test]
417    fn test_button_helper() {
418        let btn = button("Helper");
419        assert_eq!(btn.label, "Helper");
420    }
421
422    #[test]
423    fn test_button_custom_colors() {
424        let btn = Button::new("Custom").fg(Color::RED).bg(Color::BLUE);
425
426        assert_eq!(btn.state.fg, Some(Color::RED));
427        assert_eq!(btn.state.bg, Some(Color::BLUE));
428    }
429
430    #[test]
431    fn test_button_with_icon() {
432        let btn = Button::new("Save").icon('💾');
433        assert_eq!(btn.icon, Some('💾'));
434        assert_eq!(btn.label, "Save");
435    }
436
437    #[test]
438    fn test_button_icon_width() {
439        let btn_no_icon = Button::new("OK");
440        let btn_with_icon = Button::new("OK").icon('✓');
441
442        assert!(btn_with_icon.icon.is_some());
443        assert!(btn_no_icon.icon.is_none());
444    }
445}