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}
32
33impl ToggleState {
34    /// Create a new toggle state
35    pub fn new(checked: bool, label: impl Into<String>) -> Self {
36        Self {
37            checked,
38            label: label.into(),
39            focus: FocusState::Normal,
40        }
41    }
42
43    /// Set the focus state
44    pub fn with_focus(mut self, focus: FocusState) -> Self {
45        self.focus = focus;
46        self
47    }
48
49    /// Check if the toggle is enabled
50    pub fn is_enabled(&self) -> bool {
51        self.focus != FocusState::Disabled
52    }
53
54    /// Toggle the value
55    pub fn toggle(&mut self) {
56        if self.is_enabled() {
57            self.checked = !self.checked;
58        }
59    }
60}
61
62/// Colors for the toggle control
63#[derive(Debug, Clone, Copy)]
64pub struct ToggleColors {
65    /// Checkbox bracket color
66    pub bracket: Color,
67    /// Checkmark color when checked
68    pub checkmark: Color,
69    /// Label text color
70    pub label: Color,
71    /// Focused highlight background color
72    pub focused: Color,
73    /// Focused highlight foreground color (text on focused background)
74    pub focused_fg: Color,
75    /// Disabled color
76    pub disabled: Color,
77}
78
79impl Default for ToggleColors {
80    fn default() -> Self {
81        Self {
82            bracket: Color::Gray,
83            checkmark: Color::Green,
84            label: Color::White,
85            focused: Color::Cyan,
86            focused_fg: Color::Black,
87            disabled: Color::DarkGray,
88        }
89    }
90}
91
92impl ToggleColors {
93    /// Create colors from theme
94    pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
95        Self {
96            bracket: theme.line_number_fg,
97            checkmark: theme.diagnostic_info_fg,
98            label: theme.editor_fg,
99            focused: theme.settings_selected_bg,
100            focused_fg: theme.settings_selected_fg,
101            disabled: theme.line_number_fg,
102        }
103    }
104}
105
106/// Layout information returned after rendering for hit testing
107#[derive(Debug, Clone, Copy, Default)]
108pub struct ToggleLayout {
109    /// The checkbox area (clickable)
110    pub checkbox_area: Rect,
111    /// The full control area including label
112    pub full_area: Rect,
113}
114
115impl ToggleLayout {
116    /// Check if a point is within the clickable area
117    pub fn contains(&self, x: u16, y: u16) -> bool {
118        x >= self.full_area.x
119            && x < self.full_area.x + self.full_area.width
120            && y >= self.full_area.y
121            && y < self.full_area.y + self.full_area.height
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use ratatui::backend::TestBackend;
129    use ratatui::Terminal;
130
131    fn test_frame<F>(width: u16, height: u16, f: F)
132    where
133        F: FnOnce(&mut ratatui::Frame, Rect),
134    {
135        let backend = TestBackend::new(width, height);
136        let mut terminal = Terminal::new(backend).unwrap();
137        terminal
138            .draw(|frame| {
139                let area = Rect::new(0, 0, width, height);
140                f(frame, area);
141            })
142            .unwrap();
143    }
144
145    #[test]
146    fn test_toggle_checked() {
147        test_frame(40, 1, |frame, area| {
148            let state = ToggleState::new(true, "Enable");
149            let colors = ToggleColors::default();
150            let layout = render_toggle(frame, area, &state, &colors);
151
152            // Chip is "[v]" = 3 cols.
153            assert_eq!(layout.checkbox_area.width, 3);
154            // "Enable" (6) + ": " (2) + chip (3) = 11.
155            assert_eq!(layout.full_area.width, 11);
156        });
157    }
158
159    #[test]
160    fn test_toggle_unchecked() {
161        test_frame(40, 1, |frame, area| {
162            let state = ToggleState::new(false, "Enable");
163            let colors = ToggleColors::default();
164            let layout = render_toggle(frame, area, &state, &colors);
165
166            // Same chip width either way — layout doesn't shift on toggle.
167            assert_eq!(layout.checkbox_area.width, 3);
168        });
169    }
170
171    #[test]
172    fn test_toggle_click_detection() {
173        test_frame(40, 1, |frame, area| {
174            let state = ToggleState::new(true, "Enable");
175            let colors = ToggleColors::default();
176            let layout = render_toggle(frame, area, &state, &colors);
177
178            // Click on the chip (cols 8..11).
179            assert!(layout.contains(8, 0));
180            assert!(layout.contains(10, 0));
181
182            // Click on the label (cols 0..6).
183            assert!(layout.contains(0, 0));
184            assert!(layout.contains(5, 0));
185
186            // Click past the chip.
187            assert!(!layout.contains(15, 0));
188        });
189    }
190
191    #[test]
192    fn test_toggle_state_toggle() {
193        let mut state = ToggleState::new(false, "Test");
194        assert!(!state.checked);
195
196        state.toggle();
197        assert!(state.checked);
198
199        state.toggle();
200        assert!(!state.checked);
201    }
202
203    #[test]
204    fn test_toggle_disabled_no_toggle() {
205        let mut state = ToggleState::new(false, "Test").with_focus(FocusState::Disabled);
206        state.toggle();
207        assert!(!state.checked); // Should not change
208    }
209
210    #[test]
211    fn test_toggle_narrow_area() {
212        test_frame(2, 1, |frame, area| {
213            let state = ToggleState::new(true, "Enable");
214            let colors = ToggleColors::default();
215            let layout = render_toggle(frame, area, &state, &colors);
216
217            // Should still have some layout even if truncated
218            assert!(layout.full_area.width <= area.width);
219        });
220    }
221}