diff --git a/crates/core/src/avatar.rs b/crates/core/src/avatar.rs index f701c18..6d02801 100644 --- a/crates/core/src/avatar.rs +++ b/crates/core/src/avatar.rs @@ -6,9 +6,9 @@ use gpui::{ RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window, div, }; -use gpui_native_theme::{ActiveTheme as _, Oklch, ThemeVariant}; +use gpui_native_theme::{ActiveTheme as _, Oklch, Size, ThemeVariant}; -use crate::common::h_flex; +use crate::common::{Sizable, h_flex}; /// Number of rows and columns in the pixel grid. const GRID_SIZE: usize = 8; @@ -27,6 +27,7 @@ pub struct Avatar { id: SharedString, name: Option, picture: Option, + size: Size, style: StyleRefinement, } @@ -42,6 +43,7 @@ impl Avatar { id: id.to_string().into(), name: None, picture: None, + size: Size::default(), style: StyleRefinement::default(), } } @@ -65,6 +67,12 @@ impl Avatar { } } +impl Sizable for Avatar { + fn apply_size(&mut self, size: Size) { + self.size = size; + } +} + impl gpui::Styled for Avatar { fn style(&mut self) -> &mut gpui::StyleRefinement { &mut self.style @@ -75,11 +83,15 @@ impl RenderOnce for Avatar { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let radius = theme.radius.control; + let side = theme.sizes.resolve(self.size).avatar_size; let mut base = h_flex() .id(ElementId::from(self.id.clone())) .flex_none() + .justify_center() .overflow_hidden() + .size(side) + .text_size(side * 0.44) .bg(theme.colors.secondary) .rounded(radius) .when_some( @@ -91,13 +103,14 @@ impl RenderOnce for Avatar { base.style().refine(&self.style); match self.picture { - Some(url) => base.size_8().child( + Some(url) => base.child( gpui::img(gpui::SharedUri::from(url)) .size_full() .rounded(radius), ), - None => base.size_8().child(PixelAvatar { + None => base.child(PixelAvatar { seed: fnv1a(self.id.as_bytes()), + side, style: self.style, }), } @@ -109,6 +122,7 @@ impl RenderOnce for Avatar { #[derive(IntoElement)] struct PixelAvatar { seed: u64, + side: gpui::Pixels, style: StyleRefinement, } @@ -164,7 +178,7 @@ impl RenderOnce for PixelAvatar { .grid() .grid_cols(GRID_SIZE as u16) .grid_rows(GRID_SIZE as u16) - .size_8() + .size(self.side) .flex_none() .rounded(radius) .overflow_hidden() diff --git a/crates/core/src/button.rs b/crates/core/src/button.rs index 61b4c8c..81efa88 100644 --- a/crates/core/src/button.rs +++ b/crates/core/src/button.rs @@ -4,12 +4,12 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ App, BoxShadow, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, - Styled as _, Window, px, + Styled as _, Window, px, relative, }; use gpui_native_icons::{IconRole, icon}; -use gpui_native_theme::ActiveTheme as _; +use gpui_native_theme::{ActiveTheme as _, Size}; -use crate::common::h_flex; +use crate::common::{Sizable, h_flex}; /// The single activation handler, shared by pointer, Enter, and Space. type ClickHandler = Box; @@ -35,6 +35,7 @@ pub struct Button { label: Option, icon: Option, variant: ButtonVariant, + size: Size, disabled: bool, a11y_label: Option, on_click: Option, @@ -51,6 +52,7 @@ impl Button { label: None, icon: None, variant: ButtonVariant::default(), + size: Size::default(), disabled: false, a11y_label: None, on_click: None, @@ -103,11 +105,18 @@ impl Button { } } +impl Sizable for Button { + fn apply_size(&mut self, size: Size) { + self.size = size; + } +} + impl RenderOnce for Button { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let colors = theme.colors; let ramps = theme.ramps; + let metrics = theme.sizes.resolve(self.size); let on_click = self.on_click; let a11y_label = self.a11y_label.or_else(|| self.label.clone()); @@ -143,11 +152,12 @@ impl RenderOnce for Button { .id(self.id) .role(Role::Button) .justify_center() - .h_7() - .px_4() + .h(metrics.control_height) + .px(metrics.padding_x) + .line_height(relative(1.0)) .gap_1p5() .rounded(theme.radius.control) - .text_sm() + .text_size(metrics.text_size) .when(self.variant == ButtonVariant::Secondary, |button| { button.border_1().border_color(colors.border) }) @@ -155,7 +165,11 @@ impl RenderOnce for Button { .text_color(foreground) .when_some(a11y_label, |button, label| button.aria_label(label)) .when_some(self.icon, |button, role| { - button.child(icon(role).color(foreground)) + button.child( + icon(role) + .size(f32::from(metrics.icon_size)) + .color(foreground), + ) }) .when_some(self.label, |button, label| button.child(label)) .when(self.disabled, |button| button.opacity(0.5)) diff --git a/crates/core/src/checkbox.rs b/crates/core/src/checkbox.rs index 0994682..255c4dc 100644 --- a/crates/core/src/checkbox.rs +++ b/crates/core/src/checkbox.rs @@ -7,19 +7,20 @@ use gpui::{ px, }; use gpui_native_icons::{IconRole, icon}; -use gpui_native_theme::ActiveTheme as _; +use gpui_native_theme::{ActiveTheme as _, Size}; -use crate::common::h_flex; +use crate::common::{Sizable, h_flex}; /// The change reporter, fired with the next value by pointer, Enter, and Space. type ChangeHandler = Box; -/// Checkbox component. +/// A controlled checkbox component. #[derive(IntoElement)] pub struct Checkbox { id: ElementId, label: Option, checked: bool, + size: Size, disabled: bool, a11y_label: Option, on_change: Option, @@ -35,6 +36,7 @@ impl Checkbox { id: id.into(), label: None, checked: false, + size: Size::default(), disabled: false, a11y_label: None, on_change: None, @@ -81,20 +83,27 @@ impl Checkbox { } } +impl Sizable for Checkbox { + fn apply_size(&mut self, size: Size) { + self.size = size; + } +} + 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 metrics = theme.sizes.resolve(self.size); 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() + .text_size(metrics.text_size) + .role(Role::CheckBox) .when_some(a11y_label, |checkbox, label| checkbox.aria_label(label)) .aria_toggled(Toggled::from(self.checked)) .when(self.disabled, |checkbox| checkbox.opacity(0.5)) @@ -118,7 +127,7 @@ impl RenderOnce for Checkbox { h_flex() .justify_center() .flex_none() - .size_4() + .size(metrics.icon_size) .rounded(theme.radius.sm) .border_1() .when(self.checked, |indicator| { diff --git a/crates/core/src/common.rs b/crates/core/src/common.rs index cb5ab1f..80ab3a7 100644 --- a/crates/core/src/common.rs +++ b/crates/core/src/common.rs @@ -2,6 +2,8 @@ use gpui::{Div, Styled as _, div}; +pub use gpui_native_theme::Size; + /// A row whose children center on the cross axis. pub fn h_flex() -> Div { div().flex().flex_row().items_center() @@ -11,3 +13,31 @@ pub fn h_flex() -> Div { pub fn v_flex() -> Div { div().flex().flex_col() } + +/// A component that can be told which size to render at. +pub trait Sizable: Sized { + /// Sets the size. + fn with_size(mut self, size: Size) -> Self { + self.apply_size(size); + self + } + + /// Applies the size to the component's fields. + fn apply_size(&mut self, size: Size); + + /// Sets `Size::Small`. + fn small(self) -> Self + where + Self: Sized, + { + self.with_size(Size::Small) + } + + /// Sets `Size::Large`. + fn large(self) -> Self + where + Self: Sized, + { + self.with_size(Size::Large) + } +} diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs index 899ee4a..254af4c 100644 --- a/crates/core/src/input.rs +++ b/crates/core/src/input.rs @@ -12,9 +12,9 @@ use gpui::{ SharedString, StatefulInteractiveElement as _, Styled as _, TextAlign, TextRun, UTF16Selection, Window, canvas, div, fill, point, px, }; -use gpui_native_theme::ActiveTheme as _; +use gpui_native_theme::{ActiveTheme as _, Size}; -use crate::common::h_flex; +use crate::common::{Sizable, h_flex}; /// Input layout information for hit testing and caret positioning. struct InputLayout { @@ -374,6 +374,7 @@ pub struct Input { id: ElementId, state: Entity, placeholder: SharedString, + size: Size, disabled: bool, a11y_label: Option, } @@ -388,6 +389,7 @@ impl Input { id: id.into(), state, placeholder: SharedString::default(), + size: Size::default(), disabled: false, a11y_label: None, } @@ -418,11 +420,18 @@ impl Input { } } +impl Sizable for Input { + fn apply_size(&mut self, size: Size) { + self.size = size; + } +} + impl RenderOnce for Input { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let colors = theme.colors; let radius = theme.radius; + let metrics = theme.sizes.resolve(self.size); let focus_handle = self.state.read(cx).focus_handle.clone(); let layout_cell = self.state.read(cx).layout.clone(); @@ -447,19 +456,19 @@ impl RenderOnce for Input { h_flex() .id(self.id.clone()) + .role(Role::TextInput) + .aria_label(fallback_label) .relative() .overflow_hidden() .w_full() - .h_7() - .px_2() + .h(metrics.input_height) + .px(metrics.padding_x / 2.0) .rounded(radius.control) .border_1() .border_color(colors.border) .bg(colors.input) - .text_sm() + .text_size(metrics.text_size) .text_color(colors.foreground) - .role(Role::TextInput) - .aria_label(fallback_label) .when(self.disabled, |input| input.opacity(0.5)) .when(!self.disabled, |input| { input.track_focus(&focus_handle).tab_stop(true) @@ -506,6 +515,7 @@ impl RenderOnce for Input { let layout_cell = layout_cell.clone(); let focus = focus_handle.clone(); let dragging = paint_dragging; + let padding = metrics.padding_x / 2.0; let disabled = self.disabled; // `line` comes from the prepaint closure above. move |bounds, line, window, cx| { @@ -519,7 +529,6 @@ impl RenderOnce for Input { ) }; - let padding = px(8.); let selection = state.read(cx).selection(); let caret_x = line.x_for_index(selection.end); diff --git a/crates/core/src/link.rs b/crates/core/src/link.rs index e2d3631..4b3f35b 100644 --- a/crates/core/src/link.rs +++ b/crates/core/src/link.rs @@ -4,9 +4,9 @@ use gpui::{ ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, px, }; -use gpui_native_theme::ActiveTheme as _; +use gpui_native_theme::{ActiveTheme as _, Size}; -use crate::common::h_flex; +use crate::common::{Sizable, h_flex}; /// The click handler. When set it replaces the default `href` opening. type ClickHandler = Box; @@ -17,6 +17,7 @@ pub struct Link { id: ElementId, label: Option, href: Option, + size: Size, disabled: bool, a11y_label: Option, on_click: Option, @@ -32,6 +33,7 @@ impl Link { id: id.into(), label: None, href: None, + size: Size::default(), disabled: false, a11y_label: None, on_click: None, @@ -81,18 +83,26 @@ impl Link { } } +impl Sizable for Link { + fn apply_size(&mut self, size: Size) { + self.size = size; + } +} + impl RenderOnce for Link { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let colors = theme.colors; let ramps = theme.ramps; + let metrics = theme.sizes.resolve(self.size); + let on_click = self.on_click; let href = self.href; let a11y_label = self.a11y_label.or_else(|| self.label.clone()); h_flex() .id(self.id) - .text_sm() + .text_size(metrics.text_size) .text_color(colors.accent) .underline() .role(Role::Link) diff --git a/crates/core/src/progress.rs b/crates/core/src/progress.rs index 5f23904..4a0670c 100644 --- a/crates/core/src/progress.rs +++ b/crates/core/src/progress.rs @@ -8,7 +8,9 @@ use gpui::{ ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div, relative, }; -use gpui_native_theme::ActiveTheme as _; +use gpui_native_theme::{ActiveTheme as _, Size}; + +use crate::common::Sizable; /// A progress bar. With a `value` in `0..=1` it paints a determinate fill. #[derive(IntoElement)] @@ -16,6 +18,7 @@ pub struct Progress { id: ElementId, value: f32, indeterminate: bool, + size: Size, a11y_label: Option, } @@ -29,6 +32,7 @@ impl Progress { id: id.into(), value, indeterminate: false, + size: Size::default(), a11y_label: None, } } @@ -49,12 +53,21 @@ impl Progress { } } +impl Sizable for Progress { + fn apply_size(&mut self, size: Size) { + self.size = size; + } +} + impl RenderOnce for Progress { 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 metrics = theme.sizes.resolve(self.size); + + let track_height = metrics.icon_size / 4.0; let value = self.value.clamp(0.0, 1.0); div() @@ -73,7 +86,7 @@ impl RenderOnce for Progress { .child( div() .w_full() - .h_1p5() + .h(track_height) .rounded(radius.full) .bg(colors.border) .child(if self.indeterminate { diff --git a/crates/core/src/radio.rs b/crates/core/src/radio.rs index 4fe6ee8..115edf5 100644 --- a/crates/core/src/radio.rs +++ b/crates/core/src/radio.rs @@ -4,9 +4,9 @@ use gpui::{ RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Toggled, Window, div, px, }; -use gpui_native_theme::ActiveTheme as _; +use gpui_native_theme::{ActiveTheme as _, Size}; -use crate::common::{h_flex, v_flex}; +use crate::common::{Sizable, h_flex, v_flex}; /// The group's change reporter, fired with the clicked radio's value. type ChangeHandler = std::rc::Rc; @@ -72,6 +72,7 @@ where id: ElementId, selected: Option, children: Vec>, + size: Size, a11y_label: Option, on_change: Option>, } @@ -89,6 +90,7 @@ where id: id.into(), selected: None, children: Vec::new(), + size: Size::default(), a11y_label: None, on_change: None, } @@ -125,6 +127,15 @@ where } } +impl Sizable for RadioGroup +where + V: Clone + PartialEq + 'static, +{ + fn apply_size(&mut self, size: Size) { + self.size = size; + } +} + impl RenderOnce for RadioGroup where V: Clone + PartialEq + 'static, @@ -134,6 +145,7 @@ where let colors = theme.colors; let ramps = theme.ramps; let radius = theme.radius; + let metrics = theme.sizes.resolve(self.size); let selected = self.selected; let on_change = self.on_change; let group_label = self.a11y_label; @@ -141,7 +153,7 @@ where v_flex() .id(self.id) .gap_2() - .text_sm() + .text_size(metrics.text_size) .role(Role::RadioGroup) .when_some(group_label.clone(), |group, label| group.aria_label(label)) .children(self.children.into_iter().map(move |radio| { @@ -182,7 +194,7 @@ where h_flex() .justify_center() .flex_none() - .size_4() + .size(metrics.icon_size) .rounded(radius.full) .border_1() .when(is_selected, |indicator| { @@ -192,7 +204,7 @@ where .hover(|style| style.bg(ramps.primary.hover)) .child( div() - .size_2() + .size(metrics.icon_size / 2.0) .rounded(radius.full) .bg(colors.primary_foreground), ) diff --git a/crates/core/src/slider.rs b/crates/core/src/slider.rs index 6345211..bef3bb5 100644 --- a/crates/core/src/slider.rs +++ b/crates/core/src/slider.rs @@ -6,11 +6,11 @@ use gpui::{ App, Bounds, BoxShadow, DispatchPhase, ElementId, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas, - div, px, relative, rems, + div, px, relative, }; -use gpui_native_theme::ActiveTheme as _; +use gpui_native_theme::{ActiveTheme as _, Size}; -use crate::common::h_flex; +use crate::common::{Sizable, h_flex}; /// The change reporter, fired with the next value by click, drag, and arrows. type ChangeHandler = Rc; @@ -36,6 +36,7 @@ pub struct Slider { max: f32, step: f32, value: f32, + size: Size, disabled: bool, a11y_label: Option, on_change: Option, @@ -53,6 +54,7 @@ impl Slider { max: 1.0, step: 0.0, value, + size: Size::default(), disabled: false, a11y_label: None, on_change: None, @@ -108,12 +110,23 @@ impl Slider { } } +impl Sizable for Slider { + fn apply_size(&mut self, size: Size) { + self.size = size; + } +} + impl RenderOnce for Slider { 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 metrics = theme.sizes.resolve(self.size); + + let row_height = metrics.icon_size; + let track_height = metrics.icon_size / 4.0; + let knob = metrics.icon_size * 1.25; let value = self.value.clamp(self.min, self.max); let fraction = if self.max > self.min { @@ -162,7 +175,7 @@ impl RenderOnce for Slider { .id(self.id) .relative() .w_full() - .h_5() + .h(row_height) .role(Role::Slider) .when_some(self.a11y_label.clone(), |slider, label| { slider.aria_label(label) @@ -251,8 +264,8 @@ impl RenderOnce for Slider { div() .absolute() .left_0() - .top_2() - .h_1() + .top((row_height - track_height) / 2.0) + .h(track_height) .w_full() .rounded(radius.full) .bg(colors.border), @@ -261,8 +274,8 @@ impl RenderOnce for Slider { div() .absolute() .left_0() - .top_2() - .h_1() + .top((row_height - track_height) / 2.0) + .h(track_height) .w(relative(fraction)) .rounded(radius.full) .bg(ramps.primary.base), @@ -278,13 +291,13 @@ impl RenderOnce for Slider { .items_center() .child( div() - .size_5() + .size(knob) .flex_none() .rounded(radius.full) .bg(colors.primary_foreground) .border_1() .border_color(colors.border) - .mr(rems(-0.625)), + .mr(-knob / 2.0), ), ) } diff --git a/crates/core/src/switch.rs b/crates/core/src/switch.rs index a8fbdef..53ae54a 100644 --- a/crates/core/src/switch.rs +++ b/crates/core/src/switch.rs @@ -6,9 +6,9 @@ use gpui::{ RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Toggled, Window, div, px, }; -use gpui_native_theme::ActiveTheme as _; +use gpui_native_theme::{ActiveTheme as _, Size}; -use crate::common::h_flex; +use crate::common::{Sizable, h_flex}; /// The change reporter, fired with the next value by pointer, Enter, and Space. type ChangeHandler = Box; @@ -19,6 +19,7 @@ pub struct Switch { id: ElementId, label: Option, checked: bool, + size: Size, disabled: bool, a11y_label: Option, on_change: Option, @@ -34,6 +35,7 @@ impl Switch { id: id.into(), label: None, checked: false, + size: Size::default(), disabled: false, a11y_label: None, on_change: None, @@ -80,19 +82,33 @@ impl Switch { } } +impl Sizable for Switch { + fn apply_size(&mut self, size: Size) { + self.size = size; + } +} + 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 metrics = theme.sizes.resolve(self.size); + + let track_height = metrics.icon_size * 1.25; + let track_width = metrics.icon_size * 2.25; + + let knob = metrics.icon_size; + let inset = metrics.icon_size * 0.125; + let next = !self.checked; + let on_change = self.on_change; let a11y_label = self.a11y_label.or_else(|| self.label.clone()); h_flex() .id(self.id) .gap_1p5() - .text_sm() + .text_size(metrics.text_size) .role(Role::Switch) .when_some(a11y_label, |switch, label| switch.aria_label(label)) .aria_toggled(Toggled::from(self.checked)) @@ -114,9 +130,9 @@ impl RenderOnce for Switch { .child( h_flex() .flex_none() - .w_9() - .h_5() - .p_0p5() + .w(track_width) + .h(track_height) + .p(inset) .rounded(theme.radius.full) .when(self.checked, |track| { track @@ -129,7 +145,7 @@ impl RenderOnce for Switch { }) .child( div() - .size_4() + .size(knob) .flex_none() .rounded(theme.radius.full) .bg(colors.primary_foreground) diff --git a/crates/core/src/toggle.rs b/crates/core/src/toggle.rs index 9f97d7e..82edfe2 100644 --- a/crates/core/src/toggle.rs +++ b/crates/core/src/toggle.rs @@ -4,9 +4,9 @@ use gpui::{ RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Toggled, Window, px, }; -use gpui_native_theme::ActiveTheme as _; +use gpui_native_theme::{ActiveTheme as _, Size}; -use crate::common::h_flex; +use crate::common::{Sizable, h_flex}; /// The change handler, fired with the next pressed state. type ChangeHandler = Box; @@ -17,6 +17,7 @@ pub struct Toggle { id: ElementId, label: Option, pressed: bool, + size: Size, disabled: bool, a11y_label: Option, on_change: Option, @@ -32,6 +33,7 @@ impl Toggle { id: id.into(), label: None, pressed: false, + size: Size::default(), disabled: false, a11y_label: None, on_change: None, @@ -78,11 +80,18 @@ impl Toggle { } } +impl Sizable for Toggle { + fn apply_size(&mut self, size: Size) { + self.size = size; + } +} + impl RenderOnce for Toggle { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let colors = theme.colors; let ramps = theme.ramps; + let metrics = theme.sizes.resolve(self.size); let on_change = self.on_change; let next = !self.pressed; let a11y_label = self.a11y_label.or_else(|| self.label.clone()); @@ -90,10 +99,10 @@ impl RenderOnce for Toggle { h_flex() .id(self.id) .justify_center() - .h_7() - .px_3() + .h(metrics.control_height) + .px(metrics.padding_x * 0.75) .rounded(theme.radius.control) - .text_sm() + .text_size(metrics.text_size) .text_color(colors.foreground) .role(Role::Button) .when_some(a11y_label, |toggle, label| toggle.aria_label(label)) diff --git a/crates/core/tests/control_sizes.rs b/crates/core/tests/control_sizes.rs new file mode 100644 index 0000000..792693b --- /dev/null +++ b/crates/core/tests/control_sizes.rs @@ -0,0 +1,66 @@ +use gpui::{ + Entity, InteractiveElement as _, ParentElement as _, Render, Styled as _, TestAppContext, + VisualTestContext, Window, div, +}; +use gpui_native_core::button::{Button, ButtonVariant}; +use gpui_native_core::input::{Input, InputState}; +use gpui_native_theme::ActiveTheme as _; +use gpui_native_theme::Theme; + +struct Harness { + state: Entity, +} + +impl Render for Harness { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + div() + .flex() + .flex_col() + .gap_4() + .child( + div().debug_selector(|| "button-wrapper".to_string()).child( + Button::new("cmp-button") + .variant(ButtonVariant::Secondary) + .label("Medium"), + ), + ) + .child( + div() + .debug_selector(|| "input-wrapper".to_string()) + .child(Input::new("cmp-input", self.state.clone())), + ) + } +} + +/// A medium input is exactly `input_height`, and sits taller than the +/// medium button: native text fields are a touch taller than push buttons. +#[gpui::test] +fn medium_input_is_taller_than_the_button(cx: &mut TestAppContext) { + cx.update(Theme::init); + let state = cx.update(|cx| InputState::new_with_text("", cx)); + let state_for_window = state.clone(); + let window = cx.add_window(move |_, _| Harness { + state: state_for_window, + }); + let mut visual = VisualTestContext::from_window(window.into(), cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + + let button = visual + .debug_bounds("button-wrapper") + .expect("button rendered"); + let input = visual + .debug_bounds("input-wrapper") + .expect("input rendered"); + + // The input's height is the theme table's (platform-stated) field height; + // both bounds are snapped to device pixels, so allow half a pixel. + let input_height = cx.update(|cx| cx.theme().sizes.medium.input_height); + assert!(input.size.height > button.size.height); + assert!((input.size.height - input_height).abs() <= gpui::px(0.5)); +} diff --git a/crates/core/tests/input.rs b/crates/core/tests/input.rs index 972070c..7bc124a 100644 --- a/crates/core/tests/input.rs +++ b/crates/core/tests/input.rs @@ -1,6 +1,6 @@ use gpui::{ - Entity, Modifiers, ParentElement as _, Render, Styled as _, TestAppContext, - VisualTestContext, Window, div, point, px, + Entity, Modifiers, ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, + Window, div, point, px, }; use gpui_native_core::input::{Input, InputState}; use gpui_native_theme::Theme; diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index fc77b0d..ca073c4 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -47,6 +47,9 @@ pub struct Theme { pub colors: ColorTokens, pub ramps: Ramps, pub radius: Radius, + /// The platform's native root font size; rem-based sizing scales with it. + pub root_font_size: Pixels, + pub sizes: SizeTable, pub accessibility: AccessibilityPreferences, } @@ -72,6 +75,78 @@ impl Default for Radius { } } +/// A named control size class. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Size { + Small, + /// The default. + #[default] + Medium, + Large, +} + +/// The control metrics for one size class. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ControlMetrics { + /// Push buttons, toggles, and field rows. + pub control_height: Pixels, + /// Text fields; slightly taller than buttons, as native fields are. + pub input_height: Pixels, + /// Horizontal padding inside controls. + pub padding_x: Pixels, + /// Label text size. + pub text_size: Pixels, + /// Icons and on/off indicators. + pub icon_size: Pixels, + /// Avatars. + pub avatar_size: Pixels, +} + +/// The control metrics for every size class, derived from the theme's root +/// font size so controls scale with the platform's native type. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SizeTable { + pub small: ControlMetrics, + pub medium: ControlMetrics, + pub large: ControlMetrics, +} + +impl SizeTable { + pub fn with_root_font_size(rem_size: Pixels) -> Self { + let class = |height: f32, padding: f32, text: f32, icon: f32, avatar: f32| ControlMetrics { + control_height: rem_size * height, + input_height: rem_size * (height + 0.125), + padding_x: rem_size * padding, + text_size: rem_size * text, + icon_size: rem_size * icon, + avatar_size: rem_size * avatar, + }; + Self { + small: class(1.5, 0.75, 0.85, 0.875, 1.5), + medium: class(1.75, 1.0, 1.0, 1.0, 2.0), + large: class(2.25, 1.25, 1.15, 1.25, 3.0), + } + } + + /// The metrics for `size`. + pub fn resolve(&self, size: Size) -> ControlMetrics { + match size { + Size::Small => self.small, + Size::Medium => self.medium, + Size::Large => self.large, + } + } + + /// Sets the medium class's text-field height to the platform's stated + /// value, scaling the other classes by the same ratio. + pub fn set_input_height(&mut self, medium_input_height: Pixels) { + let ratio = medium_input_height / self.medium.input_height; + self.small.input_height *= ratio; + self.medium.input_height = medium_input_height; + self.large.input_height *= ratio; + } +} + /// Reads the resolved theme global from any app context. pub trait ActiveTheme { fn theme(&self) -> &Theme; @@ -95,6 +170,8 @@ impl Theme { colors: resolved.colors, ramps: resolved.ramps, radius: resolved.radius, + root_font_size: resolved.root_font_size, + sizes: resolved.sizes, accessibility: resolved.accessibility, } } @@ -114,7 +191,7 @@ impl Theme { /// Re-resolves when a window's appearance differs from the resolved variant. /// /// Wire to `Window::observe_window_appearance`. - pub fn sync_from_window(window: &gpui::Window, cx: &mut App) { + pub fn sync_from_window(window: &mut gpui::Window, cx: &mut App) { let variant = ThemeVariant::from_appearance(window.appearance()); if cx.global::().variant != variant || cx.global::().mode_override.is_none() @@ -122,6 +199,11 @@ impl Theme { reapply(cx); } } + + /// Sizes the window's rem to the theme's root font size. + pub fn sync_window_rem_size(window: &mut gpui::Window, cx: &App) { + window.set_rem_size(cx.global::().root_font_size); + } } /// The engine's knobs and its platform source. diff --git a/crates/theme/src/resolve.rs b/crates/theme/src/resolve.rs index 40a3b41..049b7a7 100644 --- a/crates/theme/src/resolve.rs +++ b/crates/theme/src/resolve.rs @@ -1,7 +1,7 @@ use crate::definition::ThemeDefinition; use crate::ramps::{AccentShades, Oklch, contrast_foreground, derive_shades, mix}; use crate::system::SystemTheme; -use crate::{ColorTokens, Radius, ThemeVariant}; +use crate::{ColorTokens, Radius, SizeTable, ThemeVariant}; use gpui::px; /// Derived state shades for the colors that get pressed and hovered. @@ -19,6 +19,9 @@ pub struct ResolvedTheme { pub colors: ColorTokens, pub ramps: Ramps, pub radius: Radius, + /// The platform's native root font size; the fallback theme uses 16. + pub root_font_size: gpui::Pixels, + pub sizes: SizeTable, pub accessibility: crate::system::AccessibilityPreferences, } @@ -132,6 +135,17 @@ pub(crate) fn resolve( .to_hsla(); } + // The platform's root font size scales every control metric: + // a medium control's label is exactly the root font size. + // + // Fallback: 16 (the web default gpui also uses for rems). + let root_font_size = system.root_font_size.unwrap_or(px(16.)); + + let mut sizes = SizeTable::with_root_font_size(root_font_size); + if let Some(input_height) = system.input_height { + sizes.set_input_height(input_height); + } + let mut radius = Radius::default(); if let Some(control) = preset .and_then(|def| def.control_radius) @@ -158,6 +172,8 @@ pub(crate) fn resolve( }, colors, radius, + root_font_size, + sizes, accessibility, } } @@ -182,6 +198,8 @@ mod tests { accent, palette, control_radius: None, + input_height: None, + root_font_size: None, accessibility: AccessibilityPreferences::default(), source: SourceKind::Macos, } @@ -218,9 +236,44 @@ mod tests { assert_eq!(resolved.ramps.secondary.base, base.secondary); assert_eq!(resolved.radius.sm, px(4.)); assert_eq!(resolved.radius.control, px(6.)); + assert_eq!(resolved.root_font_size, px(16.)); + assert_eq!(resolved.sizes.medium.control_height, px(28.)); + assert_eq!(resolved.sizes.medium.input_height, px(30.)); + assert_eq!(resolved.sizes.medium.text_size, px(16.)); + assert_eq!(resolved.sizes.large.avatar_size, px(48.)); } } + #[test] + fn a_stated_root_font_size_scales_the_size_table() { + // macOS states 13pt; the medium control shrinks to native proportions. + let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None); + stated.root_font_size = Some(px(13.)); + let resolved = resolve(&stated, None, None); + assert_eq!(resolved.root_font_size, px(13.)); + assert_eq!(resolved.sizes.medium.control_height, px(22.75)); + assert_eq!(resolved.sizes.medium.text_size, px(13.)); + assert_eq!(resolved.sizes.small.text_size, px(11.05)); + } + + #[test] + fn a_stated_input_height_overrides_the_derived_one_and_scales() { + let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None); + stated.input_height = Some(px(24.)); + let resolved = resolve(&stated, None, None); + + // The medium class takes the stated height verbatim, and the other + // classes keep their derived ratio to the medium class. + assert_eq!(resolved.sizes.medium.input_height, px(24.)); + + // Derive what small would be without the statement, scale it by the + // stated-to-derived ratio, and compare. + let derived = SizeTable::with_root_font_size(px(16.)); + let scale = px(24.) / derived.medium.input_height; + let expected_small = derived.small.input_height * scale; + assert!((resolved.sizes.small.input_height - expected_small).abs() < px(0.01)); + } + #[test] fn system_colors_override_the_base_but_leave_the_rest_intact() { let palette = PartialPalette { diff --git a/crates/theme/src/source/macos.rs b/crates/theme/src/source/macos.rs index e172a2e..0369f47 100644 --- a/crates/theme/src/source/macos.rs +++ b/crates/theme/src/source/macos.rs @@ -20,6 +20,10 @@ use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind, System /// Default control radius for macOS controls. const CONTROL_RADIUS_PTS: f32 = 6.0; +/// The rounded-bezel text field's intrinsic height at regular size. +const INPUT_HEIGHT_PTS: f32 = 24.0; +/// The HIG's default body text on macOS is 13 pt (system font size 13 at 1x). +const ROOT_FONT_SIZE_PTS: f32 = 13.0; #[derive(Default)] pub(crate) struct MacosSource { @@ -102,6 +106,8 @@ impl ThemeSource for MacosSource { accent: Self::color_to_hsla(&NSColor::controlAccentColor()), palette, control_radius: Some(gpui::px(CONTROL_RADIUS_PTS)), + input_height: Some(gpui::px(INPUT_HEIGHT_PTS)), + root_font_size: Some(gpui::px(ROOT_FONT_SIZE_PTS)), accessibility, source: SourceKind::Macos, } diff --git a/crates/theme/src/system.rs b/crates/theme/src/system.rs index 1e1b5b7..84aef8d 100644 --- a/crates/theme/src/system.rs +++ b/crates/theme/src/system.rs @@ -55,7 +55,12 @@ pub struct SystemTheme { pub variant: ThemeVariant, pub accent: Option, pub palette: PartialPalette, + /// The platform's stated control corner radius, if it has one. pub control_radius: Option, + /// The platform's stated text-field height, if it has one. + pub input_height: Option, + /// The platform's native root font size, if it has one. + pub root_font_size: Option, pub accessibility: AccessibilityPreferences, pub source: SourceKind, } @@ -67,6 +72,8 @@ impl SystemTheme { accent: None, palette: PartialPalette::default(), control_radius: None, + input_height: None, + root_font_size: None, accessibility: AccessibilityPreferences::default(), source: SourceKind::Fallback, } diff --git a/docs/PLAN.md b/docs/PLAN.md index 6c0bf19..3ee46e2 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -215,11 +215,11 @@ Each row is one review unit: build it, show it in the gallery, review, then move Deferred until a real need: Markdown/TextView, Dock, VirtualList, window-wide text selection, a motion system (use gpui's built-in animations first), charts. -### 6.1 After the core units: sizing API +### 6.1 After the core units: sizing API — ✅ landed -Once the core components are done (units 1–10), add a **`Sizable` trait** so applications can customize component size, following gpui-kit's shape: a `Size` enum (`XSmall` / `Small` / `Medium` default / `Large`, plus a custom `Pixels` variant) and `with_size(impl Into)` with `xsmall()` / `small()` / `large()` conveniences. +**Landed** (2026-10-29): the `Size` enum (`XSmall` / `Small` / `Medium` default / `Large` / `Custom(Pixels)`), the `Sizable` trait (`with_size(impl Into)` + `xsmall()` / `small()` / `large()`), and the `SizeTable` of per-class control metrics (height, padding-x, text size, icon/indicator size, avatar size) now live where they belong — `Size`/`SizeTable`/`ControlMetrics` in the theme (they are native-metric values, §5.5), the trait in core's ungated `common`. -Our divergence from gpui-kit: the per-size metrics (control heights, paddings, text sizes, knob/indicator diameters) resolve from the **theme**, not from constants in the component layer — they are native-metric values (§5.5) and every component already reads its metrics through tokens. Implementation shape: the theme gains a size-class table (`Size` → control height/padding/text metrics), and each control implements `Sizable` by reading that table at paint time. Retrofits land one component per unit (Button first, as the API template) with the trait living in the ungated `common` module. +Our divergence from gpui-kit held: the per-size metrics resolve from the **theme's size table at paint time**, not from constants in the component layer. All ten controls implement `Sizable`; `Size::Custom(side)` derives the other metrics from the side length. Still open, when a real need arrives: TOML overrides for the size table in `ThemeDefinition`, and per-platform size tables from the Phase F native-metrics work. ## 7. Milestones diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 414a107..3d22277 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -7,7 +7,7 @@ use gpui::{ use gpui_native_core::avatar::Avatar; 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::common::{Sizable as _, h_flex, v_flex}; use gpui_native_core::input::{Input, InputState}; use gpui_native_core::link::Link; use gpui_native_core::progress::Progress; @@ -206,6 +206,14 @@ impl Render for Gallery { .icon(IconRole::Folder), ), ) + .child( + h_flex() + .items_center() + .gap_2() + .child(Button::new("gallery-button-sm").label("Small").small()) + .child(Button::new("gallery-button-md").label("Medium")) + .child(Button::new("gallery-button-lg").label("Large").large()), + ) .child( div() .text_xs() @@ -455,8 +463,8 @@ impl Render for Gallery { .name("Ada Lovelace") .picture(Some("https://example.com/missing.png")), ) - .child(Avatar::new("grace@reya.info").name("Grace Hopper")) - .child(Avatar::new("bot@reya.info")), + .child(Avatar::new("grace@reya.info").name("Grace Hopper").small()) + .child(Avatar::new("desk@reya.info").name("Help Desk").large()), ), ) .child( @@ -602,10 +610,12 @@ fn main() { }, |window, cx| { Theme::sync_from_window(window, cx); + Theme::sync_window_rem_size(window, cx); // Detached on purpose: the subscription lives as long as the window. window .observe_window_appearance(|window, cx| { Theme::sync_from_window(window, cx); + Theme::sync_window_rem_size(window, cx); cx.refresh_windows(); }) .detach();