Skip to main content

ratatui_interact/components/
checkbox.rs

1//! CheckBox component - Toggleable checkbox with label
2//!
3//! Supports keyboard focus, mouse clicks, and customizable styling.
4//!
5//! # Example
6//!
7//! ```rust
8//! use ratatui_interact::components::{CheckBox, CheckBoxState, CheckBoxStyle};
9//! use ratatui::{buffer::Buffer, layout::Rect};
10//!
11//! let mut state = CheckBoxState::new(false);
12//! let checkbox = CheckBox::new("Enable notifications", &state)
13//!     .style(CheckBoxStyle::unicode());
14//!
15//! // Toggle when activated
16//! state.toggle();
17//! assert!(state.checked);
18//! ```
19
20use ratatui::{
21    buffer::Buffer,
22    layout::Rect,
23    style::{Color, Modifier, Style},
24    text::{Line, Span},
25    widgets::{Paragraph, Widget},
26};
27
28use crate::traits::{ClickRegion, FocusId};
29
30/// Actions a checkbox can emit.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum CheckBoxAction {
33    /// Toggle the checkbox state.
34    Toggle,
35}
36
37/// State for a checkbox.
38#[derive(Debug, Clone)]
39pub struct CheckBoxState {
40    /// Whether the checkbox is checked.
41    pub checked: bool,
42    /// Whether the checkbox has focus.
43    pub focused: bool,
44    /// Whether the checkbox is enabled (can be toggled).
45    pub enabled: bool,
46}
47
48impl Default for CheckBoxState {
49    fn default() -> Self {
50        Self {
51            checked: false,
52            focused: false,
53            enabled: true,
54        }
55    }
56}
57
58impl CheckBoxState {
59    /// Create a new checkbox state.
60    ///
61    /// # Arguments
62    ///
63    /// * `checked` - Initial checked state
64    pub fn new(checked: bool) -> Self {
65        Self {
66            checked,
67            ..Default::default()
68        }
69    }
70
71    /// Toggle the checkbox state.
72    ///
73    /// Does nothing if the checkbox is disabled.
74    pub fn toggle(&mut self) {
75        if self.enabled {
76            self.checked = !self.checked;
77        }
78    }
79
80    /// Set the checked state.
81    pub fn set_checked(&mut self, checked: bool) {
82        if self.enabled {
83            self.checked = checked;
84        }
85    }
86
87    /// Set the focus state.
88    pub fn set_focused(&mut self, focused: bool) {
89        self.focused = focused;
90    }
91
92    /// Set the enabled state.
93    pub fn set_enabled(&mut self, enabled: bool) {
94        self.enabled = enabled;
95    }
96}
97
98/// Configuration for checkbox appearance.
99#[derive(Debug, Clone)]
100pub struct CheckBoxStyle {
101    /// Symbol when checked.
102    pub checked_symbol: &'static str,
103    /// Symbol when unchecked.
104    pub unchecked_symbol: &'static str,
105    /// Foreground color when focused.
106    pub focused_fg: Color,
107    /// Foreground color when unfocused.
108    pub unfocused_fg: Color,
109    /// Foreground color when disabled.
110    pub disabled_fg: Color,
111    /// Foreground color when checked (unfocused).
112    pub checked_fg: Color,
113}
114
115impl Default for CheckBoxStyle {
116    fn default() -> Self {
117        Self {
118            checked_symbol: "[x]",
119            unchecked_symbol: "[ ]",
120            focused_fg: Color::Yellow,
121            unfocused_fg: Color::White,
122            disabled_fg: Color::DarkGray,
123            checked_fg: Color::Green,
124        }
125    }
126}
127
128impl From<&crate::theme::Theme> for CheckBoxStyle {
129    fn from(theme: &crate::theme::Theme) -> Self {
130        let p = &theme.palette;
131        Self {
132            checked_symbol: "[x]",
133            unchecked_symbol: "[ ]",
134            focused_fg: p.primary,
135            unfocused_fg: p.text,
136            disabled_fg: p.text_disabled,
137            checked_fg: p.success,
138        }
139    }
140}
141
142impl CheckBoxStyle {
143    /// ASCII style with brackets: `[x]` and `[ ]`
144    pub fn ascii() -> Self {
145        Self::default()
146    }
147
148    /// Unicode box style: `☑` and `☐`
149    pub fn unicode() -> Self {
150        Self {
151            checked_symbol: "☑",
152            unchecked_symbol: "☐",
153            ..Default::default()
154        }
155    }
156
157    /// Unicode checkmark style: `✓` and `○`
158    pub fn checkmark() -> Self {
159        Self {
160            checked_symbol: "✓",
161            unchecked_symbol: "○",
162            ..Default::default()
163        }
164    }
165
166    /// Custom symbols.
167    pub fn custom(checked: &'static str, unchecked: &'static str) -> Self {
168        Self {
169            checked_symbol: checked,
170            unchecked_symbol: unchecked,
171            ..Default::default()
172        }
173    }
174
175    /// Set the focused foreground color.
176    pub fn focused_fg(mut self, color: Color) -> Self {
177        self.focused_fg = color;
178        self
179    }
180
181    /// Set the unfocused foreground color.
182    pub fn unfocused_fg(mut self, color: Color) -> Self {
183        self.unfocused_fg = color;
184        self
185    }
186
187    /// Set the disabled foreground color.
188    pub fn disabled_fg(mut self, color: Color) -> Self {
189        self.disabled_fg = color;
190        self
191    }
192
193    /// Set the checked foreground color.
194    pub fn checked_fg(mut self, color: Color) -> Self {
195        self.checked_fg = color;
196        self
197    }
198}
199
200/// CheckBox widget.
201///
202/// A toggleable checkbox with a label that supports focus styling
203/// and mouse click regions.
204pub struct CheckBox<'a> {
205    label: &'a str,
206    state: &'a CheckBoxState,
207    style: CheckBoxStyle,
208    focus_id: FocusId,
209}
210
211impl<'a> CheckBox<'a> {
212    /// Create a new checkbox.
213    ///
214    /// # Arguments
215    ///
216    /// * `label` - The text label displayed next to the checkbox
217    /// * `state` - Reference to the checkbox state
218    pub fn new(label: &'a str, state: &'a CheckBoxState) -> Self {
219        Self {
220            label,
221            state,
222            style: CheckBoxStyle::default(),
223            focus_id: FocusId::default(),
224        }
225    }
226
227    /// Set the checkbox style.
228    pub fn style(mut self, style: CheckBoxStyle) -> Self {
229        self.style = style;
230        self
231    }
232
233    /// Apply a theme to this checkbox.
234    pub fn theme(self, theme: &crate::theme::Theme) -> Self {
235        self.style(CheckBoxStyle::from(theme))
236    }
237
238    /// Set the focus ID.
239    pub fn focus_id(mut self, id: FocusId) -> Self {
240        self.focus_id = id;
241        self
242    }
243
244    /// Build the display line for this checkbox.
245    fn build_line(&self) -> Line<'a> {
246        let symbol = if self.state.checked {
247            self.style.checked_symbol
248        } else {
249            self.style.unchecked_symbol
250        };
251
252        let fg_color = if !self.state.enabled {
253            self.style.disabled_fg
254        } else if self.state.focused {
255            self.style.focused_fg
256        } else if self.state.checked {
257            self.style.checked_fg
258        } else {
259            self.style.unfocused_fg
260        };
261
262        let mut style = Style::default().fg(fg_color);
263        if self.state.focused && self.state.enabled {
264            style = style.add_modifier(Modifier::BOLD);
265        }
266
267        Line::from(vec![
268            Span::styled(symbol, style),
269            Span::styled(" ", style),
270            Span::styled(self.label, style),
271        ])
272    }
273
274    /// Calculate width needed for this checkbox.
275    pub fn width(&self) -> u16 {
276        let symbol_len = if self.state.checked {
277            self.style.checked_symbol.chars().count()
278        } else {
279            self.style.unchecked_symbol.chars().count()
280        };
281        (symbol_len + 1 + self.label.chars().count()) as u16
282    }
283
284    /// Render the checkbox and return the click region.
285    ///
286    /// Use this method when you need to track click regions for mouse handling.
287    pub fn render_stateful(self, area: Rect, buf: &mut Buffer) -> ClickRegion<CheckBoxAction> {
288        let width = self.width().min(area.width);
289        let click_area = Rect::new(area.x, area.y, width, 1);
290
291        let line = self.build_line();
292        let paragraph = Paragraph::new(line);
293        paragraph.render(area, buf);
294
295        ClickRegion::new(click_area, CheckBoxAction::Toggle)
296    }
297}
298
299impl Widget for CheckBox<'_> {
300    fn render(self, area: Rect, buf: &mut Buffer) {
301        let line = self.build_line();
302        let paragraph = Paragraph::new(line);
303        paragraph.render(area, buf);
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_state_default() {
313        let state = CheckBoxState::default();
314        assert!(!state.checked);
315        assert!(!state.focused);
316        assert!(state.enabled);
317    }
318
319    #[test]
320    fn test_state_new() {
321        let state = CheckBoxState::new(true);
322        assert!(state.checked);
323        assert!(!state.focused);
324        assert!(state.enabled);
325    }
326
327    #[test]
328    fn test_toggle() {
329        let mut state = CheckBoxState::new(false);
330        assert!(!state.checked);
331
332        state.toggle();
333        assert!(state.checked);
334
335        state.toggle();
336        assert!(!state.checked);
337    }
338
339    #[test]
340    fn test_toggle_disabled() {
341        let mut state = CheckBoxState::new(false);
342        state.enabled = false;
343
344        state.toggle();
345        assert!(!state.checked); // Should not change when disabled
346    }
347
348    #[test]
349    fn test_set_checked() {
350        let mut state = CheckBoxState::new(false);
351
352        state.set_checked(true);
353        assert!(state.checked);
354
355        state.set_checked(false);
356        assert!(!state.checked);
357    }
358
359    #[test]
360    fn test_set_checked_disabled() {
361        let mut state = CheckBoxState::new(false);
362        state.enabled = false;
363
364        state.set_checked(true);
365        assert!(!state.checked); // Should not change when disabled
366    }
367
368    #[test]
369    fn test_style_default() {
370        let style = CheckBoxStyle::default();
371        assert_eq!(style.checked_symbol, "[x]");
372        assert_eq!(style.unchecked_symbol, "[ ]");
373    }
374
375    #[test]
376    fn test_style_unicode() {
377        let style = CheckBoxStyle::unicode();
378        assert_eq!(style.checked_symbol, "☑");
379        assert_eq!(style.unchecked_symbol, "☐");
380    }
381
382    #[test]
383    fn test_style_checkmark() {
384        let style = CheckBoxStyle::checkmark();
385        assert_eq!(style.checked_symbol, "✓");
386        assert_eq!(style.unchecked_symbol, "○");
387    }
388
389    #[test]
390    fn test_style_custom() {
391        let style = CheckBoxStyle::custom("ON", "OFF");
392        assert_eq!(style.checked_symbol, "ON");
393        assert_eq!(style.unchecked_symbol, "OFF");
394    }
395
396    #[test]
397    fn test_checkbox_width() {
398        let state = CheckBoxState::new(false);
399        let checkbox = CheckBox::new("Test", &state);
400
401        // "[ ] Test" = 3 + 1 + 4 = 8
402        assert_eq!(checkbox.width(), 8);
403    }
404
405    #[test]
406    fn test_checkbox_width_unicode() {
407        let state = CheckBoxState::new(true);
408        let checkbox = CheckBox::new("Test", &state).style(CheckBoxStyle::unicode());
409
410        // "☑ Test" = 1 + 1 + 4 = 6
411        assert_eq!(checkbox.width(), 6);
412    }
413
414    #[test]
415    fn test_render_basic() {
416        let state = CheckBoxState::new(true);
417        let checkbox = CheckBox::new("Test", &state);
418
419        let area = Rect::new(0, 0, 20, 1);
420        let mut buffer = Buffer::empty(area);
421
422        checkbox.render(area, &mut buffer);
423
424        // Check that content was rendered
425        let content: String = (0..8)
426            .map(|x| buffer[(x, 0)].symbol().to_string())
427            .collect();
428        assert!(content.contains("[x]"));
429    }
430
431    #[test]
432    fn test_render_stateful() {
433        let state = CheckBoxState::new(false);
434        let checkbox = CheckBox::new("Click me", &state);
435
436        let area = Rect::new(5, 3, 20, 1);
437        let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 10));
438
439        let click_region = checkbox.render_stateful(area, &mut buffer);
440
441        // Click region should match the checkbox area
442        assert_eq!(click_region.area.x, 5);
443        assert_eq!(click_region.area.y, 3);
444        assert_eq!(click_region.data, CheckBoxAction::Toggle);
445    }
446
447    #[test]
448    fn test_click_region_detection() {
449        let state = CheckBoxState::new(false);
450        let checkbox = CheckBox::new("Test", &state);
451
452        let area = Rect::new(10, 5, 20, 1);
453        let mut buffer = Buffer::empty(Rect::new(0, 0, 40, 10));
454
455        let click_region = checkbox.render_stateful(area, &mut buffer);
456
457        // Should detect clicks within the checkbox
458        assert!(click_region.contains(10, 5));
459        assert!(click_region.contains(15, 5));
460
461        // Should not detect clicks outside
462        assert!(!click_region.contains(9, 5));
463        assert!(!click_region.contains(10, 4));
464        assert!(!click_region.contains(10, 6));
465    }
466}