Skip to main content

ccf_gpui_widgets/widgets/
toggle_switch.rs

1//! Toggle switch widget
2//!
3//! A toggle switch (on/off) control similar to iOS-style switches.
4//! Supports keyboard interaction (Space/Enter to toggle) and mouse clicks.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use ccf_gpui_widgets::widgets::ToggleSwitch;
10//!
11//! let toggle = cx.new(|cx| {
12//!     ToggleSwitch::new(cx)
13//!         .with_on(true)
14//!         .label("Enable notifications")
15//! });
16//!
17//! // Subscribe to changes
18//! cx.subscribe(&toggle, |this, _toggle, event: &ToggleSwitchEvent, cx| {
19//!     if let ToggleSwitchEvent::Toggle(is_on) = event {
20//!         println!("Toggle is now: {}", is_on);
21//!     }
22//! }).detach();
23//! ```
24
25use gpui::prelude::*;
26use gpui::*;
27
28use crate::theme::{get_theme_or, Theme};
29use super::focus_navigation::{handle_tab_navigation, with_focus_actions, EnabledCursorExt};
30
31/// Position of the label relative to the toggle switch
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33pub enum LabelPosition {
34    /// Label appears to the left of the toggle
35    Left,
36    /// Label appears to the right of the toggle (default)
37    #[default]
38    Right,
39}
40
41/// Events emitted by ToggleSwitch
42#[derive(Clone, Debug)]
43pub enum ToggleSwitchEvent {
44    /// Toggle state changed.
45    /// The boolean indicates the new on/off state: `true` = on, `false` = off.
46    Toggle(bool),
47}
48
49/// Toggle switch widget
50pub struct ToggleSwitch {
51    /// Whether the toggle is in the "on" state
52    on: bool,
53    label: Option<SharedString>,
54    label_position: LabelPosition,
55    focus_handle: FocusHandle,
56    custom_theme: Option<Theme>,
57    /// Whether the widget is enabled (interactive)
58    enabled: bool,
59}
60
61impl EventEmitter<ToggleSwitchEvent> for ToggleSwitch {}
62
63impl Focusable for ToggleSwitch {
64    fn focus_handle(&self, _cx: &App) -> FocusHandle {
65        self.focus_handle.clone()
66    }
67}
68
69impl ToggleSwitch {
70    /// Create a new toggle switch
71    pub fn new(cx: &mut Context<Self>) -> Self {
72        Self {
73            on: false,
74            label: None,
75            label_position: LabelPosition::default(),
76            focus_handle: cx.focus_handle().tab_stop(true),
77            custom_theme: None,
78            enabled: true,
79        }
80    }
81
82    /// Set initial on/off state (builder pattern)
83    #[must_use]
84    pub fn with_on(mut self, value: bool) -> Self {
85        self.on = value;
86        self
87    }
88
89    /// Set label text (builder pattern)
90    #[must_use]
91    pub fn label(mut self, text: impl Into<SharedString>) -> Self {
92        self.label = Some(text.into());
93        self
94    }
95
96    /// Set label position (builder pattern)
97    #[must_use]
98    pub fn label_position(mut self, position: LabelPosition) -> Self {
99        self.label_position = position;
100        self
101    }
102
103    /// Set custom theme (builder pattern)
104    #[must_use]
105    pub fn theme(mut self, theme: Theme) -> Self {
106        self.custom_theme = Some(theme);
107        self
108    }
109
110    /// Set enabled state (builder pattern)
111    #[must_use]
112    pub fn with_enabled(mut self, enabled: bool) -> Self {
113        self.enabled = enabled;
114        self
115    }
116
117    /// Get current on/off state
118    pub fn is_on(&self) -> bool {
119        self.on
120    }
121
122    /// Set on/off state programmatically
123    pub fn set_on(&mut self, on: bool, cx: &mut Context<Self>) {
124        if self.on != on {
125            self.on = on;
126            cx.emit(ToggleSwitchEvent::Toggle(on));
127            cx.notify();
128        }
129    }
130
131    /// Get the focus handle
132    pub fn focus_handle(&self) -> &FocusHandle {
133        &self.focus_handle
134    }
135
136    /// Check if the toggle switch is enabled
137    pub fn is_enabled(&self) -> bool {
138        self.enabled
139    }
140
141    /// Set enabled state programmatically
142    pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
143        if self.enabled != enabled {
144            self.enabled = enabled;
145            cx.notify();
146        }
147    }
148
149    fn toggle(&mut self, cx: &mut Context<Self>) {
150        self.on = !self.on;
151        cx.emit(ToggleSwitchEvent::Toggle(self.on));
152        cx.notify();
153    }
154}
155
156impl Render for ToggleSwitch {
157    fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
158        let theme = get_theme_or(cx, self.custom_theme.as_ref());
159        let is_on = self.on;
160        let label = self.label.clone();
161        let label_position = self.label_position;
162        let focus_handle = self.focus_handle.clone();
163        let is_focused = self.focus_handle.is_focused(window);
164        let enabled = self.enabled;
165
166        // Toggle dimensions
167        let track_width = 44.0;
168        let track_height = 24.0;
169        let thumb_size = 18.0;
170        let thumb_padding = 3.0;
171
172        // Calculate thumb position (left edge when off, right edge when on)
173        let thumb_left = if is_on {
174            track_width - thumb_size - thumb_padding
175        } else {
176            thumb_padding
177        };
178
179        // Helper to create label element
180        let make_label = |text: SharedString| {
181            div()
182                .text_sm()
183                .font_weight(FontWeight::SEMIBOLD)
184                .when(enabled, |d| d.text_color(rgb(theme.text_label)))
185                .when(!enabled, |d| d.text_color(rgb(theme.disabled_text)))
186                .child(text)
187        };
188
189        // Helper to create toggle element
190        let make_toggle = || {
191            let (track_bg, thumb_bg) = if enabled {
192                let track = if is_on { theme.primary } else { theme.bg_input };
193                (track, theme.bg_white)
194            } else {
195                let track = if is_on { theme.disabled_text } else { theme.disabled_bg };
196                (track, theme.disabled_bg)
197            };
198
199            div()
200                .w(px(track_width))
201                .h(px(track_height))
202                .rounded(px(track_height / 2.0)) // Pill shape
203                .relative()
204                .bg(rgb(track_bg))
205                .cursor_for_enabled(enabled)
206                .child(
207                    // Thumb
208                    div()
209                        .absolute()
210                        .top(px(thumb_padding))
211                        .left(px(thumb_left))
212                        .w(px(thumb_size))
213                        .h(px(thumb_size))
214                        .rounded_full()
215                        .bg(rgb(thumb_bg))
216                        .when(enabled, |d| d.shadow_sm())
217                )
218        };
219
220        let mut container = with_focus_actions(
221            div()
222                .id("ccf_toggle_switch")
223                .track_focus(&focus_handle)
224                .tab_stop(enabled),
225            cx,
226        )
227        .on_key_down(cx.listener(move |toggle, event: &KeyDownEvent, window, cx| {
228            if !toggle.enabled {
229                return;
230            }
231            if handle_tab_navigation(event, window) {
232                return;
233            }
234            if matches!(event.keystroke.key.as_str(), "space" | "enter") {
235                toggle.toggle(cx);
236            }
237        }))
238        .flex()
239        .flex_row()
240        .gap_2()
241        .items_center()
242        .py_1()
243        .px_1()
244        .rounded_sm()
245        .cursor_for_enabled(enabled)
246        .border_2()
247        .border_color(if is_focused && enabled { rgb(theme.border_focus) } else { rgba(0x00000000) });
248
249        if enabled {
250            container = container.on_mouse_down(MouseButton::Left, cx.listener(|toggle, _event, window, cx| {
251                toggle.focus_handle.focus(window);
252                toggle.toggle(cx);
253            }));
254        }
255
256        // Arrange label and toggle based on position
257        match (label_position, label) {
258            (LabelPosition::Left, Some(text)) => {
259                container = container.child(make_label(text)).child(make_toggle());
260            }
261            (LabelPosition::Right, Some(text)) => {
262                container = container.child(make_toggle()).child(make_label(text));
263            }
264            (_, None) => {
265                container = container.child(make_toggle());
266            }
267        }
268
269        container
270    }
271}