gpui-macos 0.1.1

A complete macOS design system for GPUI Kit: native controls, Liquid Glass surfaces, and desktop layouts.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
//! Optional AppKit chrome and material hosting for macOS windows.
//!
//! Navigation underlays expose system material through transparent GPUI regions.
//! Anchored popup factories use separate GPUI windows with an AppKit glass
//! content view, so the material can sample the owning window's rendered canvas.
//!
//! For native chrome, set `WindowOptions::window_background` to
//! [`gpui_kit::WindowBackgroundAppearance::Transparent`]. GPUI's `Blurred`
//! mode adds a separate selection-material backdrop that changes the appearance
//! of native glass layered above it.
use crate::ActiveTheme;
use gpui_kit::{
    AnyElement, App, Context, Div, ElementId, Hsla, InteractiveElement, Interactivity, IntoElement,
    ParentElement, Pixels, RenderOnce, SharedString, Stateful, StatefulInteractiveElement,
    StyleRefinement, Styled, Subscription, Window, canvas, div, px, rems,
};
use std::rc::Rc;

#[cfg(target_os = "macos")]
mod macos;
mod panel;
mod portal;
pub use panel::{
    GlassPanel, GlassPanelHandle, GlassPanelOptions, NativeGlassPopup, install_glass_popup,
    open_glass_panel,
};
pub use portal::install_glass_popup_presenter;

/// Whether a real NSGlassEffectView can be hosted in this desktop window.
/// Returns false off macOS, in headless windows, and before macOS 26.
pub fn glass_available(window: &Window) -> bool {
    #[cfg(target_os = "macos")]
    return macos::glass_available(window);
    #[cfg(not(target_os = "macos"))]
    {
        let _ = window;
        false
    }
}

/// Whether this window's foreground renderer is hosted in a real glass popup.
/// Surface adapters can leave their backgrounds clear in this presentation.
pub fn is_glass_popup(window: &Window) -> bool {
    #[cfg(target_os = "macos")]
    return macos::is_glass_popup(window);
    #[cfg(not(target_os = "macos"))]
    {
        let _ = window;
        false
    }
}

/// AppKit source-list dimensions in logical points. Native rows follow the
/// system sidebar size preference; portable/headless views use the regular size.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SidebarMetrics {
    pub row_height: Pixels,
    pub icon_column_width: Pixels,
    pub label_size: Pixels,
}

impl Default for SidebarMetrics {
    fn default() -> Self {
        Self {
            row_height: px(32.),
            icon_column_width: px(24.),
            label_size: px(13.),
        }
    }
}

/// Read the system's default source-list row size without allocating a view
/// for every row. Call [`crate::refresh_accessibility`] after changing settings.
pub fn sidebar_metrics() -> SidebarMetrics {
    #[cfg(target_os = "macos")]
    return macos::sidebar_metrics();
    #[cfg(not(target_os = "macos"))]
    SidebarMetrics::default()
}

/// The selected-row tint in an inactive source list. Ordinary AppKit source
/// lists use vibrant material paint, not the opaque generic selection color.
/// Accessibility modes use the system's opaque semantic selection instead.
pub fn sidebar_inactive_selection(cx: &App) -> Hsla {
    let theme = cx.macos();
    let dark = theme.is_dark();
    if !theme.reduce_transparency && !theme.increased_contrast {
        // Measured NSOutlineView.sourceList: #F9F9F9 over #FFFFFF in Aqua;
        // #191B1D over #1F2022 in Dark Aqua. A tint preserves native glass.
        return gpui_kit::rgb(0)
            .opacity(if dark { 0.18 } else { 6. / 255. })
            .into();
    }
    #[cfg(target_os = "macos")]
    if let Some(color) = macos::sidebar_inactive_selection(dark) {
        return color;
    }
    gpui_kit::rgb(if dark { 0x464646 } else { 0xdcdcdc }).into()
}

pub(crate) fn refresh_sidebar_metrics() {
    #[cfg(target_os = "macos")]
    macos::refresh_sidebar_metrics();
}

