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 color
72    pub focused: Color,
73    /// Disabled color
74    pub disabled: Color,
75}
76
77impl Default for ToggleColors {
78    fn default() -> Self {
79        Self {
80            bracket: Color::Gray,
81            checkmark: Color::Green,
82            label: Color::White,
83            focused: Color::Cyan,
84            disabled: Color::DarkGray,
85        }
86    }
87}
88
89impl ToggleColors {
90    /// Create colors from theme
91    pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
92        Self {
93            bracket: theme.line_number_fg,
94            checkmark: theme.diagnostic_info_fg,
95            label: theme.editor_fg,
96            focused: theme.selection_bg,
97            disabled: theme.line_number_fg,
98        }
99    }
100}
101
102/// Layout information returned after rendering for hit testing
103#[derive(Debug, Clone, Copy, Default)]
104pub struct ToggleLayout {
105    /// The checkbox area (clickable)
106    pub checkbox_area: Rect,
107    /// The full control area including label
108    pub full_area: Rect,
109}
110
111impl ToggleLayout {
112    /// Check if a point is within the clickable area
113    pub fn contains(&self, x: u16, y: u16) -> bool {
114        x >= self.full_area.x
115            && x < self.full_area.x + self.full_area.width
116            && y >= self.full_area.y
117            && y < self.full_area.y + self.full_area.height
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use ratatui::backend::TestBackend;
125    use ratatui::Terminal;
126
127    fn test_frame<F>(width: u16, height: u16, f: F)
128    where
129        F: FnOnce(&mut ratatui::Frame, Rect),
130    {
131        let backend = TestBackend::new(width, height);
132        let mut terminal = Terminal::new(backend).unwrap();
133        terminal
134            .draw(|frame| {
135                let area = Rect::new(0, 0, width, height);
136                f(frame, area);
137            })
138            .unwrap();
139    }
140
141    #[test]
142    fn test_toggle_checked() {
143        test_frame(20, 1, |frame, area| {
144            let state = ToggleState::new(true, "Enable");
145            let colors = ToggleColors::default();
146            let layout = render_toggle(frame, area, &state, &colors);
147
148            assert_eq!(layout.checkbox_area.width, 3);
149            assert_eq!(layout.full_area.width, 11); // "Enable: [x]"
150        });
151    }
152
153    #[test]
154    fn test_toggle_unchecked() {
155        test_frame(20, 1, |frame, area| {
156            let state = ToggleState::new(false, "Enable");
157            let colors = ToggleColors::default();
158            let layout = render_toggle(frame, area, &state, &colors);
159
160            assert_eq!(layout.checkbox_area.width, 3);
161        });
162    }
163
164    #[test]
165    fn test_toggle_click_detection() {
166        test_frame(20, 1, |frame, area| {
167            let state = ToggleState::new(true, "Enable");
168            let colors = ToggleColors::default();
169            let layout = render_toggle(frame, area, &state, &colors);
170
171            // Click on checkbox
172            assert!(layout.contains(0, 0));
173            assert!(layout.contains(2, 0));
174
175            // Click on label
176            assert!(layout.contains(5, 0));
177
178            // Click outside
179            assert!(!layout.contains(15, 0));
180        });
181    }
182
183    #[test]
184    fn test_toggle_state_toggle() {
185        let mut state = ToggleState::new(false, "Test");
186        assert!(!state.checked);
187
188        state.toggle();
189        assert!(state.checked);
190
191        state.toggle();
192        assert!(!state.checked);
193    }
194
195    #[test]
196    fn test_toggle_disabled_no_toggle() {
197        let mut state = ToggleState::new(false, "Test").with_focus(FocusState::Disabled);
198        state.toggle();
199        assert!(!state.checked); // Should not change
200    }
201
202    #[test]
203    fn test_toggle_narrow_area() {
204        test_frame(2, 1, |frame, area| {
205            let state = ToggleState::new(true, "Enable");
206            let colors = ToggleColors::default();
207            let layout = render_toggle(frame, area, &state, &colors);
208
209            // Should still have some layout even if truncated
210            assert!(layout.full_area.width <= area.width);
211        });
212    }
213}