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
245impl From<&crate::theme::Theme> for ButtonStyle {
246    fn from(theme: &crate::theme::Theme) -> Self {
247        let p = &theme.palette;
248        Self {
249            variant: ButtonVariant::SingleLine,
250            focused_fg: p.highlight_fg,
251            focused_bg: p.highlight_bg,
252            unfocused_fg: p.text,
253            unfocused_bg: Color::DarkGray,
254            disabled_fg: p.text_disabled,
255            pressed_fg: p.pressed_fg,
256            pressed_bg: p.pressed_bg,
257            toggled_fg: p.highlight_fg,
258            toggled_bg: p.success,
259        }
260    }
261}
262
263/// Button widget.
264///
265/// A clickable button with various display styles.
266pub struct Button<'a> {
267    label: &'a str,
268    icon: Option<&'a str>,
269    state: &'a ButtonState,
270    style: ButtonStyle,
271    focus_id: FocusId,
272    alignment: Alignment,
273}
274
275impl<'a> Button<'a> {
276    /// Create a new button.
277    ///
278    /// # Arguments
279    ///
280    /// * `label` - The button text
281    /// * `state` - Reference to the button state
282    pub fn new(label: &'a str, state: &'a ButtonState) -> Self {
283        Self {
284            label,
285            icon: None,
286            state,
287            style: ButtonStyle::default(),
288            focus_id: FocusId::default(),
289            alignment: Alignment::Center,
290        }
291    }
292
293    /// Set an icon to display before the label.
294    pub fn icon(mut self, icon: &'a str) -> Self {
295        self.icon = Some(icon);
296        self
297    }
298
299    /// Set the button style.
300    pub fn style(mut self, style: ButtonStyle) -> Self {
301        self.style = style;
302        self
303    }
304
305    /// Apply a theme to this button.
306    pub fn theme(self, theme: &crate::theme::Theme) -> Self {
307        self.style(ButtonStyle::from(theme))
308    }
309
310    /// Set the button variant.
311    pub fn variant(mut self, variant: ButtonVariant) -> Self {
312        self.style.variant = variant;
313        self
314    }
315
316    /// Set the focus ID.
317    pub fn focus_id(mut self, id: FocusId) -> Self {
318        self.focus_id = id;
319        self
320    }
321
322    /// Set the text alignment.
323    pub fn alignment(mut self, alignment: Alignment) -> Self {
324        self.alignment = alignment;
325        self
326    }
327
328    /// Get the current style based on state.
329    fn current_style(&self) -> Style {
330        if !self.state.enabled {
331            Style::default().fg(self.style.disabled_fg)
332        } else if self.state.pressed {
333            Style::default()
334                .fg(self.style.pressed_fg)
335                .bg(self.style.pressed_bg)
336        } else if self.style.variant == ButtonVariant::Toggle && self.state.toggled {
337            Style::default()
338                .fg(self.style.toggled_fg)
339                .bg(self.style.toggled_bg)
340                .add_modifier(Modifier::BOLD)
341        } else if self.state.focused {
342            Style::default()
343                .fg(self.style.focused_fg)
344                .bg(self.style.focused_bg)
345                .add_modifier(Modifier::BOLD)
346        } else {
347            Style::default()
348                .fg(self.style.unfocused_fg)
349                .bg(self.style.unfocused_bg)
350        }
351    }
352
353    /// Build the button text.
354    fn build_text(&self) -> String {
355        match self.style.variant {
356            ButtonVariant::SingleLine | ButtonVariant::Toggle => {
357                if let Some(icon) = self.icon {
358                    format!(" {} {} ", icon, self.label)
359                } else {
360                    format!(" {} ", self.label)
361                }
362            }
363            ButtonVariant::Block | ButtonVariant::IconText | ButtonVariant::Minimal => {
364                if let Some(icon) = self.icon {
365                    format!("{} {}", icon, self.label)
366                } else {
367                    self.label.to_string()
368                }
369            }
370        }
371    }
372
373    /// Calculate minimum width for this button.
374    pub fn min_width(&self) -> u16 {
375        let text = self.build_text();
376        let text_len = text.chars().count() as u16;
377
378        match self.style.variant {
379            ButtonVariant::Block => text_len + 4, // Border + padding
380            _ => text_len,
381        }
382    }
383
384    /// Calculate minimum height for this button.
385    pub fn min_height(&self) -> u16 {
386        match self.style.variant {
387            ButtonVariant::Block => 3, // Border top + content + border bottom
388            _ => 1,
389        }
390    }
391
392    /// Render the button and return the click region.
393    ///
394    /// This method renders the button and returns a `ClickRegion` that you must
395    /// register with a `ClickRegionRegistry` to enable mouse click support.
396    ///
397    /// For a more convenient API, consider using `render_with_registry()` which
398    /// handles both rendering and registration in one call.
399    ///
400    /// # Example
401    ///
402    /// ```rust
403    /// use ratatui_interact::components::{Button, ButtonState};
404    /// use ratatui_interact::traits::ClickRegionRegistry;
405    /// use ratatui::layout::Rect;
406    /// use ratatui::buffer::Buffer;
407    ///
408    /// let state = ButtonState::enabled();
409    /// let button = Button::new("OK", &state);
410    /// let area = Rect::new(0, 0, 10, 1);
411    /// let mut buf = Buffer::empty(Rect::new(0, 0, 20, 5));
412    /// let mut registry: ClickRegionRegistry<usize> = ClickRegionRegistry::new();
413    ///
414    /// let region = button.render_stateful(area, &mut buf);
415    /// registry.register(region.area, 0);
416    /// ```
417    pub fn render_stateful(self, area: Rect, buf: &mut Buffer) -> ClickRegion<ButtonAction> {
418        let click_area = match self.style.variant {
419            ButtonVariant::Block => area,
420            _ => Rect::new(area.x, area.y, self.min_width().min(area.width), 1),
421        };
422
423        self.render(area, buf);
424
425        ClickRegion::new(click_area, ButtonAction::Click)
426    }
427
428    /// Render the button and automatically register its click region.
429    ///
430    /// This is a convenience method that combines `render_stateful()` with
431    /// registry registration. Use this when you have a `ClickRegionRegistry`
432    /// and want to avoid the two-step render + register pattern.
433    ///
434    /// # Arguments
435    ///
436    /// * `area` - The area to render the button in
437    /// * `buf` - The buffer to render to
438    /// * `registry` - The click region registry to register with
439    /// * `data` - The data to associate with this button's click region
440    ///
441    /// # Example
442    ///
443    /// ```rust
444    /// use ratatui_interact::components::{Button, ButtonState};
445    /// use ratatui_interact::traits::ClickRegionRegistry;
446    /// use ratatui::layout::Rect;
447    /// use ratatui::buffer::Buffer;
448    ///
449    /// let state = ButtonState::enabled();
450    /// let mut registry: ClickRegionRegistry<usize> = ClickRegionRegistry::new();
451    ///
452    /// // Clear before each render cycle
453    /// registry.clear();
454    ///
455    /// let button = Button::new("OK", &state);
456    /// let area = Rect::new(0, 0, 10, 1);
457    /// let mut buf = Buffer::empty(Rect::new(0, 0, 20, 5));
458    ///
459    /// // Render and register in one call
460    /// button.render_with_registry(area, &mut buf, &mut registry, 0);
461    ///
462    /// // Later, check for clicks
463    /// if let Some(&idx) = registry.handle_click(5, 0) {
464    ///     println!("Button {} clicked!", idx);
465    /// }
466    /// ```
467    pub fn render_with_registry<D: Clone>(
468        self,
469        area: Rect,
470        buf: &mut Buffer,
471        registry: &mut ClickRegionRegistry<D>,
472        data: D,
473    ) {
474        let region = self.render_stateful(area, buf);
475        registry.register(region.area, data);
476    }
477}
478
479impl Widget for Button<'_> {
480    fn render(self, area: Rect, buf: &mut Buffer) {
481        let style = self.current_style();
482        let text = self.build_text();
483
484        match self.style.variant {
485            ButtonVariant::SingleLine | ButtonVariant::Toggle | ButtonVariant::Minimal => {
486                let line = Line::from(Span::styled(text, style));
487                let paragraph = Paragraph::new(line).alignment(self.alignment);
488                paragraph.render(area, buf);
489            }
490
491            ButtonVariant::Block => {
492                let block = Block::default().borders(Borders::ALL).border_style(style);
493
494                let inner = block.inner(area);
495                block.render(area, buf);
496
497                let paragraph = Paragraph::new(text).style(style).alignment(self.alignment);
498                paragraph.render(inner, buf);
499            }
500
501            ButtonVariant::IconText => {
502                let line = Line::from(Span::styled(text, style));
503                let paragraph = Paragraph::new(line);
504                paragraph.render(area, buf);
505            }
506        }
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn test_state_default() {
516        let state = ButtonState::default();
517        assert!(!state.focused);
518        assert!(!state.pressed);
519        assert!(state.enabled);
520        assert!(!state.toggled);
521    }
522
523    #[test]
524    fn test_state_enabled() {
525        let state = ButtonState::enabled();
526        assert!(state.enabled);
527        assert!(!state.focused);
528    }
529
530    #[test]
531    fn test_state_disabled() {
532        let state = ButtonState::disabled();
533        assert!(!state.enabled);
534    }
535
536    #[test]
537    fn test_state_toggled() {
538        let state = ButtonState::toggled(true);
539        assert!(state.toggled);
540        assert!(state.enabled);
541    }
542
543    #[test]
544    fn test_toggle() {
545        let mut state = ButtonState::enabled();
546        assert!(!state.toggled);
547
548        state.toggle();
549        assert!(state.toggled);
550
551        state.toggle();
552        assert!(!state.toggled);
553    }
554
555    #[test]
556    fn test_toggle_disabled() {
557        let mut state = ButtonState::disabled();
558        state.toggled = false;
559
560        state.toggle();
561        assert!(!state.toggled); // Should not change when disabled
562    }
563
564    #[test]
565    fn test_button_text_single_line() {
566        let state = ButtonState::enabled();
567        let button = Button::new("Click", &state).variant(ButtonVariant::SingleLine);
568
569        assert_eq!(button.build_text(), " Click ");
570    }
571
572    #[test]
573    fn test_button_text_with_icon() {
574        let state = ButtonState::enabled();
575        let button = Button::new("Save", &state).icon("💾");
576
577        assert_eq!(button.build_text(), " 💾 Save ");
578    }
579
580    #[test]
581    fn test_button_min_width() {
582        let state = ButtonState::enabled();
583
584        let button = Button::new("OK", &state).variant(ButtonVariant::SingleLine);
585        assert_eq!(button.min_width(), 4); // " OK "
586
587        let button = Button::new("OK", &state).variant(ButtonVariant::Block);
588        assert_eq!(button.min_width(), 6); // "OK" + 4 for border
589    }
590
591    #[test]
592    fn test_button_min_height() {
593        let state = ButtonState::enabled();
594
595        let button = Button::new("OK", &state).variant(ButtonVariant::SingleLine);
596        assert_eq!(button.min_height(), 1);
597
598        let button = Button::new("OK", &state).variant(ButtonVariant::Block);
599        assert_eq!(button.min_height(), 3);
600    }
601
602    #[test]
603    fn test_render_stateful() {
604        let state = ButtonState::enabled();
605        let button = Button::new("Test", &state);
606
607        let area = Rect::new(5, 3, 20, 1);
608        let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 10));
609
610        let click_region = button.render_stateful(area, &mut buffer);
611
612        assert_eq!(click_region.area.x, 5);
613        assert_eq!(click_region.area.y, 3);
614        assert_eq!(click_region.data, ButtonAction::Click);
615    }
616
617    #[test]
618    fn test_render_with_registry() {
619        use crate::traits::ClickRegionRegistry;
620
621        let state = ButtonState::enabled();
622        let button = Button::new("Click", &state);
623        let area = Rect::new(5, 3, 20, 1);
624        let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 10));
625        let mut registry: ClickRegionRegistry<&str> = ClickRegionRegistry::new();
626
627        button.render_with_registry(area, &mut buffer, &mut registry, "test_button");
628
629        // Verify registry has the region
630        assert_eq!(registry.len(), 1);
631
632        // Verify click detection works - click inside the button area
633        assert_eq!(registry.handle_click(5, 3), Some(&"test_button"));
634
635        // Verify click outside returns None
636        assert_eq!(registry.handle_click(100, 100), None);
637    }
638
639    #[test]
640    fn test_render_with_registry_multiple_buttons() {
641        use crate::traits::ClickRegionRegistry;
642
643        let mut registry: ClickRegionRegistry<usize> = ClickRegionRegistry::new();
644        let mut buffer = Buffer::empty(Rect::new(0, 0, 50, 10));
645
646        // Render three buttons
647        let state = ButtonState::enabled();
648
649        let button1 = Button::new("OK", &state);
650        button1.render_with_registry(Rect::new(0, 0, 10, 1), &mut buffer, &mut registry, 0);
651
652        let button2 = Button::new("Cancel", &state);
653        button2.render_with_registry(Rect::new(15, 0, 12, 1), &mut buffer, &mut registry, 1);
654
655        let button3 = Button::new("Help", &state);
656        button3.render_with_registry(Rect::new(30, 0, 10, 1), &mut buffer, &mut registry, 2);
657
658        // Verify all buttons are registered
659        assert_eq!(registry.len(), 3);
660
661        // Verify clicking each button returns the correct index
662        assert_eq!(registry.handle_click(2, 0), Some(&0)); // OK
663        assert_eq!(registry.handle_click(18, 0), Some(&1)); // Cancel
664        assert_eq!(registry.handle_click(32, 0), Some(&2)); // Help
665
666        // Verify clicking in gaps returns None
667        assert_eq!(registry.handle_click(12, 0), None);
668    }
669
670    #[test]
671    fn test_style_presets() {
672        let primary = ButtonStyle::primary();
673        assert_eq!(primary.focused_bg, Color::Blue);
674
675        let danger = ButtonStyle::danger();
676        assert_eq!(danger.focused_bg, Color::Red);
677
678        let success = ButtonStyle::success();
679        assert_eq!(success.focused_bg, Color::Green);
680    }
681
682    #[test]
683    fn test_style_builder() {
684        let style = ButtonStyle::default()
685            .variant(ButtonVariant::Toggle)
686            .focused(Color::White, Color::Cyan)
687            .toggled(Color::Black, Color::Magenta);
688
689        assert_eq!(style.variant, ButtonVariant::Toggle);
690        assert_eq!(style.focused_fg, Color::White);
691        assert_eq!(style.focused_bg, Color::Cyan);
692        assert_eq!(style.toggled_fg, Color::Black);
693        assert_eq!(style.toggled_bg, Color::Magenta);
694    }
695
696    #[test]
697    fn test_current_style_states() {
698        // Disabled state
699        let state = ButtonState::disabled();
700        let button = Button::new("Test", &state);
701        let style = button.current_style();
702        assert_eq!(style.fg, Some(button.style.disabled_fg));
703
704        // Focused state
705        let mut state = ButtonState::enabled();
706        state.focused = true;
707        let button = Button::new("Test", &state);
708        let style = button.current_style();
709        assert_eq!(style.fg, Some(button.style.focused_fg));
710        assert_eq!(style.bg, Some(button.style.focused_bg));
711
712        // Toggled state
713        let mut state = ButtonState::enabled();
714        state.toggled = true;
715        let button = Button::new("Test", &state).variant(ButtonVariant::Toggle);
716        let style = button.current_style();
717        assert_eq!(style.fg, Some(button.style.toggled_fg));
718        assert_eq!(style.bg, Some(button.style.toggled_bg));
719    }
720}