/// A system glass underlay with GPUI-owned content, layout and interactions.
///
/// Leave ancestor fills transparent where the native material should show.
/// Opaque GPUI content hides the underlay normally. The system view refracts
/// content behind the window; it cannot refract the GPUI Metal plane above it.
/// The underlay follows layout and clipping, ignores pointer hit testing, and
/// is removed when the element stops rendering. Older systems and reduced
/// transparency use the theme's opaque/material fallback.
/// Native corners are uniform; use `corner_radius` or uniform `rounded` builders
/// to keep the AppKit effect and GPUI border geometry aligned.
#[derive(IntoElement)]
pub struct NativeGlass {
    id: ElementId,
    base: Stateful<Div>,
    clear: bool,
    tint: Option<Hsla>,
}

/// Start a real system glass surface where supported, with a themed fallback.
pub fn native_glass(id: impl Into<ElementId>, cx: &App) -> NativeGlass {
    let id = id.into();
    let t = cx.macos();
    NativeGlass {
        base: div()
            .id(id.clone())
            .relative()
            .rounded(rems(0.75))
            .border_color(t.glass_border())
            .font_family(t.font.clone())
            .text_size(rems(0.8125))
            .text_color(t.foreground),
        id,
        clear: false,
        tint: None,
    }
}

impl NativeGlass {
    /// Match both the native glass and GPUI border corner geometry.
    pub fn corner_radius(mut self, radius: Pixels) -> Self {
        let value = f32::from(radius);
        let radius = px(if value.is_finite() { value.max(0.) } else { 0. });
        self.base = self.base.rounded(radius);
        self
    }

    /// Use AppKit's clear glass style instead of its regular style.
    pub fn clear(mut self, clear: bool) -> Self {
        self.clear = clear;
        self
    }

    /// Tint the real glass using a system NSColor.
    pub fn tint(mut self, tint: Hsla) -> Self {
        self.tint = Some(tint);
        self
    }
}

impl Styled for NativeGlass {
    fn style(&mut self) -> &mut StyleRefinement {
        self.base.style()
    }
}
impl ParentElement for NativeGlass {
    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
        self.base.extend(elements);
    }
}
impl InteractiveElement for NativeGlass {
    fn interactivity(&mut self) -> &mut Interactivity {
        self.base.interactivity()
    }
}
impl StatefulInteractiveElement for NativeGlass {}

impl RenderOnce for NativeGlass {
    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
        if !cx.macos().reduce_transparency && is_glass_popup(window) {
            // The popup's own glass already surrounds this renderer. Avoid
            // adding arbitrary siblings inside AppKit's glass content tree.
            return self.base;
        }
        #[cfg(target_os = "macos")]
        if !cx.macos().reduce_transparency && glass_available(window) {
            let state = window.use_keyed_state((self.id, "native-glass-host"), cx, |window, _| {
                macos::GlassUnderlay::new(window).ok()
            });
            if state.read(cx).is_some() {
                let radius = self
                    .base
                    .style()
                    .corner_radii
                    .top_left
                    .unwrap_or(px(0.).into());
                let rem_size = window.rem_size();
                let clear = self.clear;
                let tint = self.tint;
                return self.base.child(
                    canvas(
                        |bounds, window, _| bounds.intersect(&window.content_mask().bounds),
                        move |bounds, visible, window, cx| {
                            if let Some(host) = state.read(cx) {
                                host.update(
                                    bounds,
                                    visible,
                                    native_corner_radius(radius, rem_size, bounds.size),
                                    clear,
                                    tint,
                                    window,
                                );
                            }
                        },
                    )
                    .absolute()
                    .inset_0(),
                );
            }
        }
        let _ = window;
        self.base = self.base.bg(cx.macos().glass_fill());
        self.base
    }
}

fn native_corner_radius(
    radius: gpui_kit::AbsoluteLength,
    rem_size: Pixels,
    size: gpui_kit::Size<Pixels>,
) -> Pixels {
    let radius = f32::from(radius.to_pixels(rem_size));
    px(if radius.is_finite() {
        radius
            .max(0.)
            .min(f32::from(size.width).max(0.) / 2.)
            .min(f32::from(size.height).max(0.) / 2.)
    } else {
        0.
    })
}

/// A real NSToolbar command with native SF Symbols artwork and accessibility.
#[derive(Clone, Debug)]
pub enum NativeToolbarItem {
    Button {
        id: SharedString,
        label: SharedString,
        symbol: SharedString,
        enabled: bool,
    },
    Search {
        id: SharedString,
        label: SharedString,
        placeholder: SharedString,
        width: Pixels,
        enabled: bool,
    },
    FlexibleSpace,
}

