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 CheckBoxStyle {
129    /// ASCII style with brackets: `[x]` and `[ ]`
130    pub fn ascii() -> Self {
131        Self::default()
132    }
133
134    /// Unicode box style: `☑` and `☐`
135    pub fn unicode() -> Self {
136        Self {
137            checked_symbol: "☑",
138            unchecked_symbol: "☐",
139            ..Default::default()
140        }
141    }
142
143    /// Unicode checkmark style: `✓` and `○`
144    pub fn checkmark() -> Self {
145        Self {
146            checked_symbol: "✓",
147            unchecked_symbol: "○",
148            ..Default::default()
149        }
150    }
151
152    /// Custom symbols.
153    pub fn custom(checked: &'static str, unchecked: &'static str) -> Self {
154        Self {
155            checked_symbol: checked,
156            unchecked_symbol: unchecked,
157            ..Default::default()
158        }
159    }
160
161    /// Set the focused foreground color.
162    pub fn focused_fg(mut self, color: Color) -> Self {
163        self.focused_fg = color;
164        self
165    }
166
167    /// Set the unfocused foreground color.
168    pub fn unfocused_fg(mut self, color: Color) -> Self {
169        self.unfocused_fg = color;
170        self
171    }
172
173    /// Set the disabled foreground color.
174    pub fn disabled_fg(mut self, color: Color) -> Self {
175        self.disabled_fg = color;
176        self
177    }
178
179    /// Set the checked foreground color.
180    pub fn checked_fg(mut self, color: Color) -> Self {
181        self.checked_fg = color;
182        self
183    }
184}
185
186/// CheckBox widget.
187///
188/// A toggleable checkbox with a label that supports focus styling
189/// and mouse click regions.
190pub struct CheckBox<'a> {
191    label: &'a str,
192    state: &'a CheckBoxState,
193    style: CheckBoxStyle,
194    focus_id: FocusId,
195}
196
197impl<'a> CheckBox<'a> {
198    /// Create a new checkbox.
199    ///
200    /// # Arguments
201    ///
202    /// * `label` - The text label displayed next to the checkbox
203    /// * `state` - Reference to the checkbox state
204    pub fn new(label: &'a str, state: &'a CheckBoxState) -> Self {
205        Self {
206            label,
207            state,
208            style: CheckBoxStyle::default(),
209            focus_id: FocusId::default(),
210        }
211    }
212
213    /// Set the checkbox style.
214    pub fn style(mut self, style: CheckBoxStyle) -> Self {
215        self.style = style;
216        self
217    }
218
219    /// Set the focus ID.
220    pub fn focus_id(mut self, id: FocusId) -> Self {
221        self.focus_id = id;
222        self
223    }
224
225    /// Build the display line for this checkbox.
226    fn build_line(&self) -> Line<'a> {
227        let symbol = if self.state.checked {
228            self.style.checked_symbol
229        } else {
230            self.style.unchecked_symbol
231        };
232
233        let fg_color = if !self.state.enabled {
234            self.style.disabled_fg
235        } else if self.state.focused {
236            self.style.focused_fg
237        } else if self.state.checked {
238            self.style.checked_fg
239        } else {
240            self.style.unfocused_fg
241        };
242
243        let mut style = Style::default().fg(fg_color);
244        if self.state.focused && self.state.enabled {
245            style = style.add_modifier(Modifier::BOLD);
246        }
247
248        Line::from(vec![
249            Span::styled(symbol, style),
250            Span::styled(" ", style),
251            Span::styled(self.label, style),
252        ])
253    }
254
255    /// Calculate width needed for this checkbox.
256    pub fn width(&self) -> u16 {
257        let symbol_len = if self.state.checked {
258            self.style.checked_symbol.chars().count()
259        } else {
260            self.style.unchecked_symbol.chars().count()
261        };
262        (symbol_len + 1 + self.label.chars().count()) as u16
263    }
264
265    /// Render the checkbox and return the click region.
266    ///
267    /// Use this method when you need to track click regions for mouse handling.
268    pub fn render_stateful(self, area: Rect, buf: &mut Buffer) -> ClickRegion<CheckBoxAction> {
269        let width = self.width().min(area.width);
270        let click_area = Rect::new(area.x, area.y, width, 1);
271
272        let line = self.build_line();
273        let paragraph = Paragraph::new(line);
274        paragraph.render(area, buf);
275
276        ClickRegion::new(click_area, CheckBoxAction::Toggle)
277    }
278}
279
280impl Widget for CheckBox<'_> {
281    fn render(self, area: Rect, buf: &mut Buffer) {
282        let line = self.build_line();
283        let paragraph = Paragraph::new(line);
284        paragraph.render(area, buf);
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn test_state_default() {
294        let state = CheckBoxState::default();
295        assert!(!state.checked);
296        assert!(!state.focused);
297        assert!(state.enabled);
298    }
299
300    #[test]
301    fn test_state_new() {
302        let state = CheckBoxState::new(true);
303        assert!(state.checked);
304        assert!(!state.focused);
305        assert!(state.enabled);
306    }
307
308    #[test]
309    fn test_toggle() {
310        let mut state = CheckBoxState::new(false);
311        assert!(!state.checked);
312
313        state.toggle();
314        assert!(state.checked);
315
316        state.toggle();
317        assert!(!state.checked);
318    }
319
320    #[test]
321    fn test_toggle_disabled() {
322        let mut state = CheckBoxState::new(false);
323        state.enabled = false;
324
325        state.toggle();
326        assert!(!state.checked); // Should not change when disabled
327    }
328
329    #[test]
330    fn test_set_checked() {
331        let mut state = CheckBoxState::new(false);
332
333        state.set_checked(true);
334        assert!(state.checked);
335
336        state.set_checked(false);
337        assert!(!state.checked);
338    }
339
340    #[test]
341    fn test_set_checked_disabled() {
342        let mut state = CheckBoxState::new(false);
343        state.enabled = false;
344
345        state.set_checked(true);
346        assert!(!state.checked); // Should not change when disabled
347    }
348
349    #[test]
350    fn test_style_default() {
351        let style = CheckBoxStyle::default();
352        assert_eq!(style.checked_symbol, "[x]");
353        assert_eq!(style.unchecked_symbol, "[ ]");
354    }
355
356    #[test]
357    fn test_style_unicode() {
358        let style = CheckBoxStyle::unicode();
359        assert_eq!(style.checked_symbol, "☑");
360        assert_eq!(style.unchecked_symbol, "☐");
361    }
362
363    #[test]
364    fn test_style_checkmark() {
365        let style = CheckBoxStyle::checkmark();
366        assert_eq!(style.checked_symbol, "✓");
367        assert_eq!(style.unchecked_symbol, "○");
368    }
369
370    #[test]
371    fn test_style_custom() {
372        let style = CheckBoxStyle::custom("ON", "OFF");
373        assert_eq!(style.checked_symbol, "ON");
374        assert_eq!(style.unchecked_symbol, "OFF");
375    }
376
377    #[test]
378    fn test_checkbox_width() {
379        let state = CheckBoxState::new(false);
380        let checkbox = CheckBox::new("Test", &state);
381
382        // "[ ] Test" = 3 + 1 + 4 = 8
383        assert_eq!(checkbox.width(), 8);
384    }
385
386    #[test]
387    fn test_checkbox_width_unicode() {
388        let state = CheckBoxState::new(true);
389        let checkbox = CheckBox::new("Test", &state).style(CheckBoxStyle::unicode());
390
391        // "☑ Test" = 1 + 1 + 4 = 6
392        assert_eq!(checkbox.width(), 6);
393    }
394
395    #[test]
396    fn test_render_basic() {
397        let state = CheckBoxState::new(true);
398        let checkbox = CheckBox::new("Test", &state);
399
400        let area = Rect::new(0, 0, 20, 1);
401        let mut buffer = Buffer::empty(area);
402
403        checkbox.render(area, &mut buffer);
404
405        // Check that content was rendered
406        let content: String = (0..8)
407            .map(|x| buffer[(x, 0)].symbol().to_string())
408            .collect();
409        assert!(content.contains("[x]"));
410    }
411
412    #[test]
413    fn test_render_stateful() {
414        let state = CheckBoxState::new(false);
415        let checkbox = CheckBox::new("Click me", &state);
416
417        let area = Rect::new(5, 3, 20, 1);
418        let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 10));
419
420        let click_region = checkbox.render_stateful(area, &mut buffer);
421
422        // Click region should match the checkbox area
423        assert_eq!(click_region.area.x, 5);
424        assert_eq!(click_region.area.y, 3);
425        assert_eq!(click_region.data, CheckBoxAction::Toggle);
426    }
427
428    #[test]
429    fn test_click_region_detection() {
430        let state = CheckBoxState::new(false);
431        let checkbox = CheckBox::new("Test", &state);
432
433        let area = Rect::new(10, 5, 20, 1);
434        let mut buffer = Buffer::empty(Rect::new(0, 0, 40, 10));
435
436        let click_region = checkbox.render_stateful(area, &mut buffer);
437
438        // Should detect clicks within the checkbox
439        assert!(click_region.contains(10, 5));
440        assert!(click_region.contains(15, 5));
441
442        // Should not detect clicks outside
443        assert!(!click_region.contains(9, 5));
444        assert!(!click_region.contains(10, 4));
445        assert!(!click_region.contains(10, 6));
446    }
447}