From 0924d1d301cc5427e0e8b72167132494febd727c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 28 Oct 2026 19:04:42 +0700 Subject: [PATCH] add switch --- crates/core/src/lib.rs | 3 + crates/core/src/switch.rs | 142 +++++++++++++++++++++++++++++++++++ crates/core/tests/switch.rs | 111 +++++++++++++++++++++++++++ examples/gallery/src/main.rs | 36 +++++++++ 4 files changed, 292 insertions(+) create mode 100644 crates/core/src/switch.rs create mode 100644 crates/core/tests/switch.rs diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index e93a103..73ece5e 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -7,3 +7,6 @@ pub mod button; #[cfg(feature = "checkbox")] pub mod checkbox; + +#[cfg(feature = "switch")] +pub mod switch; diff --git a/crates/core/src/switch.rs b/crates/core/src/switch.rs new file mode 100644 index 0000000..e10516d --- /dev/null +++ b/crates/core/src/switch.rs @@ -0,0 +1,142 @@ +//! The switch: a controlled on/off pill with a thumb. + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + App, BoxShadow, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, + RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Toggled, Window, + div, px, +}; +use gpui_native_theme::ActiveTheme as _; + +use crate::common::h_flex; + +/// The change reporter, fired with the next value by pointer, Enter, and Space. +type ChangeHandler = Box; + +/// Switch component. +#[derive(IntoElement)] +pub struct Switch { + id: ElementId, + label: Option, + checked: bool, + disabled: bool, + a11y_label: Option, + on_change: Option, +} + +impl Switch { + /// Creates an unchecked switch. + pub fn new(id: I) -> Self + where + I: Into, + { + Self { + id: id.into(), + label: None, + checked: false, + disabled: false, + a11y_label: None, + on_change: None, + } + } + + /// Sets the visible label; clicking it toggles too. + pub fn label(mut self, label: L) -> Self + where + L: Into, + { + self.label = Some(label.into()); + self + } + + /// Sets the current value; the switch never owns it. + pub fn checked(mut self, checked: bool) -> Self { + self.checked = checked; + self + } + + /// Renders the switch inert, unfocusable, and dimmed; disabled always wins. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Overrides the accessibility label, which defaults to the visible label. + pub fn accessibility_label(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } + + /// The change handler, fired with the next value by pointer, Enter, and Space. + pub fn on_change(mut self, handler: F) -> Self + where + F: Fn(bool, &mut Window, &mut App) + 'static, + { + self.on_change = Some(Box::new(handler)); + self + } +} + +impl RenderOnce for Switch { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors; + let ramps = theme.ramps; + let on_change = self.on_change; + let next = !self.checked; + let a11y_label = self.a11y_label.or_else(|| self.label.clone()); + + h_flex() + .id(self.id) + .gap_1p5() + .text_sm() + .role(Role::Switch) + .when_some(a11y_label, |switch, label| switch.aria_label(label)) + .aria_toggled(Toggled::from(self.checked)) + .when(self.disabled, |switch| switch.opacity(0.5)) + .when(!self.disabled, |switch| { + switch + .cursor_pointer() + .focusable() + .tab_stop(true) + .focus_visible(|style| { + style.shadow(vec![ + BoxShadow::new(px(0.), px(0.), colors.ring).spread_radius(px(3.)), + ]) + }) + .when_some(on_change, |switch, on_change| { + switch.on_click(move |_, window, cx| on_change(next, window, cx)) + }) + }) + .child( + h_flex() + .flex_none() + .w_9() + .h_5() + .p_0p5() + .rounded(theme.radius.full) + .when(self.checked, |track| { + track + .bg(ramps.primary.base) + .justify_end() + .hover(|style| style.bg(ramps.primary.hover)) + }) + .when(!self.checked, |track| { + track.bg(colors.border).justify_start() + }) + .child( + div() + .size_4() + .flex_none() + .rounded(theme.radius.full) + .bg(colors.primary_foreground) + .border_1() + .border_color(colors.border), + ), + ) + .when_some(self.label, |switch, label| switch.child(label)) + } +} diff --git a/crates/core/tests/switch.rs b/crates/core/tests/switch.rs new file mode 100644 index 0000000..5882110 --- /dev/null +++ b/crates/core/tests/switch.rs @@ -0,0 +1,111 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + AnyWindowHandle, AppContext as _, Context, IntoElement, KeyDownEvent, KeyUpEvent, Keystroke, + Modifiers, ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, + div, point, px, +}; +use gpui_native_core::switch::Switch; +use gpui_native_theme::Theme; + +struct Harness { + changes: Rc>>, + checked: bool, + disabled: bool, +} + +impl Render for Harness { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let changes = self.changes.clone(); + let view = cx.entity(); + div().size_full().flex().flex_col().child( + Switch::new("test-switch") + .label("Wi-Fi") + .checked(self.checked) + .disabled(self.disabled) + .on_change(move |next, _, cx| { + changes.borrow_mut().push(next); + view.update(cx, |harness, cx| { + harness.checked = next; + cx.notify(); + }); + }), + ) + } +} + +/// A maximized window whose switch track sits in the top-left corner, so +/// `point(px(10.), px(10.))` lands on it. +fn switch_window( + checked: bool, + disabled: bool, +) -> (VisualTestContext, AnyWindowHandle, Rc>>) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let changes = Rc::new(RefCell::new(Vec::new())); + let harness_changes = changes.clone(); + let window = cx.add_window(move |_, _| Harness { + changes: harness_changes, + checked, + disabled, + }); + let window = window.into(); + cx.update_window(window, |_, window, cx| { + window.draw(cx).clear(cx); + }) + .unwrap(); + (VisualTestContext::from_window(window, &cx), window, changes) +} + +fn draw(cx: &mut VisualTestContext, window: AnyWindowHandle) { + cx.update_window(window, |_, window, cx| { + window.draw(cx).clear(cx); + }) + .unwrap(); +} + +#[test] +fn clicking_reports_the_next_value_through_the_controlled_loop() { + let (mut cx, _window, changes) = switch_window(false, false); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert_eq!(*changes.borrow(), vec![true]); + + // The second click must report `false`: only if the owner applied the + // first change does the switch know it is now checked. + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert_eq!(*changes.borrow(), vec![true, false]); +} + +#[test] +fn disabled_switch_does_not_report_changes() { + let (mut cx, _window, changes) = switch_window(false, true); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert!(changes.borrow().is_empty()); +} + +#[test] +fn enter_and_space_report_the_next_value_while_focused() { + let (mut cx, window, changes) = switch_window(false, false); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert_eq!( + *changes.borrow(), + vec![true], + "the pointer click itself toggles" + ); + + // Repaint with the switch focused so its Enter/Space handlers register. + draw(&mut cx, window); + + for key in ["enter", "space"] { + cx.simulate_event(KeyDownEvent { + keystroke: Keystroke::parse(key).unwrap(), + is_held: false, + prefer_character_input: false, + }); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).unwrap(), + }); + } + + assert_eq!(*changes.borrow(), vec![true, false, true]); +} diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index b76e228..d95fc8c 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -7,6 +7,7 @@ use gpui::{ use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::checkbox::Checkbox; use gpui_native_core::common::{h_flex, v_flex}; +use gpui_native_core::switch::Switch; use gpui_native_icons::{IconAssets, IconRole, icon}; use gpui_native_theme::{ActiveTheme as _, Theme}; use gpui_platform::application; @@ -14,6 +15,7 @@ use gpui_platform::application; struct Gallery { button_clicks: usize, checkbox_checked: bool, + switch_checked: bool, } impl Render for Gallery { @@ -233,6 +235,39 @@ impl Render for Gallery { ), ), ) + .child( + v_flex() + .gap_2() + .child(section_label( + "switch · native refs — NSSwitch, WinUI ToggleSwitch, Adwaita Switch, Breeze Switch", + colors, + )) + .child( + v_flex().gap_2().child({ + let gallery = gallery.clone(); + Switch::new("gallery-switch") + .label("Wi-Fi") + .checked(self.switch_checked) + .on_change(move |checked, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.switch_checked = checked; + cx.notify(); + }); + }) + }) + .child( + Switch::new("gallery-switch-disabled-off") + .label("Disabled off") + .disabled(true), + ) + .child( + Switch::new("gallery-switch-disabled-on") + .label("Disabled on") + .checked(true) + .disabled(true), + ), + ), + ) .child( div() .flex() @@ -385,6 +420,7 @@ fn main() { cx.new(|_| Gallery { button_clicks: 0, checkbox_checked: false, + switch_checked: true, }) }, ) {