impl NativeToolbarItem {
    pub fn button(
        id: impl Into<SharedString>,
        label: impl Into<SharedString>,
        sf_symbol: impl Into<SharedString>,
    ) -> Self {
        Self::Button {
            id: id.into(),
            label: label.into(),
            symbol: sf_symbol.into(),
            enabled: true,
        }
    }

    pub fn disabled(mut self, disabled: bool) -> Self {
        match &mut self {
            Self::Button { enabled, .. } | Self::Search { enabled, .. } => *enabled = !disabled,
            Self::FlexibleSpace => {}
        }
        self
    }

    /// An actual NSSearchField, sized in logical points inside the toolbar.
    pub fn search(
        id: impl Into<SharedString>,
        label: impl Into<SharedString>,
        placeholder: impl Into<SharedString>,
        width: Pixels,
    ) -> Self {
        Self::Search {
            id: id.into(),
            label: label.into(),
            placeholder: placeholder.into(),
            width,
            enabled: true,
        }
    }
}

/// Keep this handle in the application's window view for the toolbar lifetime.
/// Dropping it restores the window's preceding toolbar and title treatment.
pub struct NativeToolbarHandle {
    #[cfg(target_os = "macos")]
    inner: macos::ToolbarHost,
}

impl NativeToolbarHandle {
    /// Update an existing command's enabled state and its native accessibility.
    pub fn set_enabled(&self, id: &str, enabled: bool) -> bool {
        #[cfg(target_os = "macos")]
        return self.inner.set_enabled(id, enabled);
        #[cfg(not(target_os = "macos"))]
        {
            let _ = (id, enabled);
            false
        }
    }

    /// Read an actual toolbar search field. Its command callback fires whenever
    /// native search text changes, allowing the application to read this value.
    pub fn search_text(&self, id: &str) -> Option<SharedString> {
        #[cfg(target_os = "macos")]
        return self.inner.search_text(id);
        #[cfg(not(target_os = "macos"))]
        {
            let _ = id;
            None
        }
    }

    pub fn set_title(&self, title: impl Into<SharedString>) {
        let title = title.into();
        #[cfg(target_os = "macos")]
        self.inner.set_title(&title);
        #[cfg(not(target_os = "macos"))]
        let _ = title;
    }

    /// Extend a full-height sidebar behind the native titlebar and reserve its
    /// width before the native window title. Zero restores ordinary chrome.
    /// Update this when the sidebar is resized or hidden.
    pub fn set_sidebar_width(&self, width: Pixels) {
        #[cfg(target_os = "macos")]
        self.inner.set_sidebar_width(width);
        #[cfg(not(target_os = "macos"))]
        let _ = width;
    }
}

type Command = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;

/// Install an actual unified AppKit toolbar on the existing GPUI NSWindow.
///
/// Use [`native_toolbar_inset`] to reserve any native toolbar overlap in a
/// full-size content window. Avoid drawing a second GPUI title/toolbar row.
/// Command callbacks run on GPUI's foreground executor after the native event,
/// so they cannot reenter an App borrow. The NSWindow delegate stays with GPUI.
/// Use a transparent GPUI window background rather than its separate `Blurred`
/// backdrop. The bridge supplies AppKit's semantic window/titlebar background.
pub fn install_native_toolbar(
    id: impl Into<SharedString>,
    title: impl Into<SharedString>,
    items: Vec<NativeToolbarItem>,
    window: &mut Window,
    cx: &mut App,
    on_command: impl Fn(SharedString, &mut Window, &mut App) + 'static,
) -> anyhow::Result<NativeToolbarHandle> {
    let id = id.into();
    let title = title.into();
    let command: Command = Rc::new(on_command);
    #[cfg(target_os = "macos")]
    return macos::install_toolbar(id, title, items, window, cx, command)
        .map(|inner| NativeToolbarHandle { inner });
    #[cfg(not(target_os = "macos"))]
    {
        let _ = (id, title, items, window, cx, command);
        anyhow::bail!("Native AppKit toolbars require macOS")
    }
}

/// The area at the top of the GPUI renderer covered by native window chrome.
/// Returns zero when AppKit already excludes that area or no handle exists.
pub fn native_toolbar_inset(window: &Window) -> Pixels {
    #[cfg(target_os = "macos")]
    return macos::toolbar_inset(window);
    #[cfg(not(target_os = "macos"))]
    {
        let _ = window;
        Pixels::ZERO
    }
}

