From d107f350849e294eb741312ea7fc08ab80d17827 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 1 Nov 2026 09:54:35 +0700 Subject: [PATCH] theme: derive elevations from the platform's native shadows --- crates/core/src/alert.rs | 10 +--- crates/theme/src/lib.rs | 114 ++++++++++++++++++++++++++++-------- crates/theme/src/resolve.rs | 33 ++++++++++- docs/PLAN.md | 7 ++- 4 files changed, 127 insertions(+), 37 deletions(-) diff --git a/crates/core/src/alert.rs b/crates/core/src/alert.rs index 1c3a0f4..a8084e5 100644 --- a/crates/core/src/alert.rs +++ b/crates/core/src/alert.rs @@ -7,7 +7,7 @@ use gpui::{ Styled as _, Window, deferred, div, ease_out_quint, px, }; use gpui_native_icons::{IconRole, icon}; -use gpui_native_theme::{ActiveTheme as _, ThemeVariant}; +use gpui_native_theme::ActiveTheme as _; use crate::common::DismissHandler; use crate::common::v_flex; @@ -127,13 +127,7 @@ impl RenderOnce for Alert { let muted = theme.colors.muted; let surface = theme.colors.surface; let border = theme.colors.border; - - let mut elevation = theme.elevations.popover; - if theme.variant == ThemeVariant::Light { - elevation.color = elevation.color.alpha(0.12); - } - let shadows = elevation.shadows(); - + let shadows = theme.elevations.popover.shadows(); let text_size = theme.sizes.small.text_size; let icon_size = theme.sizes.small.icon_size; diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 4d0c5cd..2d138e3 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -84,10 +84,10 @@ impl Default for Radius { } } -/// One drop shadow of a floating layer, -/// transcribed from the platform's native overlay shadows. +/// One layer of a floating panel's drop shadow. #[derive(Clone, Copy, Debug, PartialEq)] -pub struct Elevation { +pub struct Shadow { + pub offset_x: Pixels, pub offset_y: Pixels, pub blur_radius: Pixels, pub spread_radius: Pixels, @@ -95,43 +95,109 @@ pub struct Elevation { pub color: Hsla, } +impl Shadow { + fn none() -> Self { + Self { + offset_x: px(0.), + offset_y: px(0.), + blur_radius: px(0.), + spread_radius: px(0.), + color: colors::scrim(0.), + } + } + + fn black(offset_x: f32, offset_y: f32, blur_radius: f32, alpha: f32) -> Self { + Self { + offset_x: px(offset_x), + offset_y: px(offset_y), + blur_radius: px(blur_radius), + spread_radius: px(0.), + color: colors::scrim(alpha), + } + } +} + +/// The drop shadow stack of a floating layer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Elevation { + /// Key shadow first, ambient grounding second. + pub layers: [Shadow; 2], +} + impl Elevation { + fn single(key: Shadow) -> Self { + Self { + layers: [key, Shadow::none()], + } + } + + fn layered(key: Shadow, ambient: Shadow) -> Self { + Self { + layers: [key, ambient], + } + } + /// The gpui shadow list an overlay panel paints with. pub fn shadows(self) -> Vec { - vec![gpui::BoxShadow { - color: self.color, - offset: gpui::point(px(0.), self.offset_y), - blur_radius: self.blur_radius, - spread_radius: self.spread_radius, - inset: false, - }] + self.layers + .iter() + .filter(|layer| layer.color.a > 0.) + .map(|layer| gpui::BoxShadow { + color: layer.color, + offset: gpui::point(layer.offset_x, layer.offset_y), + blur_radius: layer.blur_radius, + spread_radius: layer.spread_radius, + inset: false, + }) + .collect() } } /// The drop shadows of each floating layer class. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Elevations { - /// Popovers and menus ride above content on a tight shadow. + /// Popovers, menus, and pushed alerts ride above content. pub popover: Elevation, /// Modal dialogs sit higher with a wider one. pub dialog: Elevation, } -impl Default for Elevations { - fn default() -> Self { - Self { - popover: Elevation { - offset_y: px(4.), - blur_radius: px(24.), - spread_radius: px(0.), - color: colors::scrim(0.3), +impl Elevations { + pub(crate) fn for_platform(source: SourceKind, variant: ThemeVariant) -> Self { + if variant == ThemeVariant::Dark { + return Self { + popover: Elevation::single(Shadow::black(0., 4., 24., 0.3)), + dialog: Elevation::single(Shadow::black(0., 8., 40., 0.35)), + }; + } + + match source { + SourceKind::Macos => Self { + popover: Elevation::layered( + Shadow::black(0., 4., 24., 0.12), + Shadow::black(0., 1., 1., 0.05), + ), + dialog: Elevation::layered( + Shadow::black(0., 8., 40., 0.14), + Shadow::black(0., 1., 2., 0.06), + ), }, - dialog: Elevation { - offset_y: px(8.), - blur_radius: px(40.), - spread_radius: px(0.), - color: colors::scrim(0.35), + SourceKind::Windows => Self { + popover: Elevation::layered( + Shadow::black(0., 8., 16., 0.14), + Shadow::black(0., 2., 4., 0.1), + ), + dialog: Elevation::layered( + Shadow::black(0., 16., 32., 0.16), + Shadow::black(0., 4., 8., 0.1), + ), }, + SourceKind::Omarchy | SourceKind::Kde | SourceKind::Gnome | SourceKind::Fallback => { + Self { + popover: Elevation::single(Shadow::black(0., 3., 6., 0.2)), + dialog: Elevation::single(Shadow::black(0., 9., 18., 0.24)), + } + } } } } diff --git a/crates/theme/src/resolve.rs b/crates/theme/src/resolve.rs index 1a24df6..b4aae21 100644 --- a/crates/theme/src/resolve.rs +++ b/crates/theme/src/resolve.rs @@ -220,7 +220,7 @@ pub(crate) fn resolve( }, colors, radius, - elevations: Elevations::default(), + elevations: Elevations::for_platform(system.source, variant), motion: Motion::default(), scrollbar: Scrollbar::default(), root_font_size, @@ -361,13 +361,42 @@ mod tests { ); // The popover shadow is softer and tighter than the dialog's. - assert!(resolved.elevations.popover.blur_radius < resolved.elevations.dialog.blur_radius); + assert!( + resolved.elevations.popover.layers[0].blur_radius + < resolved.elevations.dialog.layers[0].blur_radius + ); assert!(resolved.motion.overlay > std::time::Duration::ZERO); let shadows = resolved.elevations.popover.shadows(); assert_eq!(shadows.len(), 1); assert!(!shadows[0].inset); } + #[test] + fn elevations_follow_the_platform_in_light_mode_and_share_one_dark_stack() { + let popover = |source| { + let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None); + stated.source = source; + resolve(&stated, None, None).elevations.popover + }; + + // macOS paints a soft layered stack; Linux keeps a compact single one. + assert_eq!(popover(SourceKind::Macos).shadows().len(), 2); + assert_eq!(popover(SourceKind::Gnome).shadows().len(), 1); + assert!( + popover(SourceKind::Macos).layers[0].color.a + < popover(SourceKind::Gnome).layers[0].color.a + ); + + // Dark mode is one shared stack, however the adapter detected. + let dark = |source| { + let mut stated = system(ThemeVariant::Dark, PartialPalette::default(), None); + stated.source = source; + resolve(&stated, None, None).elevations + }; + assert_eq!(dark(SourceKind::Macos), dark(SourceKind::Windows)); + assert_eq!(dark(SourceKind::Macos), dark(SourceKind::Fallback)); + } + #[test] fn system_colors_override_the_base_but_leave_the_rest_intact() { let palette = PartialPalette { diff --git a/docs/PLAN.md b/docs/PLAN.md index a302441..848a9e9 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -154,7 +154,7 @@ Omarchy `colors.toml` maps almost 1:1: `lighter_background` → `surface`, `dark 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**~~ — ✅ landed with unit 11 (2026-10-29): `colors.overlay` (translucent-black scrim, denser in dark; TOML-overridable; no platform states one), `Radius.overlay` (10 pt), `Elevations.popover`/`dialog` (transcribed NSPopover-class shadows, builder → `Vec`), and `Motion.overlay` (200 ms enter transition). Popover consumes elevation + motion; the scrim waits for Dialog. +- ~~**Overlay scrim + shadow/elevation tokens**~~ — ✅ landed with unit 11 (2026-10-29): `colors.overlay` (translucent-black scrim, denser in dark; TOML-overridable; no platform states one), `Radius.overlay` (10 pt), `Elevations.popover`/`dialog` (builder → `Vec`), and `Motion.overlay` (200 ms enter transition). Popover consumes elevation + motion; the scrim waits for Dialog. **Per-platform stacks (2026-11-01, D24)**: `Elevation` became a two-layer key + ambient stack and `resolve` picks it per `SourceKind` — macOS soft and layered (key shadow at 12% alpha), Windows tight and offset, Linux compact and darker; dark mode shares one stack — deleting the Alert's light-mode alpha hack - ~~**Scrollbar thumb token + metrics**~~ — ✅ landed with the ScrollArea unit (2026-10-29): `colors.scrollbar_thumb` (translucent over content: black in light, light gray in dark; TOML-overridable) and `Theme.scrollbar` (`width` 8 pt, `inset` 2 pt, `min_thumb` 24 pt). - ~~**`success` / `warning` roles**~~ — ✅ landed with Toast (2026-10-29): `colors.success`/`warning` (+ foregrounds; light and dark variants; TOML-overridable; no platform states them) and derived `Ramps.success`/`warning` via the OKLCH engine, matching the destructive treatment. **`info` joined them (2026-10-31)** — the fourth severity, same treatment (`colors.info` + foreground + `Ramps.info`, neutral sky blue, no platform states it, TOML `info =`). Trigger: the Alert's Info severity was painting from the primary ramp, so a warm platform accent (GNOME red, Omarchy red themes) made Info read as Danger; the token keeps Info calm however the accent turns out. Severity tokens now use the same picked value for the ramp and the token (the success/warning tokens used to re-pick with the system slot dropped, diverging from their own ramps if a platform ever stated one). - **Motion tokens** — after the reverted framework attempt (2026-10-30), motion stays minimal: `Motion.overlay` (200 ms) is the only token. Components reach for gpui's own easings and `SpringConfig` inline (D21); a token is added only when a third component shares the same value (D16). TOML `[motion]` overrides and per-platform motion presets wait for a real need. ~~The reduce-motion forwarding fix~~ ✅ landed (pass a, 2026-10-30): `reapply` calls `cx.set_reduce_motion(resolved.accessibility.reduce_motion)`, so gpui's animation elements snap under the OS setting. @@ -181,7 +181,7 @@ Every target supplies one accent color (or a stated ramp). Where the platform st ### 5.5 Native metrics -Native feel is half geometry. Per-platform tables for: control heights and paddings, corner radii (WinUI control vs card rounding; macOS control radii), font fallback lists resolved through `.SystemUIFont` (SF Pro / Segoe UI Variable / Adwaita Sans / Noto Sans — no fonts bundled), focus-ring policy (macOS keyboard ring, WinUI two-layer ring, GTK outline), scrollbar style (overlay vs inline), popup elevation and menu metrics. Motion stays per-component timing on gpui's own primitives; when a platform source needs to state it (macOS NSPopover-class timing, Fluent's decelerate curve, GNOME/KDE shell timings), it states `Motion.overlay` first and further tokens arrive per the role-minimal rule. Values are transcribed from each toolkit's reference metrics and verified with side-by-side screenshots (macOS in Phase B, the rest in Phase F). +Native feel is half geometry. Per-platform tables for: control heights and paddings, corner radii (WinUI control vs card rounding; macOS control radii), font fallback lists resolved through `.SystemUIFont` (SF Pro / Segoe UI Variable / Adwaita Sans / Noto Sans — no fonts bundled), focus-ring policy (macOS keyboard ring, WinUI two-layer ring, GTK outline), scrollbar style (overlay vs inline), menu metrics (popup elevation landed 2026-11-01 as static per-platform profiles, D24). Motion stays per-component timing on gpui's own primitives; when a platform source needs to state it (macOS NSPopover-class timing, Fluent's decelerate curve, GNOME/KDE shell timings), it states `Motion.overlay` first and further tokens arrive per the role-minimal rule. Values are transcribed from each toolkit's reference metrics and verified with side-by-side screenshots (macOS in Phase B, the rest in Phase F). ### 5.6 Custom themes @@ -219,7 +219,7 @@ Each row is one review unit: build it, show it in the gallery, review, then move | 11.5 | core | ScrollArea + Scrollbar | ✅ Landed (2026-10-29). Inserted before the remaining overlays — Menus, Lists and Tables all scroll. gpui owns the mechanics (`overflow_*_scroll` + caller-held `ScrollHandle`); the component paints an overlay thumb (`scrollbar_thumb` token, `Scrollbar` metrics) with drag and geometry unit-tested. Wheel input is gpui's; the area stops wheel propagation so nested areas don't co-scroll. Horizontal thumb deferred; auto-hide on idle deferred. ✅ Fixed (2026-10-30): the caller's size refinement now lands on the outer wrapper too — previously it applied to the pane alone, so a percentage-sized pane resolved against an auto-height wrapper and a window-filling scroll area (the gallery's root shell) never actually scrolled; a headless repro of the gallery shell locks the behavior | | 12 | core | Menu | ✅ Landed (2026-10-29). Controlled `open`/`on_change` + `trigger`/`items` (MenuItem: label, icon, shortcut hint, disabled, destructive, checked, separator). Popover shell patterns duplicated per D16 (promote at Dialog); panel = surface + overlay radius + popover elevation, role Menu; claims focus while open; keyboard nav — arrows/home/end over selectable items (wrapping, skipping separators + disabled), enter/space activates (closes menu before the action), escape dismisses; hover selects, click activates; trigger clicks toggle (popover-owned); selection resets on each open; long menus scroll via the ScrollArea once a height is styled. Deferred: submenus, type-ahead, scroll-selected-item-into-view, real shortcut handling | | 11 | core | Popover | ✅ Landed (2026-10-29). First overlay. Controlled `open` + `on_change` (Rc-shared handler); `trigger`/`content` element closures; panel = surface + `Radius.overlay` + `Elevations.popover` + border, role Dialog with label; `anchored()` + `deferred()` (window-overflow flip for free); enter fade via gpui `with_animation` at `Motion.overlay` (respects reduce-motion); claims keyboard focus while open; escape and click-outside dismiss (outside clicks swallowed). No scrim — native macOS popover model; the overlay token waits for Dialog. Known v1 gaps: no exit animation, trigger aria-expanded left to the app | -| 11+ | core | Tooltip ✅ (2026-10-29), Dialog + AlertDialog ✅ (2026-10-29), Toast ✅ (2026-10-29), Alert + Toast rework ✅ (2026-10-31) | One unit each, same gate. Tooltip rides gpui's own `.tooltip()` lifecycle (hover delay, anchoring, dismissal — all framework-owned); ours is the styled view (`surface` + border + `Radius.sm`, small text) plus a `Tooltip::text` closure and a Button passthrough. Dialog + AlertDialog (one component, `alert(true)` switches semantics): scrim (`colors.overlay`, occluding) + centered panel (surface, `Radius.overlay`, `Elevations.dialog`), role Dialog/AlertDialog, focus claimed while open; modal kind closes on escape + scrim click, alert kind is inert to both (the user answers through the actions); actions footer is caller-built elements closing through owner state. **The 2026-10-31 rework split the old Toast in two (D23):** `Alert` (`alert` feature) — a persistent, fully controlled callout banner rendered inline where the app places it: severity icon (`Ramps..base` on a `surface` + `border` + `Radius.overlay` card with the popover shadow, halved in light mode so it doesn't pop on white), title and message both optional (a message-only banner paints it as the primary line), close button only when `on_dismiss` is set, reporting intent instead of hiding anything. A pushed, macOS-style stacking `Alerts`/`AlertsView` (collapse-slivers + hover-unroll + slide animations) was tried and rolled back the same day — inline-only won. The reworked `Toast` (`toast` feature) — **one at a time** (pushing replaces the one on screen), a succinct severity-less pill at the bottom center: optional icon + one-line truncating content + one optional action (`ToastAction`, the suggested-action pill) + an always-present close button, laid out as content, small gap, action, large gap, close; fades in and out (`Motion.overlay` each — exits via a store-held exiting slot whose handler runs once the fade completes). The store keeps GPUI-executor timers (`Toast::DURATION` 5 s, zero = sticky). Toast landed the deferred `success`/`warning` tokens (§5.2); Alert is the `info` token's consumer (D22). Sheet dropped from the roadmap | +| 11+ | core | Tooltip ✅ (2026-10-29), Dialog + AlertDialog ✅ (2026-10-29), Toast ✅ (2026-10-29), Alert + Toast rework ✅ (2026-10-31) | One unit each, same gate. Tooltip rides gpui's own `.tooltip()` lifecycle (hover delay, anchoring, dismissal — all framework-owned); ours is the styled view (`surface` + border + `Radius.sm`, small text) plus a `Tooltip::text` closure and a Button passthrough. Dialog + AlertDialog (one component, `alert(true)` switches semantics): scrim (`colors.overlay`, occluding) + centered panel (surface, `Radius.overlay`, `Elevations.dialog`), role Dialog/AlertDialog, focus claimed while open; modal kind closes on escape + scrim click, alert kind is inert to both (the user answers through the actions); actions footer is caller-built elements closing through owner state. **The 2026-10-31 rework split the old Toast in two (D23):** `Alert` (`alert` feature) — a persistent, fully controlled callout banner rendered inline where the app places it: severity icon (`Ramps..base` on a `surface` + `border` + `Radius.overlay` card with the platform popover shadow (D24)), title and message both optional (a message-only banner paints it as the primary line), close button only when `on_dismiss` is set, reporting intent instead of hiding anything. A pushed, macOS-style stacking `Alerts`/`AlertsView` (collapse-slivers + hover-unroll + slide animations) was tried and rolled back the same day — inline-only won. The reworked `Toast` (`toast` feature) — **one at a time** (pushing replaces the one on screen), a succinct severity-less pill at the bottom center: optional icon + one-line truncating content + one optional action (`ToastAction`, the suggested-action pill) + an always-present close button, laid out as content, small gap, action, large gap, close; fades in and out (`Motion.overlay` each — exits via a store-held exiting slot whose handler runs once the fade completes). The store keeps GPUI-executor timers (`Toast::DURATION` 5 s, zero = sticky). Toast landed the deferred `success`/`warning` tokens (§5.2); Alert is the `info` token's consumer (D22). Sheet dropped from the roadmap | | 14 | core + theme | Motion (per component) | ✅ Landed in five passes (2026-10-30), replacing the reverted same-day framework attempt — no token tower, no `motion` module, no ghost panels; just gpui's own primitives used inline, one separately reviewed pass per component: (a) theme — `reapply` forwards `reduce_motion` via `cx.set_reduce_motion`, so gpui snaps every animation under the OS setting; (b) Switch — the thumb absolute-positions in the track and slides on `with_spring` (inline `SpringConfig::new(500, 35, 1)`, `Pixels` target, mounts at its target so the initial render never animates; velocity survives mid-flight retargets); (c) Toast — each toast slides up 8 px into its stack slot on `with_animation` at `Motion.overlay` with `ease_out_quint` (margin-driven, so only the entering toast moves; keyed per entry so the clock survives unrelated stack re-renders); (d) Dialog — the scrim enters with the panel (same timing and easing, its own `dialog-scrim-enter` animation id so the clocks start in the same frame); (e) Progress — the indeterminate sweep caps at 30 fps via `with_max_fps`. Overlay enter fades already existed from units 11/12. Exits and hover washes stay deferred — they were the complexity sink of the reverted attempt (§ deferred list) | | then | data (new) | List ✅ (2026-10-30), Tree ✅ (2026-10-30), Tabs ✅ (2026-10-30), Sidebar ✅ (2026-10-30) | Created on demand, same gate. Table dropped from the roadmap. List: controlled single-selection rows (`selected: Option` + `on_change`); click selects and focuses the list, arrows/home/end move selection skipping disabled rows and never wrapping (native list behavior), hover is paint-only, click focus rides a keyed `FocusHandle` namespaced by the list id; roles List/ListItem with `aria_selected` and position/size-of-set; internal scrolling left to the caller's ScrollArea; `sheet` feature dropped from core as dead config. Tree: the caller flattens its tree into depth-annotated `TreeItem`s (bezel's model — the component never walks a tree); the caller owns expansion and receives toggles via `on_disclosure(index)` (chevron click, or left/right on the selected row); same controlled selection and row paint as List; roles Tree/TreeItem with `aria_selected`/`aria_level`/`aria_expanded`/set-size; indent guides deferred. Tabs: a controlled strip only — `selected: usize` + `on_change`; the panel is the app's to render; active tab marked by an underline on the strip's hairline (bezel's model); arrows/left-right/home/end switch tabs with automatic activation, skipping disabled and never wrapping; re-selecting the active tab reports nothing; roles TabList/Tab with `aria_selected`; no wrap, no segmented-pill variant yet. Sidebar: List with section headers — `SidebarEntry::header(label)` / `row(label).icon().detail().disabled()`; headers are never selectable, selection indices address the flat entry list; same selection/keyboard/paint rules as List; roles List/ListItem (a sidebar is a list to accesskit, like macOS AXList); selection stays filled when unfocused (native) | | then | pickers or data | Calendar/DatePicker, TimeField, ColorPicker | Decide at unit time. | @@ -316,6 +316,7 @@ Pure tests for resolve/overlay chain, payload parsing (fixtures), icon resolutio | D21 | Motion is per component and inline: each component uses gpui's own animation/spring primitives (`with_animation`, `with_spring`, the gpui easings, `with_max_fps`) directly in its file; the theme owns only the durations components actually share (today `Motion.overlay`), growing per the role-minimal rule. No shared motion module, no exit ghosts, no hover-wash machinery — exits and washes stay out until a genuinely simple approach exists. Reduce motion is gpui-side: forward the OS flag once in `reapply` and every primitive snaps. Supersedes the reverted 2026-10-30 framework attempt (which proved the substrate but over-built the plumbing) | | 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 | +| 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 | 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.