diff --git a/apps/desktop-gpui/assets/icons/chevrons-up-down.svg b/apps/desktop-gpui/assets/icons/chevrons-up-down.svg new file mode 100644 index 0000000000..af575371a4 --- /dev/null +++ b/apps/desktop-gpui/assets/icons/chevrons-up-down.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 746fccfef4..28fe176178 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -59,6 +59,7 @@ const ICONS: &[(&str, &[u8])] = assets!("icons": "check.svg", "clapperboard.svg", "chevron-down.svg", + "chevrons-up-down.svg", "crop.svg", "cursor.svg", "chevron-left.svg", @@ -349,6 +350,7 @@ mod tests { include_str!("ui/radio_cards.rs"), include_str!("ui/windows_caption.rs"), include_str!("ui/hint.rs"), + include_str!("ui/select.rs"), // The onboarding window's welcome cards and permissions surface; the // per-permission row glyphs are named on `OSPermission::icon`. include_str!("onboarding_window.rs"), diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index d20a331252..c2d663122d 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -232,50 +232,25 @@ fn shadow(color: Hsla, y: f32, blur: f32, spread: f32, inset: bool) -> gpui::Box } } -/// Drawn in gpui rather than with an `NSGlassEffectView` per button, which -/// would have to track gpui's layout under the Metal layer. Two shadows, one -/// unblurred, keep it cheap on a window that repaints with the mic meter. +/// A control on the main window's glass, as macOS draws one inside a glass +/// window (`scripts/glass-reference/gallery.swift`): a flat, soft fill with +/// no outline, gradient or lift, since glass does not stack on glass. The +/// selected card takes the accent as a tint. fn glass_button(element: E, theme: Theme, selected: bool) -> E { - let dark = theme.is_dark(); - let (top, bottom, rim) = match (selected, dark) { - (false, false) => ( - gpui::hsla(0., 0., 1., 0.7), - gpui::hsla(0., 0., 1., 0.58), - gpui::hsla(0., 0., 0., 0.08), - ), - (false, true) => ( - gpui::hsla(0., 0., 1., 0.085), - gpui::hsla(0., 0., 1., 0.06), - gpui::hsla(0., 0., 1., 0.07), - ), - (true, false) => ( - Theme::with_alpha(theme.blue_3, 0.9), - Theme::with_alpha(theme.blue_3, 0.75), - Theme::with_alpha(theme.blue_8, 0.4), - ), - (true, true) => ( - Theme::with_alpha(theme.blue_9, 0.24), - Theme::with_alpha(theme.blue_9, 0.18), - Theme::with_alpha(theme.blue_9, 0.3), - ), + let fill = match (selected, theme.is_dark()) { + (false, false) => gpui::hsla(0., 0., 0., 0.05), + (false, true) => gpui::hsla(0., 0., 1., 0.08), + (true, false) => Theme::with_alpha(theme.blue_9, 0.14), + (true, true) => Theme::with_alpha(theme.blue_9, 0.28), }; - let (highlight, lift) = if dark { - (gpui::hsla(0., 0., 1., 0.07), gpui::hsla(0., 0., 0., 0.2)) + // A hairline so the control holds its shape over whatever shows through + // the window's glass, light or dark. + let edge = if theme.is_dark() { + gpui::hsla(0., 0., 1., 0.1) } else { - (gpui::hsla(0., 0., 1., 0.8), gpui::hsla(0., 0., 0., 0.04)) + gpui::hsla(0., 0., 0., 0.08) }; - element - .bg(gpui::linear_gradient( - 180., - gpui::linear_color_stop(top, 0.), - gpui::linear_color_stop(bottom, 1.), - )) - .border_1() - .border_color(rim) - .shadow(vec![ - shadow(highlight, 1., 0., 0., true), - shadow(lift, 1., 2., 0., false), - ]) + element.bg(fill).border_1().border_color(edge) } fn capture_hover_fill(theme: Theme, selected: bool) -> Hsla { @@ -6343,24 +6318,21 @@ impl MainWindow { .flex() .items_center() .justify_center() - .size(px(28.)) + .w(px(34.)) + .h(px(28.)) .rounded_full() - .bg(if selected { - Hsla::from(theme.gray_7) - } else { - theme.body_fill(3) - }) + // A segmented control's selected segment, as macOS raises + // it: a lifted capsule over the track. .when(selected, |this| { - // `ring-2 ring-blue-500 ring-offset-1 ring-offset-gray-1`. - this.border_2() - .border_color(theme.blue_500) - .shadow(vec![gpui::BoxShadow { - color: theme.ring_offset(), - offset: gpui::point(px(0.), px(0.)), - blur_radius: px(0.), - spread_radius: px(1.), - inset: false, - }]) + this.bg(if theme.is_dark() { + gpui::hsla(0., 0., 1., 0.24) + } else { + gpui::white() + }) + .shadow(vec![ + shadow(gpui::hsla(0., 0., 0., 0.12), 1., 3., 0., false), + shadow(gpui::hsla(0., 0., 0., 0.06), 0., 0., 0.5, false), + ]) }) .child( gpui::canvas(move |value, _, _| bounds.set(Some(value)), |_, _, _, _| {}) @@ -6373,7 +6345,9 @@ impl MainWindow { .size(px(mode.icon_size())) .text_color(theme.gray_12), ) - .hover(|style| style.bg(theme.body_hover_fill(7))) + .when(!selected, |this| { + this.hover(move |style| style.bg(glass_tint(theme, 0.05, 0.07))) + }) .on_click(cx.listener(move |this, _, _window, cx| { if this.session.read(cx).editor_recording_target().is_some() { return; @@ -6388,12 +6362,10 @@ impl MainWindow { .flex() .flex_row() .items_center() - .gap(px(8.)) - .p(px(6.)) + .gap(px(2.)) + .p(px(3.)) .rounded_full() - .border_1() - .border_color(theme.body_border(5)) - .bg(theme.body_fill(3)) + .bg(glass_tint(theme, 0.06, 0.09)) .child(button(Mode::Instant, "mode-instant", 0)) .child(button(Mode::Studio, "mode-studio", 1)) .child(button(Mode::Screenshot, "mode-screenshot", 2)) @@ -6609,6 +6581,12 @@ impl MainWindow { } else { Some(target) }; + // The tile picks by hovering: a window chosen from its + // dropdown earlier would otherwise stay locked in, and the + // highlight would ignore the window under the pointer. + if target == TargetType::Window { + this.selected_window = None; + } this.sync_overlays(cx); cx.notify(); })); diff --git a/apps/desktop-gpui/src/ui/button.rs b/apps/desktop-gpui/src/ui/button.rs index fc57412450..37dda34ff3 100644 --- a/apps/desktop-gpui/src/ui/button.rs +++ b/apps/desktop-gpui/src/ui/button.rs @@ -194,26 +194,43 @@ impl Button { variant: ButtonVariant, size: ButtonSize, ) -> Self { - let control_fill = theme - .material - .map(|material| Hsla::from(material.control_fill)) - .unwrap_or_else(|| Hsla::from(theme.gray_5)); - - // No `:hover` arm: the settings block's hover rules land on the nav and - // profile rows, not on `button[data-variant]`, and the window has never - // drawn one. + // macOS's bordered buttons in a grouped form + // (`scripts/glass-reference/gallery.swift`): a capsule with a soft grey + // fill and no outline, or the accent for the prominent family. + let (fill, hover) = if theme.is_dark() { + (gpui::hsla(0., 0., 1., 0.12), gpui::hsla(0., 0., 1., 0.18)) + } else { + (gpui::hsla(0., 0., 0., 0.06), gpui::hsla(0., 0., 0., 0.1)) + }; + let accent: Hsla = gpui::rgb(Theme::SETTINGS_ACCENT).into(); + let edge = if theme.is_dark() { + gpui::hsla(0., 0., 1., 0.08) + } else { + gpui::hsla(0., 0., 0., 0.07) + }; let paint = match variant { - ButtonVariant::Gray | ButtonVariant::White | ButtonVariant::Outline => ButtonPaint { - bg: Some(control_fill), - hover_bg: None, + ButtonVariant::Gray + | ButtonVariant::White + | ButtonVariant::Outline + | ButtonVariant::Ghost => ButtonPaint { + bg: Some(fill), + hover_bg: Some(hover), text: theme.settings_text(), hover_text: None, - border: Some(theme.settings_border()), + border: Some(edge), + hover_border: None, + }, + ButtonVariant::Destructive => ButtonPaint { + bg: Some(fill), + hover_bg: Some(hover), + text: gpui::rgb(0xff3b30).into(), + hover_text: None, + border: None, hover_border: None, }, _ => ButtonPaint { - bg: Some(gpui::rgb(Theme::SETTINGS_ACCENT).into()), - hover_bg: None, + bg: Some(accent), + hover_bg: Some(accent.blend(gpui::hsla(0., 0., 0., 0.1))), text: gpui::white(), hover_text: None, border: None, @@ -222,7 +239,7 @@ impl Button { }; Self { - radius: Some(px(8.)), + radius: Some(px(999.)), ..Self::plain(theme, id, variant, size) } .paint(paint) diff --git a/apps/desktop-gpui/src/ui/glass.rs b/apps/desktop-gpui/src/ui/glass.rs index 6155b71bcd..1b7910e6c8 100644 --- a/apps/desktop-gpui/src/ui/glass.rs +++ b/apps/desktop-gpui/src/ui/glass.rs @@ -122,18 +122,20 @@ fn sample_reach(style: &PaneStyle) -> f32 { /// everything added after it paints on the glass. `radius` is clamped to the /// pane like CSS clamps `border-radius`. pub fn pane(kind: GlassKind, dark: bool, radius: f32) -> Div { - pane_with(kind, dark, radius, None, None) + pane_with(kind, dark, radius, None, None, false) } /// [`pane`] with a tint. pub fn pane_tinted(kind: GlassKind, dark: bool, radius: f32, tint: Tint) -> Div { - pane_with(kind, dark, radius, None, Some(tint)) + pane_with(kind, dark, radius, None, Some(tint), false) } /// [`pane`] on a transparent window, reading `backdrop` instead of the -/// window's own (mostly empty) pixels. +/// window's own (mostly empty) pixels, with an edge so a control holds its +/// shape over whatever the desktop shows. [`pane`] alone stays exactly the +/// system material, which the glass probe measures against. pub fn pane_over(kind: GlassKind, dark: bool, radius: f32, backdrop: &Backdrop) -> Div { - pane_with(kind, dark, radius, Some(backdrop.clone()), None) + pane_with(kind, dark, radius, Some(backdrop.clone()), None, true) } fn pane_with( @@ -142,9 +144,11 @@ fn pane_with( radius: f32, backdrop: Option, tint: Option, + edge: bool, ) -> Div { let material = *material(kind, dark); - div().relative().rounded(px(radius)).child( + let (outer, inner) = edge_lines(dark); + let pane = div().relative().rounded(px(radius)).child( canvas( |_, _, _| {}, move |bounds, _, window, _| { @@ -158,9 +162,46 @@ fn pane_with( .top_0() .left_0() .size_full(), + ); + if !edge { + return pane; + } + // The pane's edge, so it holds its shape on any backdrop: a faint dark + // line that shows on light content and a faint light one just inside it + // that shows on dark. Drawn over the glass rather than as a border so the + // glass still reaches the pane's edge. + pane.child( + div() + .absolute() + .top_0() + .left_0() + .size_full() + .rounded(px(radius)) + .border_1() + .border_color(outer), + ) + .child( + div() + .absolute() + .top(px(1.)) + .left(px(1.)) + .right(px(1.)) + .bottom(px(1.)) + .rounded(px((radius - 1.).max(0.))) + .border_1() + .border_color(inner), ) } +/// The two hairlines around a pane's edge: dark outside, light inside. +fn edge_lines(dark: bool) -> (Hsla, Hsla) { + if dark { + (gpui::hsla(0., 0., 0., 0.28), gpui::hsla(0., 0., 1., 0.14)) + } else { + (gpui::hsla(0., 0., 0., 0.1), gpui::hsla(0., 0., 1., 0.45)) + } +} + /// What a transparent window's glass panes show through them: a capture of /// the desktop beneath the window, painted under the panes before anything /// else the window draws, so whatever the window paints over the desktop diff --git a/apps/desktop-gpui/src/ui/select.rs b/apps/desktop-gpui/src/ui/select.rs index 3b0498493f..b2ba6c2218 100644 --- a/apps/desktop-gpui/src/ui/select.rs +++ b/apps/desktop-gpui/src/ui/select.rs @@ -37,15 +37,16 @@ pub struct Select { /// `gap-1.5` on the settings row, `gap-2` on the editor's trigger. gap: Pixels, stretch: bool, + chip: Option, disabled: bool, on_click: Option, on_open: Option, } impl Select { - /// `SelectSettingItem`'s button: `flex flex-row gap-1.5 text-xs items-center - /// px-2.5 py-1.5 rounded-lg border bg-gray-3 text-gray-12 border-gray-4`, - /// radius 8 under the settings material. + /// A settings row's pop-up, as macOS draws one in a grouped form + /// (`scripts/glass-reference/gallery.swift`): the value as plain text and + /// a 20 pt round chip holding an up-down chevron, with no border or fill. pub fn settings( theme: &Theme, id: impl Into, @@ -54,20 +55,24 @@ impl Select { Self { id: id.into(), label: label.into(), - padding_x: px(10.), - padding_y: px(6.), - height: None, - radius: px(8.), - text_size: px(12.), - // No `font-*` class on the button, so it keeps `body`'s Medium. - weight: FontWeight::MEDIUM, - bg: Some(theme.settings_fill()), - border: Some(theme.settings_border()), + padding_x: px(2.), + padding_y: px(0.), + height: Some(px(22.)), + radius: px(11.), + text_size: px(13.), + weight: FontWeight::NORMAL, + bg: None, + border: None, text: theme.settings_text(), - chevron: theme.settings_muted(), - chevron_size: px(14.), - gap: px(6.), + chevron: theme.settings_text(), + chevron_size: px(11.), + gap: px(7.), stretch: false, + chip: Some(if theme.is_dark() { + gpui::hsla(0., 0., 1., 0.12) + } else { + gpui::hsla(0., 0., 0., 0.07) + }), disabled: false, on_click: None, on_open: None, @@ -92,6 +97,7 @@ impl Select { chevron: Hsla::from(theme.editor.text_3), chevron_size: px(12.), gap: px(8.), + chip: None, ..Self::settings(theme, id, label) } } @@ -143,6 +149,7 @@ impl RenderOnce for Select { chevron_size, gap, stretch, + chip, disabled, on_click, on_open, @@ -178,13 +185,31 @@ impl RenderOnce for Select { this.child(label) } }) - .child( - svg() - .path("icons/chevron-down.svg") - .size(chevron_size) - .flex_shrink_0() - .text_color(chevron), - ) + .map(|this| match chip { + Some(fill) => this.child( + div() + .size(px(20.)) + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .rounded_full() + .bg(fill) + .child( + svg() + .path("icons/chevrons-up-down.svg") + .size(chevron_size) + .text_color(chevron), + ), + ), + None => this.child( + svg() + .path("icons/chevron-down.svg") + .size(chevron_size) + .flex_shrink_0() + .text_color(chevron), + ), + }) .when_some(on_click.filter(|_| !disabled), |this, handler| { this.on_click(move |event, window, cx| handler(event, window, cx)) }) diff --git a/apps/desktop-gpui/src/ui/toggle.rs b/apps/desktop-gpui/src/ui/toggle.rs index 4a6bf7fdf1..fa5a1a2a4d 100644 --- a/apps/desktop-gpui/src/ui/toggle.rs +++ b/apps/desktop-gpui/src/ui/toggle.rs @@ -24,6 +24,10 @@ pub enum ToggleSize { Md, /// `w-14 h-7 p-0.75`, `size-6` thumb. Lg, + /// The system's mini switch, measured from macOS's own in a grouped + /// settings form (`scripts/glass-reference/gallery.swift`): a 36 x 16 + /// track and a 22 x 14 capsule knob. + Mini, } impl ToggleSize { @@ -32,14 +36,16 @@ impl ToggleSize { ToggleSize::Sm => (px(34.), px(20.), px(2.)), ToggleSize::Md => (px(44.), px(24.), px(2.)), ToggleSize::Lg => (px(56.), px(28.), px(3.)), + ToggleSize::Mini => (px(36.), px(16.), px(1.)), } } - fn thumb(self) -> Pixels { + fn thumb(self) -> (Pixels, Pixels) { match self { - ToggleSize::Sm => px(16.), - ToggleSize::Md => px(20.), - ToggleSize::Lg => px(24.), + ToggleSize::Sm => (px(16.), px(16.)), + ToggleSize::Md => (px(20.), px(20.)), + ToggleSize::Lg => (px(24.), px(24.)), + ToggleSize::Mini => (px(22.), px(14.)), } } } @@ -82,7 +88,7 @@ impl Toggle { /// bevel the same rule adds has no gpui equivalent (README deviation). pub fn settings(theme: &Theme, id: impl Into, checked: bool) -> Self { Self { - size: ToggleSize::Sm, + size: ToggleSize::Mini, // The material's control fill is near-white in light mode, so an // off toggle read as a blank pill on the white settings cards. off_fill: if theme.is_dark() { @@ -173,7 +179,21 @@ impl RenderOnce for Toggle { .when(checked, |this| this.justify_end()) .when(!disabled, |this| this.cursor_pointer()) .bg(if checked { on_fill } else { off_fill }) - .child(div().size(size.thumb()).rounded_full().bg(thumb)) + .child({ + let (thumb_width, thumb_height) = size.thumb(); + div() + .w(thumb_width) + .h(thumb_height) + .rounded_full() + .bg(thumb) + .shadow(vec![gpui::BoxShadow { + color: gpui::hsla(0., 0., 0., 0.18), + offset: gpui::point(px(0.), px(0.5)), + blur_radius: px(1.5), + spread_radius: px(0.), + inset: false, + }]) + }) .when_some(on_click.filter(|_| !disabled), |this, handler| { this.on_click(move |event, window, cx| handler(event, window, cx)) })