/// Observe this window's real activation state without replacing its delegate.
/// Keep the subscription on the view, or detach it for that view's lifetime.
pub fn observe_activation<V: 'static>(
    window: &mut Window,
    cx: &mut Context<V>,
    mut callback: impl FnMut(&mut V, bool, &mut Window, &mut Context<V>) + 'static,
) -> Subscription {
    cx.observe_window_activation(window, move |view, window, cx| {
        callback(view, window.is_window_active(), window, cx);
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{ButtonVariant, Theme, button};
    use gpui_kit::{Render, TestAppContext, size};
    use std::cell::Cell;

    struct FallbackHarness {
        clicks: usize,
    }

    #[test]
    fn native_glass_corner_geometry_scales_and_matches_capsule_bounds() {
        let bounds = size(px(200.), px(40.));
        assert_eq!(
            native_corner_radius(rems(0.75).into(), px(16.), bounds),
            px(12.)
        );
        assert_eq!(
            native_corner_radius(rems(0.75).into(), px(24.), bounds),
            px(18.)
        );
        assert_eq!(
            native_corner_radius(rems(999.).into(), px(16.), bounds),
            px(20.)
        );
        assert_eq!(
            native_corner_radius(px(f32::NAN).into(), px(16.), bounds),
            Pixels::ZERO
        );
    }

    impl Render for FallbackHarness {
        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
            native_glass("fallback-surface", cx)
                .w(px(240.))
                .h(px(96.))
                .p(px(12.))
                .corner_radius(px(8.))
                .child(
                    button("native-content", "Continue", ButtonVariant::Secondary, cx)
                        .debug_selector(|| "native-content".into())
                        .on_click(cx.listener(|this, _, _, cx| {
                            this.clicks += 1;
                            cx.notify();
                        })),
                )
        }
    }

    #[gpui_kit::test]
    fn headless_glass_paints_accessible_fallback_and_preserves_child_interaction(
        cx: &mut TestAppContext,
    ) {
        cx.update(crate::init);
        let (view, cx) = cx.add_window_view(|_, _| FallbackHarness { clicks: 0 });
        for dark in [false, true] {
            for reduce_transparency in [false, true] {
                cx.update(|window, cx| {
                    let theme = if dark { Theme::dark() } else { Theme::light() };
                    theme
                        .with_reduce_transparency(reduce_transparency)
                        .apply(cx);
                    assert!(!glass_available(window));
                    assert_eq!(native_toolbar_inset(window), Pixels::ZERO);
                    window.draw(cx).clear(cx);
                    let fill = cx.macos().glass_fill();
                    let surface = window
                        .painted_quads()
                        .into_iter()
                        .find(|quad| {
                            quad.bounds.size == size(px(240.), px(96.)).scale(window.scale_factor())
                        })
                        .expect("Fallback must paint the laid-out material surface");
                    assert_eq!(surface.background.as_solid(), Some(fill));
                    if reduce_transparency {
                        assert_eq!(fill.a, 1., "Reduced transparency must be fully opaque");
                    }
                });
            }
        }
        let command = cx.debug_bounds("native-content").unwrap().center();
        cx.simulate_click(command, Default::default());
        cx.update(|window, cx| {
            window.draw(cx).clear(cx);
            assert_eq!(view.read(cx).clicks, 1);
        });
    }

    #[gpui_kit::test]
    fn headless_toolbar_install_is_rejected_without_emitting_commands(cx: &mut TestAppContext) {
        cx.update(crate::init);
        let (_, cx) = cx.add_window_view(|_, _| FallbackHarness { clicks: 0 });
        let calls = Rc::new(Cell::new(0));
        let observed = calls.clone();
        cx.update(|window, cx| {
            let result = install_native_toolbar(
                "headless-toolbar",
                "Test",
                vec![
                    NativeToolbarItem::button("add", "Add", "plus"),
                    NativeToolbarItem::search("search", "Search", "Search", px(200.)),
                ],
                window,
                cx,
                move |_, _, _| observed.set(observed.get() + 1),
            );
            assert!(
                result.is_err(),
                "Headless windows have no AppKit toolbar host"
            );
            assert_eq!(native_toolbar_inset(window), Pixels::ZERO);
            window.draw(cx).clear(cx);
        });
        assert_eq!(calls.get(), 0);
    }
}