Skip to main content

fresh/view/controls/button/
mod.rs

1//! Button control for triggering actions
2//!
3//! Renders as: `[ Button Text ]`
4//!
5//! This module provides a complete button component with:
6//! - State management (`ButtonState`)
7//! - Rendering (`render_button`, `render_button_row`)
8//! - Input handling (`ButtonState::handle_mouse`)
9//! - Layout/hit testing (`ButtonLayout`)
10
11mod input;
12mod render;
13
14use ratatui::layout::Rect;
15use ratatui::style::Color;
16
17pub use input::ButtonEvent;
18pub use render::{render_button, render_button_row};
19
20use super::FocusState;
21
22/// State for a button control
23#[derive(Debug, Clone)]
24pub struct ButtonState {
25    /// Button label text
26    pub label: String,
27    /// Focus state
28    pub focus: FocusState,
29    /// Whether the button is currently pressed (for visual feedback)
30    pub pressed: bool,
31}
32
33impl ButtonState {
34    /// Create a new button state
35    pub fn new(label: impl Into<String>) -> Self {
36        Self {
37            label: label.into(),
38            focus: FocusState::Normal,
39            pressed: false,
40        }
41    }
42
43    /// Set the focus state
44    pub fn with_focus(mut self, focus: FocusState) -> Self {
45        self.focus = focus;
46        self
47    }
48
49    /// Check if the button can be activated
50    pub fn is_enabled(&self) -> bool {
51        self.focus != FocusState::Disabled
52    }
53
54    /// Set pressed state (for visual feedback)
55    pub fn set_pressed(&mut self, pressed: bool) {
56        self.pressed = pressed;
57    }
58}
59
60/// Colors for the button control
61#[derive(Debug, Clone, Copy)]
62pub struct ButtonColors {
63    /// Button text color
64    pub text: Color,
65    /// Border color
66    pub border: Color,
67    /// Background color (when pressed)
68    pub pressed_bg: Color,
69    /// Focused highlight color
70    pub focused: Color,
71    /// Hovered highlight color
72    pub hovered: Color,
73    /// Disabled color
74    pub disabled: Color,
75}
76
77impl Default for ButtonColors {
78    fn default() -> Self {
79        Self {
80            text: Color::White,
81            border: Color::Gray,
82            pressed_bg: Color::DarkGray,
83            focused: Color::Cyan,
84            hovered: Color::Blue,
85            disabled: Color::DarkGray,
86        }
87    }
88}
89
90impl ButtonColors {
91    /// Create colors from theme
92    pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
93        Self {
94            text: theme.editor_fg,
95            border: theme.line_number_fg,
96            pressed_bg: theme.selection_bg,
97            focused: theme.selection_bg,
98            hovered: theme.menu_hover_bg,
99            disabled: theme.line_number_fg,
100        }
101    }
102
103    /// Create a primary/accent button style
104    pub fn primary() -> Self {
105        Self {
106            text: Color::Black,
107            border: Color::Cyan,
108            pressed_bg: Color::LightCyan,
109            focused: Color::Cyan,
110            hovered: Color::LightCyan,
111            disabled: Color::DarkGray,
112        }
113    }
114
115    /// Create a danger/destructive button style
116    pub fn danger() -> Self {
117        Self {
118            text: Color::White,
119            border: Color::Red,
120            pressed_bg: Color::LightRed,
121            focused: Color::Red,
122            hovered: Color::LightRed,
123            disabled: Color::DarkGray,
124        }
125    }
126}
127
128/// Layout information returned after rendering for hit testing
129#[derive(Debug, Clone, Copy, Default)]
130pub struct ButtonLayout {
131    /// The clickable button area
132    pub button_area: Rect,
133}
134
135impl ButtonLayout {
136    /// Check if a point is within the button
137    pub fn contains(&self, x: u16, y: u16) -> bool {
138        x >= self.button_area.x
139            && x < self.button_area.x + self.button_area.width
140            && y >= self.button_area.y
141            && y < self.button_area.y + self.button_area.height
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use ratatui::backend::TestBackend;
149    use ratatui::Terminal;
150
151    fn test_frame<F>(width: u16, height: u16, f: F)
152    where
153        F: FnOnce(&mut ratatui::Frame, Rect),
154    {
155        let backend = TestBackend::new(width, height);
156        let mut terminal = Terminal::new(backend).unwrap();
157        terminal
158            .draw(|frame| {
159                let area = Rect::new(0, 0, width, height);
160                f(frame, area);
161            })
162            .unwrap();
163    }
164
165    #[test]
166    fn test_button_renders() {
167        test_frame(20, 1, |frame, area| {
168            let state = ButtonState::new("OK");
169            let colors = ButtonColors::default();
170            let layout = render_button(frame, area, &state, &colors);
171
172            assert_eq!(layout.button_area.width, 6); // "[ OK ]"
173        });
174    }
175
176    #[test]
177    fn test_button_hit_detection() {
178        test_frame(20, 1, |frame, area| {
179            let state = ButtonState::new("Click");
180            let colors = ButtonColors::default();
181            let layout = render_button(frame, area, &state, &colors);
182
183            // Inside button
184            assert!(layout.contains(0, 0));
185            assert!(layout.contains(5, 0));
186
187            // Outside button
188            assert!(!layout.contains(15, 0));
189        });
190    }
191
192    #[test]
193    fn test_button_row() {
194        test_frame(40, 1, |frame, area| {
195            let ok = ButtonState::new("OK");
196            let cancel = ButtonState::new("Cancel");
197            let colors = ButtonColors::default();
198
199            let layouts = render_button_row(frame, area, &[(&ok, &colors), (&cancel, &colors)], 2);
200
201            assert_eq!(layouts.len(), 2);
202            assert!(layouts[0].button_area.x < layouts[1].button_area.x);
203        });
204    }
205
206    #[test]
207    fn test_button_disabled() {
208        let state = ButtonState::new("Save").with_focus(FocusState::Disabled);
209        assert!(!state.is_enabled());
210    }
211
212    #[test]
213    fn test_button_pressed_state() {
214        let mut state = ButtonState::new("Submit");
215        assert!(!state.pressed);
216
217        state.set_pressed(true);
218        assert!(state.pressed);
219    }
220
221    #[test]
222    fn test_button_truncation() {
223        test_frame(8, 1, |frame, area| {
224            let state = ButtonState::new("Very Long Button Text");
225            let colors = ButtonColors::default();
226            let layout = render_button(frame, area, &state, &colors);
227
228            // Button should be truncated to fit
229            assert!(layout.button_area.width <= area.width);
230        });
231    }
232}