Skip to main content

ratatui_interact/components/
button.rs

1//! Button component - Various button views
2//!
3//! Supports single-line, multi-line (block), icon+text, and toggle button styles.
4//!
5//! # Example
6//!
7//! ```rust
8//! use ratatui_interact::components::{Button, ButtonState, ButtonVariant};
9//!
10//! let state = ButtonState::enabled();
11//!
12//! // Single line button
13//! let button = Button::new("Submit", &state)
14//!     .variant(ButtonVariant::SingleLine);
15//!
16//! // Icon button
17//! let save_btn = Button::new("Save", &state)
18//!     .icon("💾");
19//!
20//! // Toggle button
21//! let mut toggle_state = ButtonState::enabled();
22//! toggle_state.toggled = true;
23//! let toggle = Button::new("Dark Mode", &toggle_state)
24//!     .variant(ButtonVariant::Toggle);
25//! ```
26
27use ratatui::{
28    buffer::Buffer,
29    layout::{Alignment, Rect},
30    style::{Color, Modifier, Style},
31    text::{Line, Span},
32    widgets::{Block, Borders, Paragraph, Widget},
33};
34
35use crate::traits::{ClickRegion, ClickRegionRegistry, FocusId};
36
37/// Actions a button can emit.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ButtonAction {
40    /// Button was clicked/activated.
41    Click,
42}
43
44/// State for a button.
45#[derive(Debug, Clone)]
46pub struct ButtonState {
47    /// Whether the button has focus.
48    pub focused: bool,
49    /// Whether the button is currently pressed.
50    pub pressed: bool,
51    /// Whether the button is enabled.
52    pub enabled: bool,
53    /// For toggle buttons: whether the button is toggled on.
54    pub toggled: bool,
55}
56
57impl Default for ButtonState {
58    fn default() -> Self {
59        Self {
60            focused: false,
61            pressed: false,
62            enabled: true,
63            toggled: false,
64        }
65    }
66}
67
68impl ButtonState {
69    /// Create an enabled button state.
70    pub fn enabled() -> Self {
71        Self {
72            enabled: true,
73            ..Default::default()
74        }
75    }
76
77    /// Create a disabled button state.
78    pub fn disabled() -> Self {
79        Self {
80            enabled: false,
81            ..Default::default()
82        }
83    }
84
85    /// Create a toggled-on button state.
86    pub fn toggled(toggled: bool) -> Self {
87        Self {
88            toggled,
89            enabled: true,
90            ..Default::default()
91        }
92    }
93
94    /// Set the focus state.
95    pub fn set_focused(&mut self, focused: bool) {
96        self.focused = focused;
97    }
98
99    /// Set the pressed state.
100    pub fn set_pressed(&mut self, pressed: bool) {
101        self.pressed = pressed;
102    }
103
104    /// Set the enabled state.
105    pub fn set_enabled(&mut self, enabled: bool) {
106        self.enabled = enabled;
107    }
108
109    /// Toggle the toggled state.
110    pub fn toggle(&mut self) {
111        if self.enabled {
112            self.toggled = !self.toggled;
113        }
114    }
115}
116
117/// Button style variants.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
119pub enum ButtonVariant {
120    /// Single line button: `[ Text ]`
121    #[default]
122    SingleLine,
123    /// Multi-line block button with border.
124    Block,
125    /// Icon with text: `🔍 Search`
126    IconText,
127    /// Toggle button (highlighted when toggled).
128    Toggle,
129    /// Minimal style - just text, changes color on focus.
130    Minimal,
131}
132
133/// Button styling.
134#[derive(Debug, Clone)]
135pub struct ButtonStyle {
136    /// The button variant.
137    pub variant: ButtonVariant,
138    /// Foreground color when focused.
139    pub focused_fg: Color,
140    /// Background color when focused.
141    pub focused_bg: Color,
142    /// Foreground color when unfocused.
143    pub unfocused_fg: Color,
144    /// Background color when unfocused.
145    pub unfocused_bg: Color,
146    /// Foreground color when disabled.
147    pub disabled_fg: Color,
148    /// Foreground color when pressed.
149    pub pressed_fg: Color,
150    /// Background color when pressed.
151    pub pressed_bg: Color,
152    /// Foreground color when toggled.
153    pub toggled_fg: Color,
154    /// Background color when toggled.
155    pub toggled_bg: Color,
156}
157
158impl Default for ButtonStyle {
159    fn default() -> Self {
160        Self {
161            variant: ButtonVariant::SingleLine,
162            focused_fg: Color::Black,
163            focused_bg: Color::Yellow,
164            unfocused_fg: Color::White,
165            unfocused_bg: Color::DarkGray,
166            disabled_fg: Color::DarkGray,
167            pressed_fg: Color::Black,
168            pressed_bg: Color::White,
169            toggled_fg: Color::Black,
170            toggled_bg: Color::Green,
171        }
172    }
173}
174
175impl ButtonStyle {
176    /// Create a style for a specific variant.
177    pub fn new(variant: ButtonVariant) -> Self {
178        Self {
179            variant,
180            ..Default::default()
181        }
182    }
183
184    /// Set the variant.
185    pub fn variant(mut self, variant: ButtonVariant) -> Self {
186        self.variant = variant;
187        self
188    }
189
190    /// Set focused colors.
191    pub fn focused(mut self, fg: Color, bg: Color) -> Self {
192        self.focused_fg = fg;
193        self.focused_bg = bg;
194        self
195    }
196
197    /// Set unfocused colors.
198    pub fn unfocused(mut self, fg: Color, bg: Color) -> Self {
199        self.unfocused_fg = fg;
200        self.unfocused_bg = bg;
201        self
202    }
203
204    /// Set toggled colors.
205    pub fn toggled(mut self, fg: Color, bg: Color) -> Self {
206        self.toggled_fg = fg;
207        self.toggled_bg = bg;
208        self
209    }
210
211    /// Primary button style (prominent).
212    pub fn primary() -> Self {
213        Self {
214            focused_fg: Color::White,
215            focused_bg: Color::Blue,
216            unfocused_fg: Color::White,
217            unfocused_bg: Color::Rgb(50, 100, 200),
218            ..Default::default()
219        }
220    }
221
222    /// Danger/destructive button style.
223    pub fn danger() -> Self {
224        Self {
225            focused_fg: Color::White,
226            focused_bg: Color::Red,
227            unfocused_fg: Color::White,
228            unfocused_bg: Color::Rgb(150, 50, 50),
229            ..Default::default()
230        }
231    }
232
233    /// Success button style.
234    pub fn success() -> Self {
235        Self {
236            focused_fg: Color::White,
237            focused_bg: Color::Green,
238            unfocused_fg: Color::White,
239            unfocused_bg: Color::Rgb(50, 150, 50),
240            ..Default::default()
241        }
242    }
243}
244
245/// Button widget.
246///
247/// A clickable button with various display styles.
248pub struct Button<'a> {
249    label: &'a str,
250    icon: Option<&'a str>,
251    state: &'a ButtonState,
252    style: ButtonStyle,
253    focus_id: FocusId,
254    alignment: Alignment,
255}
256
257impl<'a> Button<'a> {
258    /// Create a new button.
259    ///
260    /// # Arguments
261    ///
262    /// * `label` - The button text
263    /// * `state` - Reference to the button state
264    pub fn new(label: &'a str, state: &'a ButtonState) -> Self {
265        Self {
266            label,
267            icon: None,
268            state,
269            style: ButtonStyle::default(),
270            focus_id: FocusId::default(),
271            alignment: Alignment::Center,
272        }
273    }
274
275    /// Set an icon to display before the label.
276    pub fn icon(mut self, icon: &'a str) -> Self {
277        self.icon = Some(icon);
278        self
279    }
280
281    /// Set the button style.
282    pub fn style(mut self, style: ButtonStyle) -> Self {
283        self.style = style;
284        self
285    }
286
287    /// Set the button variant.
288    pub fn variant(mut self, variant: ButtonVariant) -> Self {
289        self.style.variant = variant;
290        self
291    }
292
293    /// Set the focus ID.
294    pub fn focus_id(mut self, id: FocusId) -> Self {
295        self.focus_id = id;
296        self
297    }
298
299    /// Set the text alignment.
300    pub fn alignment(mut self, alignment: Alignment) -> Self {
301        self.alignment = alignment;
302        self
303    }
304
305    /// Get the current style based on state.
306    fn current_style(&self) -> Style {
307        if !self.state.enabled {
308            Style::default().fg(self.style.disabled_fg)
309        } else if self.state.pressed {
310            Style::default()
311                .fg(self.style.pressed_fg)
312                .bg(self.style.pressed_bg)
313        } else if self.style.variant == ButtonVariant::Toggle && self.state.toggled {
314            Style::default()
315                .fg(self.style.toggled_fg)
316                .bg(self.style.toggled_bg)
317                .add_modifier(Modifier::BOLD)
318        } else if self.state.focused {
319            Style::default()
320                .fg(self.style.focused_fg)
321                .bg(self.style.focused_bg)
322                .add_modifier(Modifier::BOLD)
323        } else {
324            Style::default()
325                .fg(self.style.unfocused_fg)
326                .bg(self.style.unfocused_bg)
327        }
328    }
329
330    /// Build the button text.
331    fn build_text(&self) -> String {
332        match self.style.variant {
333            ButtonVariant::SingleLine | ButtonVariant::Toggle => {
334                if let Some(icon) = self.icon {
335                    format!(" {} {} ", icon, self.label)
336                } else {
337                    format!(" {} ", self.label)
338                }
339            }
340            ButtonVariant::Block | ButtonVariant::IconText | ButtonVariant::Minimal => {
341                if let Some(icon) = self.icon {
342                    format!("{} {}", icon, self.label)
343                } else {
344                    self.label.to_string()
345                }
346            }
347        }
348    }
349
350    /// Calculate minimum width for this button.
351    pub fn min_width(&self) -> u16 {
352        let text = self.build_text();
353        let text_len = text.chars().count() as u16;
354
355        match self.style.variant {
356            ButtonVariant::Block => text_len + 4, // Border + padding
357            _ => text_len,
358        }
359    }
360
361    /// Calculate minimum height for this button.
362    pub fn min_height(&self) -> u16 {
363        match self.style.variant {
364            ButtonVariant::Block => 3, // Border top + content + border bottom
365            _ => 1,
366        }
367    }
368
369    /// Render the button and return the click region.
370    ///
371    /// This method renders the button and returns a `ClickRegion` that you must
372    /// register with a `ClickRegionRegistry` to enable mouse click support.
373    ///
374    /// For a more convenient API, consider using `render_with_registry()` which
375    /// handles both rendering and registration in one call.
376    ///
377    /// # Example
378    ///
379    /// ```rust
380    /// use ratatui_interact::components::{Button, ButtonState};
381    /// use ratatui_interact::traits::ClickRegionRegistry;
382    /// use ratatui::layout::Rect;
383    /// use ratatui::buffer::Buffer;
384    ///
385    /// let state = ButtonState::enabled();
386    /// let button = Button::new("OK", &state);
387    /// let area = Rect::new(0, 0, 10, 1);
388    /// let mut buf = Buffer::empty(Rect::new(0, 0, 20, 5));
389    /// let mut registry: ClickRegionRegistry<usize> = ClickRegionRegistry::new();
390    ///
391    /// let region = button.render_stateful(area, &mut buf);
392    /// registry.register(region.area, 0);
393    /// ```
394    pub fn render_stateful(self, area: Rect, buf: &mut Buffer) -> ClickRegion<ButtonAction> {
395        let click_area = match self.style.variant {
396            ButtonVariant::Block => area,
397            _ => Rect::new(area.x, area.y, area.width, 1),
398        };
399
400        self.render(area, buf);
401
402        ClickRegion::new(click_area, ButtonAction::Click)
403    }
404
405    /// Render the button and automatically register its click region.
406    ///
407    /// This is a convenience method that combines `render_stateful()` with
408    /// registry registration. Use this when you have a `ClickRegionRegistry`
409    /// and want to avoid the two-step render + register pattern.
410    ///
411    /// # Arguments
412    ///
413    /// * `area` - The area to render the button in
414    /// * `buf` - The buffer to render to
415    /// * `registry` - The click region registry to register with
416    /// * `data` - The data to associate with this button's click region
417    ///
418    /// # Example
419    ///
420    /// ```rust
421    /// use ratatui_interact::components::{Button, ButtonState};
422    /// use ratatui_interact::traits::ClickRegionRegistry;
423    /// use ratatui::layout::Rect;
424    /// use ratatui::buffer::Buffer;
425    ///
426    /// let state = ButtonState::enabled();
427    /// let mut registry: ClickRegionRegistry<usize> = ClickRegionRegistry::new();
428    ///
429    /// // Clear before each render cycle
430    /// registry.clear();
431    ///
432    /// let button = Button::new("OK", &state);
433    /// let area = Rect::new(0, 0, 10, 1);
434    /// let mut buf = Buffer::empty(Rect::new(0, 0, 20, 5));
435    ///
436    /// // Render and register in one call
437    /// button.render_with_registry(area, &mut buf, &mut registry, 0);
438    ///
439    /// // Later, check for clicks
440    /// if let Some(&idx) = registry.handle_click(5, 0) {
441    ///     println!("Button {} clicked!", idx);
442    /// }
443    /// ```
444    pub fn render_with_registry<D: Clone>(
445        self,
446        area: Rect,
447        buf: &mut Buffer,
448        registry: &mut ClickRegionRegistry<D>,
449        data: D,
450    ) {
451        let region = self.render_stateful(area, buf);
452        registry.register(region.area, data);
453    }
454}
455
456impl Widget for Button<'_> {
457    fn render(self, area: Rect, buf: &mut Buffer) {
458        let style = self.current_style();
459        let text = self.build_text();
460
461        match self.style.variant {
462            ButtonVariant::SingleLine | ButtonVariant::Toggle | ButtonVariant::Minimal => {
463                let line = Line::from(Span::styled(text, style));
464                let paragraph = Paragraph::new(line).alignment(self.alignment);
465                paragraph.render(area, buf);
466            }
467
468            ButtonVariant::Block => {
469                let block = Block::default().borders(Borders::ALL).border_style(style);
470
471                let inner = block.inner(area);
472                block.render(area, buf);
473
474                let paragraph = Paragraph::new(text).style(style).alignment(self.alignment);
475                paragraph.render(inner, buf);
476            }
477
478            ButtonVariant::IconText => {
479                let line = Line::from(Span::styled(text, style));
480                let paragraph = Paragraph::new(line);
481                paragraph.render(area, buf);
482            }
483        }
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn test_state_default() {
493        let state = ButtonState::default();
494        assert!(!state.focused);
495        assert!(!state.pressed);
496        assert!(state.enabled);
497        assert!(!state.toggled);
498    }
499
500    #[test]
501    fn test_state_enabled() {
502        let state = ButtonState::enabled();
503        assert!(state.enabled);
504        assert!(!state.focused);
505    }
506
507    #[test]
508    fn test_state_disabled() {
509        let state = ButtonState::disabled();
510        assert!(!state.enabled);
511    }
512
513    #[test]
514    fn test_state_toggled() {
515        let state = ButtonState::toggled(true);
516        assert!(state.toggled);
517        assert!(state.enabled);
518    }
519
520    #[test]
521    fn test_toggle() {
522        let mut state = ButtonState::enabled();
523        assert!(!state.toggled);
524
525        state.toggle();
526        assert!(state.toggled);
527
528        state.toggle();
529        assert!(!state.toggled);
530    }
531
532    #[test]
533    fn test_toggle_disabled() {
534        let mut state = ButtonState::disabled();
535        state.toggled = false;
536
537        state.toggle();
538        assert!(!state.toggled); // Should not change when disabled
539    }
540
541    #[test]
542    fn test_button_text_single_line() {
543        let state = ButtonState::enabled();
544        let button = Button::new("Click", &state).variant(ButtonVariant::SingleLine);
545
546        assert_eq!(button.build_text(), " Click ");
547    }
548
549    #[test]
550    fn test_button_text_with_icon() {
551        let state = ButtonState::enabled();
552        let button = Button::new("Save", &state).icon("💾");
553
554        assert_eq!(button.build_text(), " 💾 Save ");
555    }
556
557    #[test]
558    fn test_button_min_width() {
559        let state = ButtonState::enabled();
560
561        let button = Button::new("OK", &state).variant(ButtonVariant::SingleLine);
562        assert_eq!(button.min_width(), 4); // " OK "
563
564        let button = Button::new("OK", &state).variant(ButtonVariant::Block);
565        assert_eq!(button.min_width(), 6); // "OK" + 4 for border
566    }
567
568    #[test]
569    fn test_button_min_height() {
570        let state = ButtonState::enabled();
571
572        let button = Button::new("OK", &state).variant(ButtonVariant::SingleLine);
573        assert_eq!(button.min_height(), 1);
574
575        let button = Button::new("OK", &state).variant(ButtonVariant::Block);
576        assert_eq!(button.min_height(), 3);
577    }
578
579    #[test]
580    fn test_render_stateful() {
581        let state = ButtonState::enabled();
582        let button = Button::new("Test", &state);
583
584        let area = Rect::new(5, 3, 20, 1);
585        let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 10));
586
587        let click_region = button.render_stateful(area, &mut buffer);
588
589        assert_eq!(click_region.area.x, 5);
590        assert_eq!(click_region.area.y, 3);
591        assert_eq!(click_region.data, ButtonAction::Click);
592    }
593
594    #[test]
595    fn test_render_with_registry() {
596        use crate::traits::ClickRegionRegistry;
597
598        let state = ButtonState::enabled();
599        let button = Button::new("Click", &state);
600        let area = Rect::new(5, 3, 20, 1);
601        let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 10));
602        let mut registry: ClickRegionRegistry<&str> = ClickRegionRegistry::new();
603
604        button.render_with_registry(area, &mut buffer, &mut registry, "test_button");
605
606        // Verify registry has the region
607        assert_eq!(registry.len(), 1);
608
609        // Verify click detection works - click inside the button area
610        assert_eq!(registry.handle_click(5, 3), Some(&"test_button"));
611
612        // Verify click outside returns None
613        assert_eq!(registry.handle_click(100, 100), None);
614    }
615
616    #[test]
617    fn test_render_with_registry_multiple_buttons() {
618        use crate::traits::ClickRegionRegistry;
619
620        let mut registry: ClickRegionRegistry<usize> = ClickRegionRegistry::new();
621        let mut buffer = Buffer::empty(Rect::new(0, 0, 50, 10));
622
623        // Render three buttons
624        let state = ButtonState::enabled();
625
626        let button1 = Button::new("OK", &state);
627        button1.render_with_registry(Rect::new(0, 0, 10, 1), &mut buffer, &mut registry, 0);
628
629        let button2 = Button::new("Cancel", &state);
630        button2.render_with_registry(Rect::new(15, 0, 12, 1), &mut buffer, &mut registry, 1);
631
632        let button3 = Button::new("Help", &state);
633        button3.render_with_registry(Rect::new(30, 0, 10, 1), &mut buffer, &mut registry, 2);
634
635        // Verify all buttons are registered
636        assert_eq!(registry.len(), 3);
637
638        // Verify clicking each button returns the correct index
639        assert_eq!(registry.handle_click(2, 0), Some(&0)); // OK
640        assert_eq!(registry.handle_click(18, 0), Some(&1)); // Cancel
641        assert_eq!(registry.handle_click(32, 0), Some(&2)); // Help
642
643        // Verify clicking in gaps returns None
644        assert_eq!(registry.handle_click(12, 0), None);
645    }
646
647    #[test]
648    fn test_style_presets() {
649        let primary = ButtonStyle::primary();
650        assert_eq!(primary.focused_bg, Color::Blue);
651
652        let danger = ButtonStyle::danger();
653        assert_eq!(danger.focused_bg, Color::Red);
654
655        let success = ButtonStyle::success();
656        assert_eq!(success.focused_bg, Color::Green);
657    }
658
659    #[test]
660    fn test_style_builder() {
661        let style = ButtonStyle::default()
662            .variant(ButtonVariant::Toggle)
663            .focused(Color::White, Color::Cyan)
664            .toggled(Color::Black, Color::Magenta);
665
666        assert_eq!(style.variant, ButtonVariant::Toggle);
667        assert_eq!(style.focused_fg, Color::White);
668        assert_eq!(style.focused_bg, Color::Cyan);
669        assert_eq!(style.toggled_fg, Color::Black);
670        assert_eq!(style.toggled_bg, Color::Magenta);
671    }
672
673    #[test]
674    fn test_current_style_states() {
675        // Disabled state
676        let state = ButtonState::disabled();
677        let button = Button::new("Test", &state);
678        let style = button.current_style();
679        assert_eq!(style.fg, Some(button.style.disabled_fg));
680
681        // Focused state
682        let mut state = ButtonState::enabled();
683        state.focused = true;
684        let button = Button::new("Test", &state);
685        let style = button.current_style();
686        assert_eq!(style.fg, Some(button.style.focused_fg));
687        assert_eq!(style.bg, Some(button.style.focused_bg));
688
689        // Toggled state
690        let mut state = ButtonState::enabled();
691        state.toggled = true;
692        let button = Button::new("Test", &state).variant(ButtonVariant::Toggle);
693        let style = button.current_style();
694        assert_eq!(style.fg, Some(button.style.toggled_fg));
695        assert_eq!(style.bg, Some(button.style.toggled_bg));
696    }
697}