Skip to main content

gpui_macos/
native.rs

1//! Optional AppKit chrome and material hosting for macOS windows.
2//!
3//! Navigation underlays expose system material through transparent GPUI regions.
4//! Anchored popup factories use separate GPUI windows with an AppKit glass
5//! content view, so the material can sample the owning window's rendered canvas.
6//!
7//! For native chrome, set `WindowOptions::window_background` to
8//! [`gpui_kit::WindowBackgroundAppearance::Transparent`]. GPUI's `Blurred`
9//! mode adds a separate selection-material backdrop that changes the appearance
10//! of native glass layered above it.
11use crate::ActiveTheme;
12use gpui_kit::{
13    AnyElement, App, Context, Div, ElementId, Hsla, InteractiveElement, Interactivity, IntoElement,
14    ParentElement, Pixels, RenderOnce, SharedString, Stateful, StatefulInteractiveElement,
15    StyleRefinement, Styled, Subscription, Window, canvas, div, px, rems,
16};
17use std::rc::Rc;
18
19#[cfg(target_os = "macos")]
20mod macos;
21mod panel;
22mod portal;
23pub use panel::{
24    GlassPanel, GlassPanelHandle, GlassPanelOptions, NativeGlassPopup, install_glass_popup,
25    open_glass_panel,
26};
27pub use portal::install_glass_popup_presenter;
28
29/// Whether a real NSGlassEffectView can be hosted in this desktop window.
30/// Returns false off macOS, in headless windows, and before macOS 26.
31pub fn glass_available(window: &Window) -> bool {
32    #[cfg(target_os = "macos")]
33    return macos::glass_available(window);
34    #[cfg(not(target_os = "macos"))]
35    {
36        let _ = window;
37        false
38    }
39}
40
41/// Whether this window's foreground renderer is hosted in a real glass popup.
42/// Surface adapters can leave their backgrounds clear in this presentation.
43pub fn is_glass_popup(window: &Window) -> bool {
44    #[cfg(target_os = "macos")]
45    return macos::is_glass_popup(window);
46    #[cfg(not(target_os = "macos"))]
47    {
48        let _ = window;
49        false
50    }
51}
52
53/// AppKit source-list dimensions in logical points. Native rows follow the
54/// system sidebar size preference; portable/headless views use the regular size.
55#[derive(Clone, Copy, Debug, PartialEq)]
56pub struct SidebarMetrics {
57    pub row_height: Pixels,
58    pub icon_column_width: Pixels,
59    pub label_size: Pixels,
60}
61
62impl Default for SidebarMetrics {
63    fn default() -> Self {
64        Self {
65            row_height: px(32.),
66            icon_column_width: px(24.),
67            label_size: px(13.),
68        }
69    }
70}
71
72/// Read the system's default source-list row size without allocating a view
73/// for every row. Call [`crate::refresh_accessibility`] after changing settings.
74pub fn sidebar_metrics() -> SidebarMetrics {
75    #[cfg(target_os = "macos")]
76    return macos::sidebar_metrics();
77    #[cfg(not(target_os = "macos"))]
78    SidebarMetrics::default()
79}
80
81/// The selected-row tint in an inactive source list. Ordinary AppKit source
82/// lists use vibrant material paint, not the opaque generic selection color.
83/// Accessibility modes use the system's opaque semantic selection instead.
84pub fn sidebar_inactive_selection(cx: &App) -> Hsla {
85    let theme = cx.macos();
86    let dark = theme.is_dark();
87    if !theme.reduce_transparency && !theme.increased_contrast {
88        // Measured NSOutlineView.sourceList: #F9F9F9 over #FFFFFF in Aqua;
89        // #191B1D over #1F2022 in Dark Aqua. A tint preserves native glass.
90        return gpui_kit::rgb(0)
91            .opacity(if dark { 0.18 } else { 6. / 255. })
92            .into();
93    }
94    #[cfg(target_os = "macos")]
95    if let Some(color) = macos::sidebar_inactive_selection(dark) {
96        return color;
97    }
98    gpui_kit::rgb(if dark { 0x464646 } else { 0xdcdcdc }).into()
99}
100
101pub(crate) fn refresh_sidebar_metrics() {
102    #[cfg(target_os = "macos")]
103    macos::refresh_sidebar_metrics();
104}
105
106/// A system glass underlay with GPUI-owned content, layout and interactions.
107///
108/// Leave ancestor fills transparent where the native material should show.
109/// Opaque GPUI content hides the underlay normally. The system view refracts
110/// content behind the window; it cannot refract the GPUI Metal plane above it.
111/// The underlay follows layout and clipping, ignores pointer hit testing, and
112/// is removed when the element stops rendering. Older systems and reduced
113/// transparency use the theme's opaque/material fallback.
114/// Native corners are uniform; use `corner_radius` or uniform `rounded` builders
115/// to keep the AppKit effect and GPUI border geometry aligned.
116#[derive(IntoElement)]
117pub struct NativeGlass {
118    id: ElementId,
119    base: Stateful<Div>,
120    clear: bool,
121    tint: Option<Hsla>,
122}
123
124/// Start a real system glass surface where supported, with a themed fallback.
125pub fn native_glass(id: impl Into<ElementId>, cx: &App) -> NativeGlass {
126    let id = id.into();
127    let t = cx.macos();
128    NativeGlass {
129        base: div()
130            .id(id.clone())
131            .relative()
132            .rounded(rems(0.75))
133            .border_color(t.glass_border())
134            .font_family(t.font.clone())
135            .text_size(rems(0.8125))
136            .text_color(t.foreground),
137        id,
138        clear: false,
139        tint: None,
140    }
141}
142
143impl NativeGlass {
144    /// Match both the native glass and GPUI border corner geometry.
145    pub fn corner_radius(mut self, radius: Pixels) -> Self {
146        let value = f32::from(radius);
147        let radius = px(if value.is_finite() { value.max(0.) } else { 0. });
148        self.base = self.base.rounded(radius);
149        self
150    }
151
152    /// Use AppKit's clear glass style instead of its regular style.
153    pub fn clear(mut self, clear: bool) -> Self {
154        self.clear = clear;
155        self
156    }
157
158    /// Tint the real glass using a system NSColor.
159    pub fn tint(mut self, tint: Hsla) -> Self {
160        self.tint = Some(tint);
161        self
162    }
163}
164
165impl Styled for NativeGlass {
166    fn style(&mut self) -> &mut StyleRefinement {
167        self.base.style()
168    }
169}
170impl ParentElement for NativeGlass {
171    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
172        self.base.extend(elements);
173    }
174}
175impl InteractiveElement for NativeGlass {
176    fn interactivity(&mut self) -> &mut Interactivity {
177        self.base.interactivity()
178    }
179}
180impl StatefulInteractiveElement for NativeGlass {}
181
182impl RenderOnce for NativeGlass {
183    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
184        if !cx.macos().reduce_transparency && is_glass_popup(window) {
185            // The popup's own glass already surrounds this renderer. Avoid
186            // adding arbitrary siblings inside AppKit's glass content tree.
187            return self.base;
188        }
189        #[cfg(target_os = "macos")]
190        if !cx.macos().reduce_transparency && glass_available(window) {
191            let state = window.use_keyed_state((self.id, "native-glass-host"), cx, |window, _| {
192                macos::GlassUnderlay::new(window).ok()
193            });
194            if state.read(cx).is_some() {
195                let radius = self
196                    .base
197                    .style()
198                    .corner_radii
199                    .top_left
200                    .unwrap_or(px(0.).into());
201                let rem_size = window.rem_size();
202                let clear = self.clear;
203                let tint = self.tint;
204                return self.base.child(
205                    canvas(
206                        |bounds, window, _| bounds.intersect(&window.content_mask().bounds),
207                        move |bounds, visible, window, cx| {
208                            if let Some(host) = state.read(cx) {
209                                host.update(
210                                    bounds,
211                                    visible,
212                                    native_corner_radius(radius, rem_size, bounds.size),
213                                    clear,
214                                    tint,
215                                    window,
216                                );
217                            }
218                        },
219                    )
220                    .absolute()
221                    .inset_0(),
222                );
223            }
224        }
225        let _ = window;
226        self.base = self.base.bg(cx.macos().glass_fill());
227        self.base
228    }
229}
230
231fn native_corner_radius(
232    radius: gpui_kit::AbsoluteLength,
233    rem_size: Pixels,
234    size: gpui_kit::Size<Pixels>,
235) -> Pixels {
236    let radius = f32::from(radius.to_pixels(rem_size));
237    px(if radius.is_finite() {
238        radius
239            .max(0.)
240            .min(f32::from(size.width).max(0.) / 2.)
241            .min(f32::from(size.height).max(0.) / 2.)
242    } else {
243        0.
244    })
245}
246
247/// A real NSToolbar command with native SF Symbols artwork and accessibility.
248#[derive(Clone, Debug)]
249pub enum NativeToolbarItem {
250    Button {
251        id: SharedString,
252        label: SharedString,
253        symbol: SharedString,
254        enabled: bool,
255    },
256    Search {
257        id: SharedString,
258        label: SharedString,
259        placeholder: SharedString,
260        width: Pixels,
261        enabled: bool,
262    },
263    FlexibleSpace,
264}
265
266impl NativeToolbarItem {
267    pub fn button(
268        id: impl Into<SharedString>,
269        label: impl Into<SharedString>,
270        sf_symbol: impl Into<SharedString>,
271    ) -> Self {
272        Self::Button {
273            id: id.into(),
274            label: label.into(),
275            symbol: sf_symbol.into(),
276            enabled: true,
277        }
278    }
279
280    pub fn disabled(mut self, disabled: bool) -> Self {
281        match &mut self {
282            Self::Button { enabled, .. } | Self::Search { enabled, .. } => *enabled = !disabled,
283            Self::FlexibleSpace => {}
284        }
285        self
286    }
287
288    /// An actual NSSearchField, sized in logical points inside the toolbar.
289    pub fn search(
290        id: impl Into<SharedString>,
291        label: impl Into<SharedString>,
292        placeholder: impl Into<SharedString>,
293        width: Pixels,
294    ) -> Self {
295        Self::Search {
296            id: id.into(),
297            label: label.into(),
298            placeholder: placeholder.into(),
299            width,
300            enabled: true,
301        }
302    }
303}
304
305/// Keep this handle in the application's window view for the toolbar lifetime.
306/// Dropping it restores the window's preceding toolbar and title treatment.
307pub struct NativeToolbarHandle {
308    #[cfg(target_os = "macos")]
309    inner: macos::ToolbarHost,
310}
311
312impl NativeToolbarHandle {
313    /// Update an existing command's enabled state and its native accessibility.
314    pub fn set_enabled(&self, id: &str, enabled: bool) -> bool {
315        #[cfg(target_os = "macos")]
316        return self.inner.set_enabled(id, enabled);
317        #[cfg(not(target_os = "macos"))]
318        {
319            let _ = (id, enabled);
320            false
321        }
322    }
323
324    /// Read an actual toolbar search field. Its command callback fires whenever
325    /// native search text changes, allowing the application to read this value.
326    pub fn search_text(&self, id: &str) -> Option<SharedString> {
327        #[cfg(target_os = "macos")]
328        return self.inner.search_text(id);
329        #[cfg(not(target_os = "macos"))]
330        {
331            let _ = id;
332            None
333        }
334    }
335
336    pub fn set_title(&self, title: impl Into<SharedString>) {
337        let title = title.into();
338        #[cfg(target_os = "macos")]
339        self.inner.set_title(&title);
340        #[cfg(not(target_os = "macos"))]
341        let _ = title;
342    }
343
344    /// Extend a full-height sidebar behind the native titlebar and reserve its
345    /// width before the native window title. Zero restores ordinary chrome.
346    /// Update this when the sidebar is resized or hidden.
347    pub fn set_sidebar_width(&self, width: Pixels) {
348        #[cfg(target_os = "macos")]
349        self.inner.set_sidebar_width(width);
350        #[cfg(not(target_os = "macos"))]
351        let _ = width;
352    }
353}
354
355type Command = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
356
357/// Install an actual unified AppKit toolbar on the existing GPUI NSWindow.
358///
359/// Use [`native_toolbar_inset`] to reserve any native toolbar overlap in a
360/// full-size content window. Avoid drawing a second GPUI title/toolbar row.
361/// Command callbacks run on GPUI's foreground executor after the native event,
362/// so they cannot reenter an App borrow. The NSWindow delegate stays with GPUI.
363/// Use a transparent GPUI window background rather than its separate `Blurred`
364/// backdrop. The bridge supplies AppKit's semantic window/titlebar background.
365pub fn install_native_toolbar(
366    id: impl Into<SharedString>,
367    title: impl Into<SharedString>,
368    items: Vec<NativeToolbarItem>,
369    window: &mut Window,
370    cx: &mut App,
371    on_command: impl Fn(SharedString, &mut Window, &mut App) + 'static,
372) -> anyhow::Result<NativeToolbarHandle> {
373    let id = id.into();
374    let title = title.into();
375    let command: Command = Rc::new(on_command);
376    #[cfg(target_os = "macos")]
377    return macos::install_toolbar(id, title, items, window, cx, command)
378        .map(|inner| NativeToolbarHandle { inner });
379    #[cfg(not(target_os = "macos"))]
380    {
381        let _ = (id, title, items, window, cx, command);
382        anyhow::bail!("Native AppKit toolbars require macOS")
383    }
384}
385
386/// The area at the top of the GPUI renderer covered by native window chrome.
387/// Returns zero when AppKit already excludes that area or no handle exists.
388pub fn native_toolbar_inset(window: &Window) -> Pixels {
389    #[cfg(target_os = "macos")]
390    return macos::toolbar_inset(window);
391    #[cfg(not(target_os = "macos"))]
392    {
393        let _ = window;
394        Pixels::ZERO
395    }
396}
397
398/// Observe this window's real activation state without replacing its delegate.
399/// Keep the subscription on the view, or detach it for that view's lifetime.
400pub fn observe_activation<V: 'static>(
401    window: &mut Window,
402    cx: &mut Context<V>,
403    mut callback: impl FnMut(&mut V, bool, &mut Window, &mut Context<V>) + 'static,
404) -> Subscription {
405    cx.observe_window_activation(window, move |view, window, cx| {
406        callback(view, window.is_window_active(), window, cx);
407    })
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use crate::{ButtonVariant, Theme, button};
414    use gpui_kit::{Render, TestAppContext, size};
415    use std::cell::Cell;
416
417    struct FallbackHarness {
418        clicks: usize,
419    }
420
421    #[test]
422    fn native_glass_corner_geometry_scales_and_matches_capsule_bounds() {
423        let bounds = size(px(200.), px(40.));
424        assert_eq!(
425            native_corner_radius(rems(0.75).into(), px(16.), bounds),
426            px(12.)
427        );
428        assert_eq!(
429            native_corner_radius(rems(0.75).into(), px(24.), bounds),
430            px(18.)
431        );
432        assert_eq!(
433            native_corner_radius(rems(999.).into(), px(16.), bounds),
434            px(20.)
435        );
436        assert_eq!(
437            native_corner_radius(px(f32::NAN).into(), px(16.), bounds),
438            Pixels::ZERO
439        );
440    }
441
442    impl Render for FallbackHarness {
443        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
444            native_glass("fallback-surface", cx)
445                .w(px(240.))
446                .h(px(96.))
447                .p(px(12.))
448                .corner_radius(px(8.))
449                .child(
450                    button("native-content", "Continue", ButtonVariant::Secondary, cx)
451                        .debug_selector(|| "native-content".into())
452                        .on_click(cx.listener(|this, _, _, cx| {
453                            this.clicks += 1;
454                            cx.notify();
455                        })),
456                )
457        }
458    }
459
460    #[gpui_kit::test]
461    fn headless_glass_paints_accessible_fallback_and_preserves_child_interaction(
462        cx: &mut TestAppContext,
463    ) {
464        cx.update(crate::init);
465        let (view, cx) = cx.add_window_view(|_, _| FallbackHarness { clicks: 0 });
466        for dark in [false, true] {
467            for reduce_transparency in [false, true] {
468                cx.update(|window, cx| {
469                    let theme = if dark { Theme::dark() } else { Theme::light() };
470                    theme
471                        .with_reduce_transparency(reduce_transparency)
472                        .apply(cx);
473                    assert!(!glass_available(window));
474                    assert_eq!(native_toolbar_inset(window), Pixels::ZERO);
475                    window.draw(cx).clear(cx);
476                    let fill = cx.macos().glass_fill();
477                    let surface = window
478                        .painted_quads()
479                        .into_iter()
480                        .find(|quad| {
481                            quad.bounds.size == size(px(240.), px(96.)).scale(window.scale_factor())
482                        })
483                        .expect("Fallback must paint the laid-out material surface");
484                    assert_eq!(surface.background.as_solid(), Some(fill));
485                    if reduce_transparency {
486                        assert_eq!(fill.a, 1., "Reduced transparency must be fully opaque");
487                    }
488                });
489            }
490        }
491        let command = cx.debug_bounds("native-content").unwrap().center();
492        cx.simulate_click(command, Default::default());
493        cx.update(|window, cx| {
494            window.draw(cx).clear(cx);
495            assert_eq!(view.read(cx).clicks, 1);
496        });
497    }
498
499    #[gpui_kit::test]
500    fn headless_toolbar_install_is_rejected_without_emitting_commands(cx: &mut TestAppContext) {
501        cx.update(crate::init);
502        let (_, cx) = cx.add_window_view(|_, _| FallbackHarness { clicks: 0 });
503        let calls = Rc::new(Cell::new(0));
504        let observed = calls.clone();
505        cx.update(|window, cx| {
506            let result = install_native_toolbar(
507                "headless-toolbar",
508                "Test",
509                vec![
510                    NativeToolbarItem::button("add", "Add", "plus"),
511                    NativeToolbarItem::search("search", "Search", "Search", px(200.)),
512                ],
513                window,
514                cx,
515                move |_, _, _| observed.set(observed.get() + 1),
516            );
517            assert!(
518                result.is_err(),
519                "Headless windows have no AppKit toolbar host"
520            );
521            assert_eq!(native_toolbar_inset(window), Pixels::ZERO);
522            window.draw(cx).clear(cx);
523        });
524        assert_eq!(calls.get(), 0);
525    }
526}