diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 2a9fc0a..87fa5bb 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -36,6 +36,9 @@ pub mod avatar; #[cfg(feature = "input")] pub mod input; +#[cfg(feature = "popover")] +pub mod popover; + /// Initializes everything the components need at startup. pub fn init(cx: &mut App) { gpui_native_theme::Theme::init(cx); diff --git a/crates/theme/src/colors.rs b/crates/theme/src/colors.rs index b5f3b80..cb4e592 100644 --- a/crates/theme/src/colors.rs +++ b/crates/theme/src/colors.rs @@ -1,12 +1,17 @@ //! Semantic color roles. This module is the only place in the workspace //! allowed to hold color literals. -use gpui::{Hsla, rgb}; +use gpui::{Hsla, hsla, rgb}; fn hex(hex: u32) -> Hsla { rgb(hex).into() } +/// The dim scrim behind modal layers; always a translucent black. +pub(crate) fn scrim(alpha: f32) -> Hsla { + hsla(0., 0., 0., alpha) +} + /// The semantic color roles components paint from. #[derive(Clone, Copy, Debug, PartialEq)] pub struct ColorTokens { @@ -28,6 +33,8 @@ pub struct ColorTokens { pub input: Hsla, pub ring: Hsla, pub selection: Hsla, + /// The scrim behind modal layers; translucent in every theme. + pub overlay: Hsla, } impl ColorTokens { @@ -52,6 +59,7 @@ impl ColorTokens { input: hex(0xe4e4e7), ring: hex(0x93c5fd), selection: hex(0xdbeafe), + overlay: scrim(0.25), } } @@ -76,6 +84,7 @@ impl ColorTokens { input: hex(0x27272a), ring: hex(0x1d4ed8), selection: hex(0x1e3a8a), + overlay: scrim(0.45), } } } diff --git a/crates/theme/src/definition.rs b/crates/theme/src/definition.rs index 583d515..9471d45 100644 --- a/crates/theme/src/definition.rs +++ b/crates/theme/src/definition.rs @@ -29,6 +29,7 @@ pub struct ThemeDefinition { pub input: Option, pub ring: Option, pub selection: Option, + pub overlay: Option, } impl ThemeDefinition { @@ -107,6 +108,10 @@ impl ThemeDefinition { pub fn selection(&self) -> Option { parse_hex(self.selection.as_deref()?) } + + pub fn overlay(&self) -> Option { + parse_hex(self.overlay.as_deref()?) + } } /// Parses `#rgb` or `#rrggbb` into an opaque color. diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index ca073c4..e062eef 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -20,7 +20,8 @@ pub use ramps::{AccentShades, Oklch}; pub use resolve::{Ramps, ResolvedTheme}; pub use system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemTheme}; -use gpui::{App, Global, Pixels, WindowAppearance, px}; +use gpui::{App, Global, Hsla, Pixels, WindowAppearance, px}; +use std::time::Duration; /// Light or dark, resolved from the system appearance. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)] @@ -47,6 +48,9 @@ pub struct Theme { pub colors: ColorTokens, pub ramps: Ramps, pub radius: Radius, + pub elevations: Elevations, + /// Overlay transition timing; gpui's animation primitives do the rest. + pub motion: Motion, /// The platform's native root font size; rem-based sizing scales with it. pub root_font_size: Pixels, pub sizes: SizeTable, @@ -61,6 +65,8 @@ pub struct Radius { /// Small controls: checkbox and radio indicators. pub sm: Pixels, pub control: Pixels, + /// Floating layers: popovers, menus, dialogs. + pub overlay: Pixels, /// Pill and circle shapes. pub full: Pixels, } @@ -70,11 +76,79 @@ impl Default for Radius { Self { sm: px(4.), control: px(6.), + overlay: px(10.), full: px(9999.), } } } +/// One drop shadow of a floating layer, transcribed from the platform's +/// native overlay shadows. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Elevation { + pub offset_y: Pixels, + pub blur_radius: Pixels, + pub spread_radius: Pixels, + /// Translucent black, like every native overlay shadow. + pub color: Hsla, +} + +impl Elevation { + /// 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, + }] + } +} + +/// 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. + 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), + }, + dialog: Elevation { + offset_y: px(8.), + blur_radius: px(40.), + spread_radius: px(0.), + color: colors::scrim(0.35), + }, + } + } +} + +/// Overlay transition timings; the platform refines these per phase. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Motion { + /// The enter transition of popovers, menus, and dialogs. + pub overlay: Duration, +} + +impl Default for Motion { + fn default() -> Self { + Self { + overlay: Duration::from_millis(200), + } + } +} + /// A named control size class. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Size { @@ -170,6 +244,8 @@ impl Theme { colors: resolved.colors, ramps: resolved.ramps, radius: resolved.radius, + elevations: resolved.elevations, + motion: resolved.motion, root_font_size: resolved.root_font_size, sizes: resolved.sizes, accessibility: resolved.accessibility, diff --git a/crates/theme/src/resolve.rs b/crates/theme/src/resolve.rs index 049b7a7..3a07090 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, SizeTable, ThemeVariant}; +use crate::{ColorTokens, Elevations, Motion, Radius, SizeTable, ThemeVariant}; use gpui::px; /// Derived state shades for the colors that get pressed and hovered. @@ -19,6 +19,8 @@ pub struct ResolvedTheme { pub colors: ColorTokens, pub ramps: Ramps, pub radius: Radius, + pub elevations: Elevations, + pub motion: Motion, /// The platform's native root font size; the fallback theme uses 16. pub root_font_size: gpui::Pixels, pub sizes: SizeTable, @@ -123,6 +125,11 @@ pub(crate) fn resolve( system_palette.selection, base.selection, ), + overlay: pick( + def_color(|d| d.overlay()), + system_palette.overlay, + base.overlay, + ), }; let accessibility = system.accessibility; @@ -172,6 +179,8 @@ pub(crate) fn resolve( }, colors, radius, + elevations: Elevations::default(), + motion: Motion::default(), root_font_size, sizes, accessibility, @@ -274,6 +283,49 @@ mod tests { assert!((resolved.sizes.small.input_height - expected_small).abs() < px(0.01)); } + #[test] + fn overlay_is_translucent_and_overridable() { + let light = resolve( + &system(ThemeVariant::Light, PartialPalette::default(), None), + None, + None, + ); + let dark = resolve( + &system(ThemeVariant::Dark, PartialPalette::default(), None), + None, + None, + ); + + // Both scrims are translucent black; dark is denser for contrast. + assert!(light.colors.overlay.a > 0.0 && light.colors.overlay.a < 0.5); + assert!(dark.colors.overlay.a > light.colors.overlay.a); + + let def = ThemeDefinition::parse_toml("overlay = \"#000000\"").unwrap(); + let overridden = resolve( + &system(ThemeVariant::Light, PartialPalette::default(), None), + Some(&def), + None, + ); + assert_eq!(overridden.colors.overlay, hsla(0x000000)); + assert_eq!(overridden.colors.overlay.a, 1.0); + } + + #[test] + fn elevations_and_motion_resolve_with_defaults() { + let resolved = resolve( + &system(ThemeVariant::Dark, PartialPalette::default(), None), + None, + None, + ); + + // The popover shadow is softer and tighter than the dialog's. + assert!(resolved.elevations.popover.blur_radius < resolved.elevations.dialog.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 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 0369f47..2c670b5 100644 --- a/crates/theme/src/source/macos.rs +++ b/crates/theme/src/source/macos.rs @@ -85,6 +85,7 @@ impl MacosSource { input: Self::color_to_hsla(&NSColor::controlBackgroundColor()), ring: Self::color_to_hsla(&NSColor::keyboardFocusIndicatorColor()), selection: Self::color_to_hsla(&NSColor::selectedTextBackgroundColor()), + overlay: None, } } } diff --git a/crates/theme/src/system.rs b/crates/theme/src/system.rs index 84aef8d..df9aaa1 100644 --- a/crates/theme/src/system.rs +++ b/crates/theme/src/system.rs @@ -47,6 +47,8 @@ pub struct PartialPalette { pub input: Option, pub ring: Option, pub selection: Option, + /// No platform states a scrim color; preset and user files override it. + pub overlay: Option, } /// A normalized snapshot of the running system's theme. diff --git a/docs/PLAN.md b/docs/PLAN.md index 3180449..b7b80a6 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -150,7 +150,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** — with the overlay units (Popover/Dialog/Menu, unit 11+); §5.5's popup elevation promise. No current role covers the modality backdrop. +- ~~**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. - **`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. @@ -208,7 +208,8 @@ Each row is one review unit: build it, show it in the gallery, review, then move | 8 | core | Progress | Determinate + indeterminate. | | 9 | core | Avatar | Initials; image slot later. | | 10 | core | Input (single-line) then multiline, NumberInput | String buffer + `unicode-segmentation` word movement; the field owns its own selection. May seed `crates/editor`. Single-line finishing pass: §6.2. | -| 11+ | core | Popover, Tooltip, Menu, Dialog, AlertDialog, Sheet, Toast | One unit each, same gate. | +| 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, Menu, Dialog, AlertDialog, Sheet, Toast | One unit each, same gate. Menu reuses the popover shell + `Radius.overlay`/`Elevations.popover`; Dialog consumes `colors.overlay` (scrim) + `Elevations.dialog` | | then | data (new) | List, Table, Tree, Tabs, Sidebar | Created on demand, same gate. | | then | pickers or data | Calendar/DatePicker, TimeField, ColorPicker | Decide at unit time. | | editor | editor (new) | Text/code editor, syntax highlighting (D12) | Same gate. | diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index ee2f864..c8eaa88 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -10,6 +10,7 @@ use gpui_native_core::checkbox::Checkbox; 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::popover::Popover; use gpui_native_core::progress::Progress; use gpui_native_core::radio::{Radio, RadioGroup}; use gpui_native_core::slider::Slider; @@ -27,6 +28,7 @@ struct Gallery { toggle_pressed: bool, volume: f32, input_state: Entity, + popover_open: bool, } impl Render for Gallery { @@ -275,6 +277,47 @@ impl Render for Gallery { ), ), ) + .child( + v_flex() + .gap_2() + .child(section_label( + "popover · native ref — NSPopover, WinUI Flyout, Adwaita Popover", + colors, + )) + .child({ + let gallery = gallery.clone(); + Popover::new("gallery-popover") + .open(self.popover_open) + .on_change(move |open, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.popover_open = open; + cx.notify(); + }); + }) + .trigger(|_, _| { + Button::new("gallery-popover-trigger") + .label("Options") + .variant(ButtonVariant::Secondary) + .into_any_element() + }) + .content(move |_, _| { + v_flex() + .gap_1() + .w(px(240.)) + .p_2() + .child(section_label("appearance", colors)) + .child( + Checkbox::new("gallery-popover-telemetry") + .label("Enable telemetry"), + ) + .child( + Checkbox::new("gallery-popover-crash") + .label("Send crash reports"), + ) + .into_any_element() + }) + }), + ) .child( v_flex() .gap_2() @@ -627,6 +670,7 @@ fn main() { toggle_pressed: false, volume: 40.0, input_state, + popover_open: false, }) }, ) {