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::Style;
7
8/// A checkbox widget with a label and toggleable state.
9///
10/// Press `Space` or `Enter` to toggle. Renders as `[✓] label` when checked
11/// and `[ ] label` when unchecked.
12pub struct Checkbox {
13    label: String,
14    checked: bool,
15    focused: bool,
16    rect: Rect,
17    style: Style,
18    checked_style: Style,
19}
20
21impl Checkbox {
22    /// Creates a new unchecked checkbox with the given label.
23    pub fn new(label: impl Into<String>) -> Self {
24        Self {
25            label: label.into(),
26            checked: false,
27            focused: false,
28            rect: Rect::default(),
29            style: Style::default(),
30            checked_style: Style::default().fg(crate::style::Color::Green),
31        }
32    }
33
34    /// Builder: sets the initial checked state.
35    pub fn checked(mut self) -> Self {
36        self.checked = true;
37        self
38    }
39
40    /// Builder: sets the default style.
41    pub fn style(mut self, style: Style) -> Self {
42        self.style = style;
43        self
44    }
45
46    /// Builder: sets the checked-state style.
47    pub fn checked_style(mut self, style: Style) -> Self {
48        self.checked_style = style;
49        self
50    }
51
52    /// Returns whether the checkbox is currently checked.
53    pub fn is_checked(&self) -> bool {
54        self.checked
55    }
56
57    /// Sets the checked state.
58    pub fn set_checked(&mut self, checked: bool, cx: &mut EventCx) {
59        if self.checked != checked {
60            self.checked = checked;
61            cx.invalidate_paint();
62        }
63    }
64
65    /// Toggles the checked state.
66    pub fn toggle(&mut self, cx: &mut EventCx) {
67        self.checked = !self.checked;
68        cx.invalidate_paint();
69    }
70}
71
72impl Component for Checkbox {
73    fn render(&self, cx: &mut RenderCx) {
74        let mark = if self.checked { "✓" } else { " " };
75        let text = format!("[{}] {}", mark, self.label);
76        if self.focused {
77            cx.set_style(self.checked_style.clone());
78        } else if self.checked {
79            cx.set_style(self.checked_style.clone());
80        } else {
81            cx.set_style(self.style.clone());
82        }
83        cx.line(&text);
84    }
85
86    fn measure(&self, _constraint: Constraint, _cx: &mut MeasureCx) -> Size {
87        let w: u16 = self.label.chars().map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16).sum();
88        Size { width: 5 + w, 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<String>,
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<String>) -> Self {
135        Self {
136            options,
137            selected: 0,
138            focused: false,
139            rect: Rect::default(),
140            style: Style::default(),
141            selected_style: Style::default().fg(crate::style::Color::Green),
142        }
143    }
144
145    /// Builder: sets the default style.
146    pub fn style(mut self, style: Style) -> Self {
147        self.style = style;
148        self
149    }
150
151    /// Builder: sets the selected-item style.
152    pub fn selected_style(mut self, style: Style) -> Self {
153        self.selected_style = style;
154        self
155    }
156
157    /// Returns the currently selected index.
158    pub fn selected(&self) -> usize {
159        self.selected
160    }
161
162    /// Returns the text of the currently selected option.
163    pub fn selected_text(&self) -> &str {
164        self.options.get(self.selected).map(|s| s.as_str()).unwrap_or("")
165    }
166
167    /// Sets the selected option.
168    pub fn set_selected(&mut self, index: usize, cx: &mut EventCx) {
169        if index < self.options.len() && index != self.selected {
170            self.selected = index;
171            cx.invalidate_paint();
172        }
173    }
174}
175
176impl Component for RadioGroup {
177    fn render(&self, cx: &mut RenderCx) {
178        for (i, opt) in self.options.iter().enumerate() {
179            let (mark, style) = if i == self.selected {
180                if self.focused {
181                    ("•", self.selected_style.clone())
182                } else {
183                    ("•", self.selected_style.clone())
184                }
185            } else {
186                (" ", self.style.clone())
187            };
188            cx.set_style(style);
189            cx.line(&format!("({}) {}", mark, opt));
190        }
191    }
192
193    fn measure(&self, _constraint: Constraint, _cx: &mut MeasureCx) -> Size {
194        let max_w: u16 = self.options.iter()
195            .map(|o| 4 + o.chars().map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16).sum::<u16>())
196            .max()
197            .unwrap_or(0);
198        Size { width: max_w, height: self.options.len() as u16 }
199    }
200
201    fn event(&mut self, event: &Event, cx: &mut EventCx) {
202        match event {
203            Event::Focus => { self.focused = true; cx.invalidate_paint(); return; }
204            Event::Blur => { self.focused = false; cx.invalidate_paint(); return; }
205            _ => {}
206        }
207
208        // Only handle key events during Target phase (when we have focus)
209        if cx.phase() != crate::event::EventPhase::Target { return; }
210        if self.options.is_empty() { return; }
211
212        if let Event::Key(key_event) = event {
213            match &key_event.key {
214                crate::event::Key::Up => {
215                    self.selected = if self.selected > 0 { self.selected - 1 } else { self.options.len() - 1 };
216                    cx.invalidate_paint();
217                }
218                crate::event::Key::Down => {
219                    self.selected = if self.selected + 1 < self.options.len() { self.selected + 1 } else { 0 };
220                    cx.invalidate_paint();
221                }
222                _ => {}
223            }
224        }
225    }
226
227    fn layout(&mut self, rect: Rect, _cx: &mut crate::component::LayoutCx) { self.rect = rect; }
228    fn focusable(&self) -> bool { true }
229    fn style(&self) -> Style { self.style.clone() }
230}