This commit is contained in:
2026-11-01 15:30:12 +07:00
parent 749b3e4e29
commit 49da45c8ac
16 changed files with 128 additions and 64 deletions
+13 -7
View File
@@ -7,7 +7,7 @@ use gpui::{
Styled as _, Window, deferred, div, ease_out_quint, px, Styled as _, Window, deferred, div, ease_out_quint, px,
}; };
use gpui_native_icons::{IconRole, icon}; use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::ActiveTheme as _; use gpui_native_theme::{ActiveTheme as _, Size};
use crate::common::DismissHandler; use crate::common::DismissHandler;
use crate::common::v_flex; use crate::common::v_flex;
@@ -128,8 +128,10 @@ impl RenderOnce for Alert {
let surface = theme.colors.surface; let surface = theme.colors.surface;
let border = theme.colors.border; let border = theme.colors.border;
let shadows = theme.elevations.popover.shadows(); let shadows = theme.elevations.popover.shadows();
let text_size = theme.sizes.small.text_size; let metrics = theme.sizes.resolve(Size::Medium);
let icon_size = theme.sizes.small.icon_size; let icon_size = metrics.icon_size;
let title_size = metrics.text_size;
let caption_size = metrics.caption_size;
div() div()
.w_full() .w_full()
@@ -160,7 +162,7 @@ impl RenderOnce for Alert {
.when_some(self.title.clone(), |alert, title| { .when_some(self.title.clone(), |alert, title| {
alert.child( alert.child(
div() div()
.text_size(text_size) .text_size(title_size)
.text_color(foreground) .text_color(foreground)
.when(self.message.is_some(), |title| { .when(self.message.is_some(), |title| {
title.font_weight(gpui::FontWeight::SEMIBOLD) title.font_weight(gpui::FontWeight::SEMIBOLD)
@@ -169,13 +171,17 @@ impl RenderOnce for Alert {
) )
}) })
.when_some(self.message.clone(), |alert, message| { .when_some(self.message.clone(), |alert, message| {
// With no title, the message is the primary line.
let color = if self.title.is_some() { let color = if self.title.is_some() {
muted_foreground muted_foreground
} else { } else {
foreground foreground
}; };
alert.child(div().text_size(text_size).text_color(color).child(message)) alert.child(
div()
.text_size(caption_size)
.text_color(color)
.child(message),
)
}), }),
) )
.when_some(self.on_dismiss, |alert, on_dismiss| { .when_some(self.on_dismiss, |alert, on_dismiss| {
@@ -189,7 +195,7 @@ impl RenderOnce for Alert {
.hover(|style| style.bg(muted)) .hover(|style| style.bg(muted))
.child( .child(
icon(IconRole::Close) icon(IconRole::Close)
.size(theme.sizes.small.icon_size * 0.75) .size(theme.sizes.small.dense_icon_size)
.color(foreground.alpha(0.55)), .color(foreground.alpha(0.55)),
) )
.on_click(move |_, _window, cx| on_dismiss(cx)), .on_click(move |_, _window, cx| on_dismiss(cx)),
+2 -5
View File
@@ -83,11 +83,9 @@ impl RenderOnce for Badge {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme(); let theme = cx.theme();
let metrics = theme.sizes.resolve(self.size); let metrics = theme.sizes.resolve(self.size);
let label_size = metrics.caption_size;
let (background, foreground) = self.variant.tokens(theme); let (background, foreground) = self.variant.tokens(theme);
// Counts read smaller than control labels, like every native badge.
let label_size = metrics.text_size * 0.8;
h_flex() h_flex()
.flex_none() .flex_none()
.justify_center() .justify_center()
@@ -95,8 +93,7 @@ impl RenderOnce for Badge {
.bg(background) .bg(background)
.text_color(foreground) .text_color(foreground)
.text_size(label_size) .text_size(label_size)
.line_height(label_size * 1.25) .px(metrics.dense_padding_x)
.px(metrics.padding_x * 0.5)
.py_0p5() .py_0p5()
.child(self.label) .child(self.label)
.refine_style(&self.style) .refine_style(&self.style)
+6 -2
View File
@@ -121,7 +121,7 @@ impl RenderOnce for Checkbox {
h_flex() h_flex()
.justify_center() .justify_center()
.flex_none() .flex_none()
.size(metrics.icon_size) .size(metrics.indicator_size)
.rounded(theme.radius.sm) .rounded(theme.radius.sm)
.border_1() .border_1()
.when(self.checked, |indicator| { .when(self.checked, |indicator| {
@@ -129,7 +129,11 @@ impl RenderOnce for Checkbox {
.bg(ramps.primary.base) .bg(ramps.primary.base)
.border_color(ramps.primary.base) .border_color(ramps.primary.base)
.hover(|style| style.bg(ramps.primary.hover)) .hover(|style| style.bg(ramps.primary.hover))
.child(icon(IconRole::Check).color(colors.primary_foreground)) .child(
icon(IconRole::Check)
.size(metrics.dense_icon_size)
.color(colors.primary_foreground),
)
}) })
.when(!self.checked, |indicator| { .when(!self.checked, |indicator| {
indicator indicator
+1 -1
View File
@@ -1551,7 +1551,7 @@ impl RenderOnce for Input {
.overflow_hidden() .overflow_hidden()
.w_full() .w_full()
.h(metrics.input_height) .h(metrics.input_height)
.px(metrics.padding_x / 2.0) .px(metrics.dense_padding_x)
.rounded(radius.control) .rounded(radius.control)
.border_1() .border_1()
.border_color(colors.border) .border_color(colors.border)
+4 -2
View File
@@ -136,6 +136,7 @@ struct MenuChrome {
radius_overlay: Pixels, radius_overlay: Pixels,
radius_sm: Pixels, radius_sm: Pixels,
icon_size: Pixels, icon_size: Pixels,
dense_icon_size: Pixels,
shadow: Vec<BoxShadow>, shadow: Vec<BoxShadow>,
} }
@@ -234,6 +235,7 @@ impl RenderOnce for Menu {
radius_overlay: theme.radius.overlay, radius_overlay: theme.radius.overlay,
radius_sm: theme.radius.sm, radius_sm: theme.radius.sm,
icon_size: theme.sizes.medium.icon_size, icon_size: theme.sizes.medium.icon_size,
dense_icon_size: theme.sizes.medium.dense_icon_size,
shadow: theme.elevations.popover.shadows(), shadow: theme.elevations.popover.shadows(),
}, },
theme.motion.overlay, theme.motion.overlay,
@@ -348,7 +350,7 @@ impl RenderOnce for Menu {
.role(Role::Menu) .role(Role::Menu)
.aria_label(label) .aria_label(label)
.mt_1() .mt_1()
.min_w_40() .min_w_56()
.p_1() .p_1()
.bg(chrome.surface) .bg(chrome.surface)
.text_color(chrome.foreground) .text_color(chrome.foreground)
@@ -522,7 +524,7 @@ fn render_item(
.when(checked == Some(true), |row| { .when(checked == Some(true), |row| {
row.child( row.child(
icon_glyph(IconRole::Check) icon_glyph(IconRole::Check)
.size(chrome.icon_size * 0.75) .size(chrome.dense_icon_size)
.color(label_color), .color(label_color),
) )
}) })
+2 -2
View File
@@ -189,7 +189,7 @@ where
h_flex() h_flex()
.justify_center() .justify_center()
.flex_none() .flex_none()
.size(metrics.icon_size) .size(metrics.indicator_size)
.rounded(radius.full) .rounded(radius.full)
.border_1() .border_1()
.when(is_selected, |indicator| { .when(is_selected, |indicator| {
@@ -199,7 +199,7 @@ where
.hover(|style| style.bg(ramps.primary.hover)) .hover(|style| style.bg(ramps.primary.hover))
.child( .child(
div() div()
.size(metrics.icon_size / 2.0) .size(metrics.indicator_size / 2.0)
.rounded(radius.full) .rounded(radius.full)
.bg(colors.primary_foreground), .bg(colors.primary_foreground),
) )
+6 -23
View File
@@ -5,7 +5,7 @@ use gpui::{
AnimationExt as _, App, Bounds, ElementId, Entity, InteractiveElement as _, IntoElement, AnimationExt as _, App, Bounds, ElementId, Entity, InteractiveElement as _, IntoElement,
KeyDownEvent, ParentElement as _, Point, RenderOnce, Role, SharedString, SpringAnimation, KeyDownEvent, ParentElement as _, Point, RenderOnce, Role, SharedString, SpringAnimation,
SpringConfig, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Toggled, Window, SpringConfig, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Toggled, Window,
canvas, div, rems, canvas, div,
}; };
use gpui_native_icons::{IconRole, icon}; use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, Size}; use gpui_native_theme::{ActiveTheme as _, Size};
@@ -152,29 +152,12 @@ where
let ring = theme.colors.ring; let ring = theme.colors.ring;
let radius = theme.radius.full; let radius = theme.radius.full;
let plate_shadow = theme.elevations.control.shadows(); let plate_shadow = theme.elevations.control.shadows();
let icon_size = metrics.icon_size * 0.85;
let track_height = match self.size { let track_height = metrics.strip_height;
Size::Small => metrics.control_height, let track_inset = metrics.plate_inset;
Size::Medium => metrics.control_height * 1.15, let horizontal_padding = metrics.dense_padding_x;
Size::Large => metrics.control_height * 1.35, let text_size = metrics.caption_size;
}; let icon_size = metrics.icon_size;
let track_inset = match self.size {
Size::Small => rems(0.125),
Size::Medium => rems(0.25),
Size::Large => rems(0.375),
};
let horizontal_padding = match self.size {
Size::Small => metrics.padding_x * 0.5,
Size::Medium | Size::Large => metrics.padding_x * 0.75,
};
let text_size = match self.size {
Size::Small => metrics.text_size * 0.85,
Size::Medium | Size::Large => metrics.text_size,
};
let selected_index = self let selected_index = self
.options .options
+2 -2
View File
@@ -168,7 +168,7 @@ where
let foreground = theme.colors.foreground; let foreground = theme.colors.foreground;
let muted_foreground = theme.colors.muted_foreground; let muted_foreground = theme.colors.muted_foreground;
let radius = theme.radius.control; let radius = theme.radius.control;
let icon_size = metrics.icon_size * 0.75; let icon_size = metrics.dense_icon_size;
let label = self let label = self
.a11y_label .a11y_label
@@ -214,7 +214,7 @@ where
.gap_2() .gap_2()
.w_full() .w_full()
.h(metrics.input_height) .h(metrics.input_height)
.px(metrics.padding_x / 2.0) .px(metrics.dense_padding_x)
.rounded(radius) .rounded(radius)
.border_1() .border_1()
.border_color(if is_open { ring } else { border }) .border_color(if is_open { ring } else { border })
+4 -4
View File
@@ -96,11 +96,11 @@ impl RenderOnce for Switch {
let ramps = theme.ramps; let ramps = theme.ramps;
let metrics = theme.sizes.resolve(self.size); let metrics = theme.sizes.resolve(self.size);
let track_height = metrics.icon_size * 1.25; let track_height = metrics.control_height;
let track_width = metrics.icon_size * 2.25; let track_width = metrics.control_height * 1.65;
let knob = metrics.icon_size; let knob = metrics.indicator_size;
let inset = metrics.icon_size * 0.125; let inset = metrics.plate_inset;
let travel = track_width - knob - inset * 2.; let travel = track_width - knob - inset * 2.;
let next = !self.checked; let next = !self.checked;
+2 -1
View File
@@ -266,6 +266,7 @@ fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful
let radius = theme.radius.full; let radius = theme.radius.full;
let shadows = theme.elevations.popover.shadows(); let shadows = theme.elevations.popover.shadows();
let icon_size = theme.sizes.small.icon_size; let icon_size = theme.sizes.small.icon_size;
let dense_icon_size = theme.sizes.small.dense_icon_size;
let text_size = theme.sizes.small.text_size; let text_size = theme.sizes.small.text_size;
let toast = &entry.toast; let toast = &entry.toast;
let toasts = toasts.clone(); let toasts = toasts.clone();
@@ -336,7 +337,7 @@ fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful
.hover(|style| style.bg(muted)) .hover(|style| style.bg(muted))
.child( .child(
icon(IconRole::Close) icon(IconRole::Close)
.size(icon_size * 0.75) .size(dense_icon_size)
.color(foreground.alpha(0.55)), .color(foreground.alpha(0.55)),
) )
.on_click(move |_, _window, cx| { .on_click(move |_, _window, cx| {
+1 -1
View File
@@ -100,7 +100,7 @@ impl RenderOnce for Toggle {
.id(self.id) .id(self.id)
.justify_center() .justify_center()
.h(metrics.control_height) .h(metrics.control_height)
.px(metrics.padding_x * 0.75) .px(metrics.dense_padding_x)
.rounded(theme.radius.control) .rounded(theme.radius.control)
.text_size(metrics.text_size) .text_size(metrics.text_size)
.text_color(colors.foreground) .text_color(colors.foreground)
+1 -1
View File
@@ -225,7 +225,7 @@ fn render_tab(
.when_some(tab.icon, |strip_tab, role| { .when_some(tab.icon, |strip_tab, role| {
strip_tab.child( strip_tab.child(
icon(role) icon(role)
.size(theme.sizes.medium.icon_size * 0.75) .size(theme.sizes.medium.dense_icon_size)
.color(label_color), .color(label_color),
) )
}), }),
+1 -1
View File
@@ -327,7 +327,7 @@ fn render_disclosure(
} else { } else {
IconRole::ChevronRight IconRole::ChevronRight
}) })
.size(theme.sizes.medium.icon_size * 0.75) .size(theme.sizes.medium.dense_icon_size)
.color(glyph_color), .color(glyph_color),
); );
+76 -12
View File
@@ -267,8 +267,20 @@ pub struct ControlMetrics {
pub text_size: Pixels, pub text_size: Pixels,
/// Icons and on/off indicators. /// Icons and on/off indicators.
pub icon_size: Pixels, pub icon_size: Pixels,
/// Icons inline with dense content — menu rows, badges, chevrons.
pub dense_icon_size: Pixels,
/// Selection indicators — checkboxes, radios, and switch knobs.
pub indicator_size: Pixels,
/// Avatars. /// Avatars.
pub avatar_size: Pixels, pub avatar_size: Pixels,
/// Dense label text.
pub caption_size: Pixels,
/// Tight horizontal padding inside pills, segments, and fields.
pub dense_padding_x: Pixels,
/// The inset between a control's edge and a raised inner plate.
pub plate_inset: Pixels,
/// The band height of multi-segment strips.
pub strip_height: Pixels,
} }
/// The control metrics for every size class. /// The control metrics for every size class.
@@ -280,20 +292,72 @@ pub struct SizeTable {
} }
impl SizeTable { impl SizeTable {
/// Every metric derives from the root font size; components fetch, and
/// never scale these themselves.
pub fn with_root_font_size(rem_size: Pixels) -> Self { pub fn with_root_font_size(rem_size: Pixels) -> Self {
let class = |height: f32, padding: f32, text: f32, icon: f32, avatar: f32| ControlMetrics { // One row per size class: control height, padding, label text, icon,
control_height: rem_size * height, // avatar, strip band, and plate inset — all as root-size multiples.
input_height: rem_size * (height + 0.125), struct ClassFactors {
padding_x: rem_size * padding, height: f32,
text_size: rem_size * text, padding: f32,
icon_size: rem_size * icon, text: f32,
avatar_size: rem_size * avatar, icon: f32,
}; avatar: f32,
Self { strip: f32,
small: class(1.5, 0.75, 0.85, 0.875, 1.5), inset: f32,
medium: class(1.75, 1.0, 1.0, 1.0, 2.0),
large: class(2.25, 1.25, 1.15, 1.25, 3.0),
} }
let small = ClassFactors {
height: 1.5,
padding: 0.75,
text: 0.85,
icon: 0.875,
avatar: 1.5,
strip: 1.5,
inset: 0.125,
};
let medium = ClassFactors {
height: 1.75,
padding: 1.0,
text: 1.0,
icon: 1.0,
avatar: 2.0,
strip: 2.0,
inset: 0.25,
};
let large = ClassFactors {
height: 2.25,
padding: 1.25,
text: 1.15,
icon: 1.25,
avatar: 3.0,
strip: 3.0,
inset: 0.375,
};
let metrics = |f: &ClassFactors| ControlMetrics {
control_height: rem_size * f.height,
input_height: rem_size * (f.height + 0.125),
padding_x: rem_size * f.padding,
text_size: rem_size * f.text,
icon_size: rem_size * f.icon,
dense_icon_size: rem_size * f.icon * 0.75,
indicator_size: rem_size * f.height * 0.7,
avatar_size: rem_size * f.avatar,
caption_size: rem_size * f.text,
dense_padding_x: rem_size * f.padding * 0.75,
plate_inset: rem_size * f.inset,
strip_height: rem_size * f.strip,
};
let mut table = Self {
small: metrics(&small),
medium: metrics(&medium),
large: metrics(&large),
};
// Dense text stops growing with the large class, and small's is
// one step below its label.
table.small.caption_size = table.small.text_size * 0.85;
table.large.caption_size = table.medium.text_size;
table
} }
/// The metrics for `size`. /// The metrics for `size`.
+6
View File
@@ -292,6 +292,12 @@ mod tests {
assert_eq!(resolved.sizes.medium.input_height, px(30.)); assert_eq!(resolved.sizes.medium.input_height, px(30.));
assert_eq!(resolved.sizes.medium.text_size, px(16.)); assert_eq!(resolved.sizes.medium.text_size, px(16.));
assert_eq!(resolved.sizes.large.avatar_size, px(48.)); assert_eq!(resolved.sizes.large.avatar_size, px(48.));
assert_eq!(resolved.sizes.small.caption_size, px(13.6 * 0.85));
assert_eq!(resolved.sizes.medium.caption_size, px(16.));
assert_eq!(resolved.sizes.large.caption_size, px(16.));
assert_eq!(resolved.sizes.medium.dense_padding_x, px(16. * 0.75));
assert_eq!(resolved.sizes.medium.plate_inset, px(4.));
assert_eq!(resolved.sizes.medium.strip_height, px(32.));
} }
} }
+1
View File
@@ -342,6 +342,7 @@ Pure tests for resolve/overlay chain, payload parsing (fixtures), icon resolutio
| D22 | The color set does not expand along gpuikit's `Themeable` shape (evaluated 2026-10-31): their per-component token layers (`button_*`, `input_*`) are passthroughs of their own primitives — a drift-prone parallel layer our role-plus-ramp model replaces with OKLCH-derived hover/pressed shades; depth variants (`surface_secondary`), the badge palette, and `fg_disabled` wait for real consumers (Badge, nested elevation, a theme that can't express disabled by opacity). The one adoption: `info` as the fourth severity — the Alert's Info severity had a live consumer painting from the primary ramp, which collides with Danger under warm accents. Domain palettes (editor diff colors) may revisit gpuikit's `ThemeExtension` idea when `crates/editor` lands | | D22 | The color set does not expand along gpuikit's `Themeable` shape (evaluated 2026-10-31): their per-component token layers (`button_*`, `input_*`) are passthroughs of their own primitives — a drift-prone parallel layer our role-plus-ramp model replaces with OKLCH-derived hover/pressed shades; depth variants (`surface_secondary`), the badge palette, and `fg_disabled` wait for real consumers (Badge, nested elevation, a theme that can't express disabled by opacity). The one adoption: `info` as the fourth severity — the Alert's Info severity had a live consumer painting from the primary ramp, which collides with Danger under warm accents. Domain palettes (editor diff colors) may revisit gpuikit's `ThemeExtension` idea when `crates/editor` lands |
| D23 | The old Toast split into `Alert` + `Toast` (2026-10-31): one component can't be both a persistent page callout and a fleeting bottom-center nudge — different lifetime (persists until acted on vs auto-dismisses), different placement (inline in flow vs floating bottom center), different content shape (title + message + severity tint vs one line + icon + action). Alert is fully controlled — the app renders it while the condition holds and its close button reports intent; Toast keeps the store + timers. Toast drops severity and titles: Adwaita toasts are single-line nudges, and severity belongs to the banners that stay on the page | | D23 | The old Toast split into `Alert` + `Toast` (2026-10-31): one component can't be both a persistent page callout and a fleeting bottom-center nudge — different lifetime (persists until acted on vs auto-dismisses), different placement (inline in flow vs floating bottom center), different content shape (title + message + severity tint vs one line + icon + action). Alert is fully controlled — the app renders it while the condition holds and its close button reports intent; Toast keeps the store + timers. Toast drops severity and titles: Adwaita toasts are single-line nudges, and severity belongs to the banners that stay on the page |
| D24 | Shadows follow the platform's design language (2026-11-01): in-app shadows are painted by gpui, so "native" means each platform's shadow *values* — `Elevation` is a two-layer key + ambient stack and `resolve` picks it per `SourceKind` (macOS soft and layered at 12% alpha, Windows tight with more offset, Linux compact and darker; one shared dark stack where borders do the separation work). Static design constants, never OS-read (no platform API states shadow values); replaces the Alert's light-mode alpha hack, which was a single-profile workaround | | D24 | Shadows follow the platform's design language (2026-11-01): in-app shadows are painted by gpui, so "native" means each platform's shadow *values* — `Elevation` is a two-layer key + ambient stack and `resolve` picks it per `SourceKind` (macOS soft and layered at 12% alpha, Windows tight with more offset, Linux compact and darker; one shared dark stack where borders do the separation work). Static design constants, never OS-read (no platform API states shadow values); replaces the Alert's light-mode alpha hack, which was a single-profile workaround |
| D25 | Components fetch, never scale (2026-11-01): every derived metric a component kept re-deriving from root metrics — dense label text (`×0.8/0.85`), dense inline icons (`×0.75`), dense horizontal padding (`×0.5/0.75/2`), plate insets (`0.125/0.25/0.375 rem`), strip band heights (`×1/1.15/1.35`) — moved into the per-size-class `ControlMetrics` table (`caption_size`, `dense_icon_size`, `dense_padding_x`, `plate_inset`, `strip_height`, plus `indicator_size` for checkboxes, radios, and switch knobs), computed once in `with_root_font_size`. The table stays role-based — one value per concept per size class, shared by every component — not gpuikit's per-component token layers (D22). Component-unique geometry that has no second consumer keeps its ratios over root metrics (switch track length, slider knob, avatar initials, tree indent). Two icon roles are fetch-only: content icons (alert info, segmented options) take `icon_size` — never smaller than the label — and indicator glyphs (checks, chevrons, close buttons) take `dense_icon_size` |
Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in a lighter form as D20. D10 (no icon bundling) is superseded by D19. Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in a lighter form as D20. D10 (no icon bundling) is superseded by D19.