From e3b65143a3e873d51ebd9275e104c2a848353172 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 28 Oct 2026 19:17:35 +0700 Subject: [PATCH] add radio --- crates/core/src/lib.rs | 3 + crates/core/src/radio.rs | 210 +++++++++++++++++++++++++++++++++++ crates/core/src/switch.rs | 2 +- crates/core/tests/radio.rs | 121 ++++++++++++++++++++ crates/theme/src/resolve.rs | 5 - docs/PLAN.md | 8 ++ examples/gallery/src/main.rs | 29 +++++ 7 files changed, 372 insertions(+), 6 deletions(-) create mode 100644 crates/core/src/radio.rs create mode 100644 crates/core/tests/radio.rs diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 73ece5e..22cc22c 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -10,3 +10,6 @@ pub mod checkbox; #[cfg(feature = "switch")] pub mod switch; + +#[cfg(feature = "radio")] +pub mod radio; diff --git a/crates/core/src/radio.rs b/crates/core/src/radio.rs new file mode 100644 index 0000000..4fe6ee8 --- /dev/null +++ b/crates/core/src/radio.rs @@ -0,0 +1,210 @@ +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, v_flex}; + +/// The group's change reporter, fired with the clicked radio's value. +type ChangeHandler = std::rc::Rc; + +/// Radio button. +pub struct Radio { + id: ElementId, + value: V, + label: Option, + disabled: bool, + a11y_label: Option, +} + +impl Radio +where + V: Clone + PartialEq + 'static, +{ + /// Creates a radio representing `value`. + pub fn new(id: I, value: V) -> Self + where + I: Into, + { + Self { + id: id.into(), + value, + label: None, + disabled: false, + a11y_label: None, + } + } + + /// Sets the visible label; clicking it selects the radio too. + pub fn label(mut self, label: L) -> Self + where + L: Into, + { + self.label = Some(label.into()); + self + } + + /// Renders the radio inert, unfocusable, and dimmed. + 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 + } +} + +/// Group of radio buttons. +#[derive(IntoElement)] +pub struct RadioGroup +where + V: Clone + PartialEq + 'static, +{ + id: ElementId, + selected: Option, + children: Vec>, + a11y_label: Option, + on_change: Option>, +} + +impl RadioGroup +where + V: Clone + PartialEq + 'static, +{ + /// Creates an empty group with nothing selected. + pub fn new(id: I) -> Self + where + I: Into, + { + Self { + id: id.into(), + selected: None, + children: Vec::new(), + a11y_label: None, + on_change: None, + } + } + + /// Sets the currently selected value. + pub fn selected(mut self, selected: Option) -> Self { + self.selected = selected; + self + } + + /// Adds a radio to the group. + pub fn child(mut self, radio: Radio) -> Self { + self.children.push(radio); + self + } + + /// Sets the group's accessibility label. + pub fn accessibility_label(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } + + /// Sets the change handler. + pub fn on_change(mut self, handler: F) -> Self + where + F: Fn(V, &mut Window, &mut App) + 'static, + { + self.on_change = Some(std::rc::Rc::new(handler)); + self + } +} + +impl RenderOnce for RadioGroup +where + V: Clone + PartialEq + 'static, +{ + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors; + let ramps = theme.ramps; + let radius = theme.radius; + let selected = self.selected; + let on_change = self.on_change; + let group_label = self.a11y_label; + + v_flex() + .id(self.id) + .gap_2() + .text_sm() + .role(Role::RadioGroup) + .when_some(group_label.clone(), |group, label| group.aria_label(label)) + .children(self.children.into_iter().map(move |radio| { + let is_selected = selected.as_ref() == Some(&radio.value); + let disabled = radio.disabled; + let row_on_change = on_change.clone(); + + let a11y_label = radio + .a11y_label + .or(radio.label.clone()) + .or_else(|| group_label.clone()); + + h_flex() + .id(radio.id) + .gap_1p5() + .role(Role::RadioButton) + .when_some(a11y_label, |row, label| row.aria_label(label)) + .aria_toggled(Toggled::from(is_selected)) + .when(disabled, |row| row.opacity(0.5)) + .when(!disabled, move |row| { + let value = radio.value.clone(); + row.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(row_on_change, |row, on_change| { + row.on_click(move |_, window, cx| { + on_change(value.clone(), window, cx) + }) + }) + }) + .child( + h_flex() + .justify_center() + .flex_none() + .size_4() + .rounded(radius.full) + .border_1() + .when(is_selected, |indicator| { + indicator + .bg(ramps.primary.base) + .border_color(ramps.primary.base) + .hover(|style| style.bg(ramps.primary.hover)) + .child( + div() + .size_2() + .rounded(radius.full) + .bg(colors.primary_foreground), + ) + }) + .when(!is_selected, |indicator| { + indicator + .bg(colors.input) + .border_color(colors.border) + .hover(|style| style.border_color(colors.muted_foreground)) + }), + ) + .when_some(radio.label, |row, label| row.child(label)) + })) + } +} diff --git a/crates/core/src/switch.rs b/crates/core/src/switch.rs index e10516d..a8fbdef 100644 --- a/crates/core/src/switch.rs +++ b/crates/core/src/switch.rs @@ -111,7 +111,7 @@ impl RenderOnce for Switch { switch.on_click(move |_, window, cx| on_change(next, window, cx)) }) }) - .child( + .child( h_flex() .flex_none() .w_9() diff --git a/crates/core/tests/radio.rs b/crates/core/tests/radio.rs new file mode 100644 index 0000000..1fc62d2 --- /dev/null +++ b/crates/core/tests/radio.rs @@ -0,0 +1,121 @@ +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::radio::{Radio, RadioGroup}; +use gpui_native_theme::Theme; + +struct Harness { + changes: Rc>>, + selected: Option<&'static str>, +} + +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( + RadioGroup::new("test-group") + .selected(self.selected) + .on_change(move |value, _, cx| { + changes.borrow_mut().push(value); + view.update(cx, |harness, cx| { + harness.selected = Some(value); + cx.notify(); + }); + }) + .child(Radio::new("radio-a", "a").label("Option A")) + .child(Radio::new("radio-b", "b").label("Option B")) + .child( + Radio::new("radio-off", "c") + .label("Disabled") + .disabled(true), + ), + ) + } +} + +/// A maximized window whose radio rows sit at the top-left: row 1 spans +/// roughly y 0..21, row 2 y 29..50, so `(8, 10)` and `(8, 40)` hit them. +fn group_window( + selected: Option<&'static str>, +) -> ( + 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, + selected, + }); + 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_values_through_the_controlled_loop() { + let (mut cx, _window, changes) = group_window(None); + cx.simulate_click(point(px(8.), px(10.)), Modifiers::default()); + assert_eq!(*changes.borrow(), vec!["a"]); + cx.simulate_click(point(px(8.), px(40.)), Modifiers::default()); + assert_eq!(*changes.borrow(), vec!["a", "b"]); +} + +#[test] +fn clicking_the_selected_radio_never_unchecks_it() { + let (mut cx, _window, changes) = group_window(Some("a")); + cx.simulate_click(point(px(8.), px(10.)), Modifiers::default()); + assert_eq!(*changes.borrow(), vec!["a"], "re-reports the same value"); +} + +#[test] +fn disabled_radio_does_not_report_changes() { + let (mut cx, _window, changes) = group_window(None); + cx.simulate_click(point(px(8.), px(70.)), Modifiers::default()); + assert!(changes.borrow().is_empty()); +} + +#[test] +fn enter_and_space_report_the_focused_radio_value() { + let (mut cx, window, changes) = group_window(None); + cx.simulate_click(point(px(8.), px(10.)), Modifiers::default()); + assert_eq!( + *changes.borrow(), + vec!["a"], + "the pointer click itself selects" + ); + + // Repaint with the first radio 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!["a", "a", "a"]); +} diff --git a/crates/theme/src/resolve.rs b/crates/theme/src/resolve.rs index 67ffce8..40a3b41 100644 --- a/crates/theme/src/resolve.rs +++ b/crates/theme/src/resolve.rs @@ -1,8 +1,3 @@ -//! The resolve pipeline: platform snapshot + preset definition + mode -//! override -> fully populated tokens. -//! -//! Pure and unit-testable without a GUI. - use crate::definition::ThemeDefinition; use crate::ramps::{AccentShades, Oklch, contrast_foreground, derive_shades, mix}; use crate::system::SystemTheme; diff --git a/docs/PLAN.md b/docs/PLAN.md index bbbb2b9..5e465fd 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -148,6 +148,14 @@ graph TD Omarchy `colors.toml` maps almost 1:1: `lighter_background` → `surface`, `dark_background`/`darker_background` → elevation depth, `dark_foreground` → muted foreground, `accent` → `primary`/`accent`/`ring`, `red` → `destructive`, `green`/`yellow`/`orange` → success/warning. Every mapping lives in one module; never literals. Tests assert finite, distinct, semantically sane tokens for every preset × mode. +The set stays role-minimal and grows one concept at a time, driven by the unit that needs it. Known planned additions: + +- **Overlay scrim + shadow/elevation tokens** — with the overlay units (Popover/Dialog/Menu, unit 11+); §5.5's popup elevation promise. No current role covers the modality backdrop. +- **`success` / `warning` roles** — with the first severity-bearing component (Toast, badges, validation); the Omarchy mapping above already anticipates them. +- **`knob` / control-fill role** — only if Slider (unit 7) confirms the Switch's borrowed roles (`primary_foreground` thumb, `border` off-track) as a second consumer; until then no expansion. + +Controls through unit 10 paint from existing roles and ramps. + ### 5.3 Platform matrix | Target | Detection | Sources read | Live changes | diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index d95fc8c..34ff1bb 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::radio::{Radio, RadioGroup}; use gpui_native_core::switch::Switch; use gpui_native_icons::{IconAssets, IconRole, icon}; use gpui_native_theme::{ActiveTheme as _, Theme}; @@ -16,6 +17,7 @@ struct Gallery { button_clicks: usize, checkbox_checked: bool, switch_checked: bool, + plan: SharedString, } impl Render for Gallery { @@ -268,6 +270,32 @@ impl Render for Gallery { ), ), ) + .child( + v_flex() + .gap_2() + .child(section_label( + "radio · native refs — NSButton (radio), WinUI RadioButton, Adwaita CheckButton (radio), Breeze", + colors, + )) + .child({ + let gallery = gallery.clone(); + RadioGroup::new("gallery-radio-group") + .selected(Some(self.plan.clone())) + .on_change(move |plan, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.plan = plan; + cx.notify(); + }); + }) + .child(Radio::new("radio-starter", SharedString::from("starter")).label("Starter")) + .child(Radio::new("radio-pro", SharedString::from("pro")).label("Pro")) + .child( + Radio::new("radio-team", SharedString::from("team")) + .label("Team") + .disabled(true), + ) + }), + ) .child( div() .flex() @@ -421,6 +449,7 @@ fn main() { button_clicks: 0, checkbox_checked: false, switch_checked: true, + plan: SharedString::from("starter"), }) }, ) {