Skip to main content

lv_tui/widgets/
checkbox.rs

1use crate::component::{Component, EventCx, MeasureCx};
2use crate::event::Event;
3use crate::geom::{Rect, Size};
4use crate::layout::Constraint;
5use crate::render::RenderCx;
6use crate::style::{Color, Style};
7use crate::text::Text;
8
9/// A checkbox widget with a label and toggleable state.
10///
11/// Press `Space` or `Enter` to toggle. Renders as `[✓] label` when checked
12/// and `[ ] label` when unchecked.
13pub struct Checkbox {
14    label: Text,
15    checked: bool,
16    focused: bool,
17    rect: Rect,
18    style: Style,
19    checked_style: Style,
20}
21
22impl Checkbox {
23    /// Creates a new unchecked checkbox with the given label.
24    pub fn new(label: impl Into<Text>) -> Self {
25        Self {
26            label: label.into(),
27            checked: false,
28            focused: false,
29            rect: Rect::default(),
30            style: Style::default(),
31            checked_style: Style::default().fg(crate::style::Color::Green),
32        }
33    }
34
35    /// Builder: sets the initial checked state.
36    pub fn checked(mut self) -> Self {
37        self.checked = true;
38        self
39    }
40
41    /// Builder: sets the default style.
42    pub fn style(mut self, style: Style) -> Self {
43        self.style = style;
44        self
45    }
46
47    /// Builder: sets the checked-state style.
48    pub fn checked_style(mut self, style: Style) -> Self {
49        self.checked_style = style;
50        self
51    }
52
53    /// Returns whether the checkbox is currently checked.
54    pub fn is_checked(&self) -> bool {
55        self.checked
56    }
57
58    /// Sets the checked state.
59    pub fn set_checked(&mut self, checked: bool, cx: &mut EventCx) {
60        if self.checked != checked {
61            self.checked = checked;
62            cx.invalidate_paint();
63        }
64    }
65
66    /// Toggles the checked state.
67    pub fn toggle(&mut self, cx: &mut EventCx) {
68        self.checked = !self.checked;
69        cx.invalidate_paint();
70    }
71}
72
73impl Component for Checkbox {
74    fn render(&self, cx: &mut RenderCx) {
75        let mark = if self.checked { "✓" } else { " " };
76        let text = format!("[{}] {}", mark, self.label.first_text());
77        if self.focused {
78            cx.set_style(self.checked_style.clone());
79        } else if self.checked {
80            cx.set_style(self.checked_style.clone());
81        } else {
82            cx.set_style(self.style.clone());
83        }
84        cx.line(&text);
85    }
86
87    fn measure(&self, _constraint: Constraint, _cx: &mut MeasureCx) -> Size {
88        Size { width: 5 + self.label.max_width(), height: 1 }
89    }
90
91    fn event(&mut self, event: &Event, cx: &mut EventCx) {
92        match event {
93            Event::Focus => { self.focused = true; cx.invalidate_paint(); return; }
94            Event::Blur => { self.focused = false; cx.invalidate_paint(); return; }
95            _ => {}
96        }
97
98        // Only handle key events during Target phase (when we have focus)
99        if cx.phase() != crate::event::EventPhase::Target { return; }
100
101        if let Event::Key(key_event) = event {
102            match &key_event.key {
103                crate::event::Key::Char(' ') | crate::event::Key::Enter => {
104                    self.toggle(cx);
105                }
106                _ => {}
107            }
108        }
109    }
110
111    fn layout(&mut self, rect: Rect, _cx: &mut crate::component::LayoutCx) { self.rect = rect; }
112    fn focusable(&self) -> bool { true }
113    fn style(&self) -> Style { self.style.clone() }
114}
115
116// ── RadioGroup ────────────────────────────────────────────────
117
118/// A radio group widget for mutually exclusive selection.
119///
120/// Press `↑`/`↓` to move selection, `Space`/`Enter` is not needed since
121/// selection changes on navigation. Renders as `(•) option` for selected
122/// and `( ) option` for unselected items.
123pub struct RadioGroup {
124    options: Vec<Text>,
125    selected: usize,
126    focused: bool,
127    rect: Rect,
128    style: Style,
129    selected_style: Style,
130}
131
132impl RadioGroup {
133    /// Creates a new radio group with the given options.
134    pub fn new(options: Vec<impl Into<Text>>) -> Self {
135        let options = options.into_iter().map(|o| o.into()).collect();
136        Self {
137            options,
138            selected: 0,
139            focused: false,
140            rect: Rect::default(),
141            style: Style::default(),
142            selected_style: Style::default().fg(crate::style::Color::Green),
143        }
144    }
145
146    /// Builder: sets the default style.
147    pub fn style(mut self, style: Style) -> Self {
148        self.style = style;
149        self
150    }
151
152    /// Builder: sets the selected-item style.
153    pub fn selected_style(mut self, style: Style) -> Self {
154        self.selected_style = style;
155        self
156    }
157
158    /// Returns the currently selected index.
159    pub fn selected(&self) -> usize {
160        self.selected
161    }
162
163    /// Returns the text of the currently selected option.
164    pub fn selected_text(&self) -> &str {
165        self.options.get(self.selected).map(|t| t.first_text()).unwrap_or("")
166    }
167
168    /// Sets the selected option.
169    pub fn set_selected(&mut self, index: usize, cx: &mut EventCx) {
170        if index < self.options.len() && index != self.selected {
171            self.selected = index;
172            cx.invalidate_paint();
173        }
174    }
175}
176
177impl Component for RadioGroup {
178    fn render(&self, cx: &mut RenderCx) {
179        // Fill background when focused for visual distinction
180        if self.focused {
181            for y in cx.rect.y..cx.rect.y + cx.rect.height {
182                for x in cx.rect.x..cx.rect.x + cx.rect.width {
183                    if let Some(cell) = cx.buffer.get_mut(x, y) {
184                        cell.style.bg = Some(Color::White);
185                    }
186                }
187            }
188        }
189        for (i, opt) in self.options.iter().enumerate() {
190            let (mark, style) = if i == self.selected {
191                if self.focused {
192                    ("•", Style::default().bg(Color::White).fg(Color::Black))
193                } else {
194                    ("•", Style::default().fg(Color::Green))
195                }
196            } else {
197                if self.focused {
198                    (" ", Style::default().bg(Color::White).fg(Color::Black))
199                } else {
200                    (" ", self.style.clone())
201                }
202            };
203            cx.set_style(style);
204            cx.line(&format!("({}) {}", mark, opt.first_text()));
205        }
206    }
207
208    fn measure(&self, _constraint: Constraint, _cx: &mut MeasureCx) -> Size {
209        let max_w = self.options.iter().map(|o| 4 + o.max_width()).max().unwrap_or(0);
210        Size { width: max_w, height: self.options.len() as u16 }
211    }
212
213    fn event(&mut self, event: &Event, cx: &mut EventCx) {
214        match event {
215            Event::Focus => { self.focused = true; cx.invalidate_paint(); return; }
216            Event::Blur => { self.focused = false; cx.invalidate_paint(); return; }
217            _ => {}
218        }
219
220        // Only handle key events during Target phase (when we have focus)
221        if cx.phase() != crate::event::EventPhase::Target { return; }
222        if self.options.is_empty() { return; }
223
224        if let Event::Key(key_event) = event {
225            match &key_event.key {
226                crate::event::Key::Up => {
227                    self.selected = if self.selected > 0 { self.selected - 1 } else { self.options.len() - 1 };
228                    cx.invalidate_paint();
229                }
230                crate::event::Key::Down => {
231                    self.selected = if self.selected + 1 < self.options.len() { self.selected + 1 } else { 0 };
232                    cx.invalidate_paint();
233                }
234                _ => {}
235            }
236        }
237    }
238
239    fn layout(&mut self, rect: Rect, _cx: &mut crate::component::LayoutCx) { self.rect = rect; }
240    fn focusable(&self) -> bool { true }
241    fn style(&self) -> Style { self.style.clone() }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::testbuffer::TestBuffer;
248
249    #[test]
250    fn test_radio_group_renders() {
251        let mut tb = TestBuffer::new(20, 2);
252        tb.render(&RadioGroup::new(vec![Text::from("A"), Text::from("B")]));
253        // First option should render with selected marker
254        assert!(tb.buffer.cells[0].symbol.contains('('));
255    }
256
257    #[test]
258    fn test_radio_group_selection_marker() {
259        let mut tb = TestBuffer::new(20, 1);
260        tb.render(&RadioGroup::new(vec![Text::from("Option")]));
261        // Selected item has • marker
262        assert_eq!(&tb.buffer.cells[1].symbol, "•");
263    }
264}
265
266    #[test]
267    fn test_checkbox_toggle() {
268        // Checkbox should accept toggle
269        let cb = Checkbox::new("opt").checked();
270        assert!(cb.is_checked());
271    }