Skip to main content

revue/widget/input_widgets/
checkbox.rs

1//! Checkbox widget for boolean selection
2
3use crate::event::{Key, KeyEvent};
4use crate::render::Cell;
5use crate::style::Color;
6use crate::widget::traits::{
7    EventResult, Interactive, RenderContext, View, WidgetProps, WidgetState,
8};
9use crate::{impl_styled_view, impl_widget_builders};
10
11/// Checkbox style variants
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13pub enum CheckboxStyle {
14    /// Square brackets: \[x\] \[ \]
15    #[default]
16    Square,
17    /// Unicode checkmark: ☑ ☐
18    Unicode,
19    /// Filled box: ■ □
20    Filled,
21    /// Circle: ● ○
22    Circle,
23}
24
25impl CheckboxStyle {
26    /// Get the checked and unchecked characters for this style
27    fn chars(&self) -> (char, char) {
28        match self {
29            CheckboxStyle::Square => ('x', ' '),
30            CheckboxStyle::Unicode => ('☑', '☐'),
31            CheckboxStyle::Filled => ('■', '□'),
32            CheckboxStyle::Circle => ('●', '○'),
33        }
34    }
35
36    /// Get the bracket characters (if applicable)
37    fn brackets(&self) -> Option<(char, char)> {
38        match self {
39            CheckboxStyle::Square => Some(('[', ']')),
40            _ => None,
41        }
42    }
43}
44
45/// A checkbox widget for boolean selection
46#[derive(Clone, Debug)]
47pub struct Checkbox {
48    label: String,
49    checked: bool,
50    /// Common widget state (focused, disabled, colors)
51    state: WidgetState,
52    /// CSS styling properties (id, classes)
53    props: WidgetProps,
54    style: CheckboxStyle,
55    /// Custom checkmark color
56    check_fg: Option<Color>,
57}
58
59impl Checkbox {
60    /// Create a new checkbox with a label
61    pub fn new(label: impl Into<String>) -> Self {
62        Self {
63            label: label.into(),
64            checked: false,
65            state: WidgetState::new(),
66            props: WidgetProps::new(),
67            style: CheckboxStyle::default(),
68            check_fg: None,
69        }
70    }
71
72    /// Set checked state
73    pub fn checked(mut self, checked: bool) -> Self {
74        self.checked = checked;
75        self
76    }
77
78    /// Set checkbox style
79    pub fn style(mut self, style: CheckboxStyle) -> Self {
80        self.style = style;
81        self
82    }
83
84    /// Set checkmark color
85    pub fn check_fg(mut self, color: Color) -> Self {
86        self.check_fg = Some(color);
87        self
88    }
89
90    /// Check if checkbox is checked
91    pub fn is_checked(&self) -> bool {
92        self.checked
93    }
94
95    /// Set checked state (mutable)
96    pub fn set_checked(&mut self, checked: bool) {
97        self.checked = checked;
98    }
99
100    /// Toggle checked state
101    pub fn toggle(&mut self) {
102        if !self.state.disabled {
103            self.checked = !self.checked;
104        }
105    }
106
107    /// Handle key input, returns true if state changed
108    pub fn handle_key(&mut self, key: &Key) -> bool {
109        if self.state.disabled {
110            return false;
111        }
112
113        if matches!(key, Key::Enter | Key::Char(' ')) {
114            self.toggle();
115            true
116        } else {
117            false
118        }
119    }
120}
121
122impl Default for Checkbox {
123    fn default() -> Self {
124        Self::new("")
125    }
126}
127
128impl View for Checkbox {
129    fn render(&self, ctx: &mut RenderContext) {
130        let area = ctx.area;
131        if area.width == 0 || area.height == 0 {
132            return;
133        }
134
135        let (checked_char, unchecked_char) = self.style.chars();
136        let brackets = self.style.brackets();
137
138        let mut x = area.x;
139
140        // Resolve colors with CSS cascade: disabled > widget override > CSS > default
141        let label_fg = self.state.resolve_fg(ctx.style, Color::WHITE);
142
143        let check_fg = if self.state.disabled {
144            Color::rgb(100, 100, 100)
145        } else if self.checked {
146            self.check_fg.unwrap_or(Color::GREEN)
147        } else {
148            self.state.fg.unwrap_or(Color::rgb(150, 150, 150))
149        };
150
151        // Render focus indicator
152        if self.state.focused && !self.state.disabled {
153            let mut cell = Cell::new('>');
154            cell.fg = Some(Color::CYAN);
155            ctx.buffer.set(x, area.y, cell);
156            x += 1;
157
158            let space = Cell::new(' ');
159            ctx.buffer.set(x, area.y, space);
160            x += 1;
161        }
162
163        // Render checkbox
164        if let Some((left, right)) = brackets {
165            // Square style: [x] or [ ]
166            let mut left_cell = Cell::new(left);
167            left_cell.fg = Some(label_fg);
168            ctx.buffer.set(x, area.y, left_cell);
169            x += 1;
170
171            let check_char = if self.checked {
172                checked_char
173            } else {
174                unchecked_char
175            };
176            let mut check_cell = Cell::new(check_char);
177            check_cell.fg = Some(check_fg);
178            ctx.buffer.set(x, area.y, check_cell);
179            x += 1;
180
181            let mut right_cell = Cell::new(right);
182            right_cell.fg = Some(label_fg);
183            ctx.buffer.set(x, area.y, right_cell);
184            x += 1;
185        } else {
186            // Unicode style: ☑ or ☐
187            let check_char = if self.checked {
188                checked_char
189            } else {
190                unchecked_char
191            };
192            let mut check_cell = Cell::new(check_char);
193            check_cell.fg = Some(check_fg);
194            ctx.buffer.set(x, area.y, check_cell);
195            x += 1;
196        }
197
198        // Space before label
199        ctx.buffer.set(x, area.y, Cell::new(' '));
200        x += 1;
201
202        // Render label
203        for ch in self.label.chars() {
204            if x >= area.x + area.width {
205                break;
206            }
207            let mut cell = Cell::new(ch);
208            cell.fg = Some(label_fg);
209            if self.state.focused && !self.state.disabled {
210                cell.modifier = crate::render::Modifier::BOLD;
211            }
212            ctx.buffer.set(x, area.y, cell);
213            x += 1;
214        }
215    }
216
217    crate::impl_view_meta!("Checkbox");
218}
219
220impl Interactive for Checkbox {
221    fn handle_key(&mut self, event: &KeyEvent) -> EventResult {
222        if self.state.disabled {
223            return EventResult::Ignored;
224        }
225
226        match event.key {
227            Key::Enter | Key::Char(' ') => {
228                self.checked = !self.checked;
229                EventResult::ConsumedAndRender
230            }
231            _ => EventResult::Ignored,
232        }
233    }
234
235    fn focusable(&self) -> bool {
236        !self.state.disabled
237    }
238
239    fn on_focus(&mut self) {
240        self.state.focused = true;
241    }
242
243    fn on_blur(&mut self) {
244        self.state.focused = false;
245    }
246}
247
248/// Create a checkbox
249pub fn checkbox(label: impl Into<String>) -> Checkbox {
250    Checkbox::new(label)
251}
252
253impl_styled_view!(Checkbox);
254impl_widget_builders!(Checkbox);
255
256// Most tests moved to tests/widget_tests.rs
257// Tests below access private fields and must stay inline
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn test_checkbox_new() {
265        let cb = Checkbox::new("Accept terms");
266        assert_eq!(cb.label, "Accept terms");
267        assert!(!cb.is_checked());
268        assert!(!cb.is_focused());
269        assert!(!cb.is_disabled());
270    }
271
272    #[test]
273    fn test_checkbox_builder() {
274        let cb = Checkbox::new("Option")
275            .checked(true)
276            .focused(true)
277            .disabled(false)
278            .style(CheckboxStyle::Unicode);
279
280        assert!(cb.is_checked());
281        assert!(cb.is_focused());
282        assert!(!cb.is_disabled());
283        assert_eq!(cb.style, CheckboxStyle::Unicode);
284    }
285
286    #[test]
287    fn test_checkbox_styles() {
288        let square = CheckboxStyle::Square.chars();
289        assert_eq!(square, ('x', ' '));
290
291        let unicode = CheckboxStyle::Unicode.chars();
292        assert_eq!(unicode, ('☑', '☐'));
293
294        let filled = CheckboxStyle::Filled.chars();
295        assert_eq!(filled, ('■', '□'));
296
297        let circle = CheckboxStyle::Circle.chars();
298        assert_eq!(circle, ('●', '○'));
299    }
300
301    #[test]
302    fn test_checkbox_helper() {
303        let cb = checkbox("Helper");
304        assert_eq!(cb.label, "Helper");
305    }
306
307    #[test]
308    fn test_checkbox_custom_colors() {
309        let cb = Checkbox::new("Colored")
310            .fg(Color::WHITE)
311            .check_fg(Color::GREEN);
312
313        assert_eq!(cb.state.fg, Some(Color::WHITE));
314        assert_eq!(cb.check_fg, Some(Color::GREEN));
315    }
316}