Skip to main content

fresh/view/controls/toggle/
mod.rs

1//! Toggle (checkbox) control for boolean values
2//!
3//! Renders as: `Label: [x]` or `Label: [ ]`
4//!
5//! This module provides a complete toggle component with:
6//! - State management (`ToggleState`)
7//! - Rendering (`render_toggle`, `render_toggle_aligned`)
8//! - Input handling (`ToggleState::handle_mouse`, `handle_key`)
9//! - Layout/hit testing (`ToggleLayout`)
10
11mod input;
12mod render;
13
14use ratatui::layout::Rect;
15use ratatui::style::Color;
16
17pub use input::ToggleEvent;
18pub use render::{render_toggle, render_toggle_aligned};
19
20use super::FocusState;
21
22/// State for a toggle control
23#[derive(Debug, Clone)]
24pub struct ToggleState {
25    /// Current value
26    pub checked: bool,
27    /// Label displayed next to the toggle
28    pub label: String,
29    /// Focus state
30    pub focus: FocusState,
31    /// When true, this toggle's value is *inherited* (the underlying setting is
32    /// unset/`null` and falls back to a lower layer). It renders as a neutral
33    /// `[-]` chip instead of a definite `[ ]`/`[v]`, so an inherited-`true`
34    /// setting is not misread as disabled (issue #2345). Any explicit toggle
35    /// clears this — the value is then the user's own, not inherited.
36    pub inherited: bool,
37}
38
39impl ToggleState {
40    /// Create a new toggle state
41    pub fn new(checked: bool, label: impl Into<String>) -> Self {
42        Self {
43            checked,
44            label: label.into(),
45            focus: FocusState::Normal,
46            inherited: false,
47        }
48    }
49
50    /// Set the focus state
51    pub fn with_focus(mut self, focus: FocusState) -> Self {
52        self.focus = focus;
53        self
54    }
55
56    /// Mark this toggle as displaying an inherited (unset) value.
57    pub fn with_inherited(mut self, inherited: bool) -> Self {
58        self.inherited = inherited;
59        self
60    }
61
62    /// Check if the toggle is enabled
63    pub fn is_enabled(&self) -> bool {
64        self.focus != FocusState::Disabled
65    }
66
67    /// Toggle the value
68    pub fn toggle(&mut self) {
69        if self.is_enabled() {
70            self.checked = !self.checked;
71            // An explicit toggle makes the value the user's own choice, so it
72            // is no longer inherited.
73            self.inherited = false;
74        }
75    }
76}
77
78/// Colors for the toggle control
79#[derive(Debug, Clone, Copy)]
80pub struct ToggleColors {
81    /// Checkbox bracket color
82    pub bracket: Color,
83    /// Checkmark color when checked
84    pub checkmark: Color,
85    /// Label text color
86    pub label: Color,
87    /// Focused highlight background color
88    pub focused: Color,
89    /// Focused highlight foreground color (text on focused background)
90    pub focused_fg: Color,
91    /// Disabled color
92    pub disabled: Color,
93}
94
95impl Default for ToggleColors {
96    fn default() -> Self {
97        Self {
98            bracket: Color::Gray,
99            checkmark: Color::Green,
100            label: Color::White,
101            focused: Color::Cyan,
102            focused_fg: Color::Black,
103            disabled: Color::DarkGray,
104        }
105    }
106}
107
108impl ToggleColors {
109    /// Create colors from theme
110    pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
111        Self {
112            bracket: theme.line_number_fg,
113            checkmark: theme.diagnostic_info_fg,
114            label: theme.editor_fg,
115            focused: theme.settings_selected_bg,
116            focused_fg: theme.settings_selected_fg,
117            disabled: theme.line_number_fg,
118        }
119    }
120}
121
122/// Layout information returned after rendering for hit testing
123#[derive(Debug, Clone, Copy, Default)]
124pub struct ToggleLayout {
125    /// The checkbox area (clickable)
126    pub checkbox_area: Rect,
127    /// The full control area including label
128    pub full_area: Rect,
129}
130
131impl ToggleLayout {
132    /// Check if a point is within the clickable area
133    pub fn contains(&self, x: u16, y: u16) -> bool {
134        x >= self.full_area.x
135            && x < self.full_area.x + self.full_area.width
136            && y >= self.full_area.y
137            && y < self.full_area.y + self.full_area.height
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use ratatui::backend::TestBackend;
145    use ratatui::Terminal;
146
147    fn test_frame<F>(width: u16, height: u16, f: F)
148    where
149        F: FnOnce(&mut ratatui::Frame, Rect),
150    {
151        let backend = TestBackend::new(width, height);
152        let mut terminal = Terminal::new(backend).unwrap();
153        terminal
154            .draw(|frame| {
155                let area = Rect::new(0, 0, width, height);
156                f(frame, area);
157            })
158            .unwrap();
159    }
160
161    #[test]
162    fn test_toggle_checked() {
163        test_frame(40, 1, |frame, area| {
164            let state = ToggleState::new(true, "Enable");
165            let colors = ToggleColors::default();
166            let layout = render_toggle(frame, area, &state, &colors);
167
168            // Chip is "[v]" = 3 cols.
169            assert_eq!(layout.checkbox_area.width, 3);
170            // "Enable" (6) + ": " (2) + chip (3) = 11.
171            assert_eq!(layout.full_area.width, 11);
172        });
173    }
174
175    #[test]
176    fn test_toggle_unchecked() {
177        test_frame(40, 1, |frame, area| {
178            let state = ToggleState::new(false, "Enable");
179            let colors = ToggleColors::default();
180            let layout = render_toggle(frame, area, &state, &colors);
181
182            // Same chip width either way — layout doesn't shift on toggle.
183            assert_eq!(layout.checkbox_area.width, 3);
184        });
185    }
186
187    #[test]
188    fn test_toggle_click_detection() {
189        test_frame(40, 1, |frame, area| {
190            let state = ToggleState::new(true, "Enable");
191            let colors = ToggleColors::default();
192            let layout = render_toggle(frame, area, &state, &colors);
193
194            // Click on the chip (cols 8..11).
195            assert!(layout.contains(8, 0));
196            assert!(layout.contains(10, 0));
197
198            // Click on the label (cols 0..6).
199            assert!(layout.contains(0, 0));
200            assert!(layout.contains(5, 0));
201
202            // Click past the chip.
203            assert!(!layout.contains(15, 0));
204        });
205    }
206
207    #[test]
208    fn test_toggle_state_toggle() {
209        let mut state = ToggleState::new(false, "Test");
210        assert!(!state.checked);
211
212        state.toggle();
213        assert!(state.checked);
214
215        state.toggle();
216        assert!(!state.checked);
217    }
218
219    #[test]
220    fn test_toggle_disabled_no_toggle() {
221        let mut state = ToggleState::new(false, "Test").with_focus(FocusState::Disabled);
222        state.toggle();
223        assert!(!state.checked); // Should not change
224    }
225
226    #[test]
227    fn test_toggle_narrow_area() {
228        test_frame(2, 1, |frame, area| {
229            let state = ToggleState::new(true, "Enable");
230            let colors = ToggleColors::default();
231            let layout = render_toggle(frame, area, &state, &colors);
232
233            // Should still have some layout even if truncated
234            assert!(layout.full_area.width <= area.width);
235        });
236    }
237}