diff --git a/crates/core/src/checkbox.rs b/crates/core/src/checkbox.rs new file mode 100644 index 0000000..0994682 --- /dev/null +++ b/crates/core/src/checkbox.rs @@ -0,0 +1,140 @@ +//! The checkbox: a controlled on/off value with an optional label. + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + App, BoxShadow, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, + RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Toggled, Window, + px, +}; +use gpui_native_icons::{IconRole, icon}; +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; + +/// Checkbox component. +#[derive(IntoElement)] +pub struct Checkbox { + id: ElementId, + label: Option, + checked: bool, + disabled: bool, + a11y_label: Option, + on_change: Option, +} + +impl Checkbox { + /// Creates an unchecked checkbox. + 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 checkbox never owns it. + pub fn checked(mut self, checked: bool) -> Self { + self.checked = checked; + self + } + + /// Renders the checkbox 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 Checkbox { + 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) + .role(Role::CheckBox) + .gap_1p5() + .text_sm() + .when_some(a11y_label, |checkbox, label| checkbox.aria_label(label)) + .aria_toggled(Toggled::from(self.checked)) + .when(self.disabled, |checkbox| checkbox.opacity(0.5)) + .when(!self.disabled, |checkbox| { + checkbox + .cursor_pointer() + .focusable() + .tab_stop(true) + .focus_visible(|style| { + // Paint-only keyboard ring; the width joins the native + // metrics table when that lands. + style.shadow(vec![ + BoxShadow::new(px(0.), px(0.), colors.ring).spread_radius(px(3.)), + ]) + }) + .when_some(on_change, |checkbox, on_change| { + checkbox.on_click(move |_, window, cx| on_change(next, window, cx)) + }) + }) + .child( + h_flex() + .justify_center() + .flex_none() + .size_4() + .rounded(theme.radius.sm) + .border_1() + .when(self.checked, |indicator| { + indicator + .bg(ramps.primary.base) + .border_color(ramps.primary.base) + .hover(|style| style.bg(ramps.primary.hover)) + .child(icon(IconRole::Check).color(colors.primary_foreground)) + }) + .when(!self.checked, |indicator| { + indicator + .bg(colors.input) + .border_color(colors.border) + .hover(|style| style.border_color(colors.muted_foreground)) + }), + ) + .when_some(self.label, |checkbox, label| checkbox.child(label)) + } +} diff --git a/crates/core/tests/checkbox.rs b/crates/core/tests/checkbox.rs new file mode 100644 index 0000000..11c3aec --- /dev/null +++ b/crates/core/tests/checkbox.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::checkbox::Checkbox; +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( + Checkbox::new("test-checkbox") + .label("Telemetry") + .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 checkbox sits in the top-left corner, so +/// `point(px(8.), px(10.))` lands on it. +fn checkbox_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) = checkbox_window(false, false); + cx.simulate_click(point(px(8.), 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 checkbox know it is now checked. + cx.simulate_click(point(px(8.), px(10.)), Modifiers::default()); + assert_eq!(*changes.borrow(), vec![true, false]); +} + +#[test] +fn disabled_checkbox_does_not_report_changes() { + let (mut cx, _window, changes) = checkbox_window(false, true); + cx.simulate_click(point(px(8.), 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) = checkbox_window(false, false); + cx.simulate_click(point(px(8.), px(10.)), Modifiers::default()); + assert_eq!( + *changes.borrow(), + vec![true], + "the pointer click itself toggles" + ); + + // Repaint with the checkbox 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]); +}