Skip to main content

gpui_macos/native/
panel.rs

1//! Real glass foregrounds in separate, GPUI-owned popup windows.
2//!
3//! The owner window's rendered scene is below the popup at the WindowServer
4//! level. AppKit can therefore sample that scene, while a transparent GPUI
5//! renderer inside `NSGlassEffectView.contentView` owns the foreground.
6use crate::ActiveTheme;
7use gpui_kit::{
8    AnyWindowHandle, App, AppContext, AsyncApp, Bounds, Context, Entity, FocusHandle, Hsla,
9    InteractiveElement, IntoElement, ParentElement, Pixels, Render, StatefulInteractiveElement,
10    Styled, Subscription, WeakEntity, Window, WindowBackgroundAppearance, WindowBounds,
11    WindowHandle, WindowKind, WindowOptions, div, px,
12};
13use std::{cell::Cell, rc::Rc};
14
15/// Geometry and material for a popup above its owning GPUI window.
16#[derive(Clone, Debug)]
17pub struct GlassPanelOptions {
18    /// Bounds in the owning renderer's top-left, logical-point coordinates.
19    pub bounds: Bounds<Pixels>,
20    pub corner_radius: Pixels,
21    pub clear: bool,
22    pub tint: Option<Hsla>,
23    /// Close when the user transfers keyboard focus to another window.
24    pub dismiss_on_deactivate: bool,
25    /// Give the panel keyboard focus. This does not activate the application.
26    /// Leave false for programmatic preparation; set true for a user action.
27    pub focus: bool,
28}
29
30impl GlassPanelOptions {
31    pub fn new(bounds: Bounds<Pixels>) -> Self {
32        Self {
33            bounds,
34            corner_radius: px(14.),
35            clear: false,
36            tint: None,
37            dismiss_on_deactivate: true,
38            focus: false,
39        }
40    }
41
42    fn checked(mut self) -> anyhow::Result<Self> {
43        check_bounds(self.bounds)?;
44        self.corner_radius =
45            super::native_corner_radius(self.corner_radius.into(), px(16.), self.bounds.size);
46        Ok(self)
47    }
48}
49
50fn check_bounds(bounds: Bounds<Pixels>) -> anyhow::Result<()> {
51    let [x, y, width, height] = [
52        bounds.origin.x,
53        bounds.origin.y,
54        bounds.size.width,
55        bounds.size.height,
56    ]
57    .map(f32::from);
58    anyhow::ensure!(
59        [x, y, width, height].iter().all(|value| value.is_finite()) && width >= 1. && height >= 1.,
60        "Glass popup bounds must be finite and at least one logical point wide and high"
61    );
62    Ok(())
63}
64
65/// Owns the native contentView installation and child-window attachment.
66/// Keep it in the popup's root entity. Dropping it restores the preceding
67/// hierarchy and removes the child attachment without changing GPUI delegates.
68pub struct NativeGlassPopup {
69    #[cfg(target_os = "macos")]
70    inner: super::macos::GlassPopup,
71}
72
73impl NativeGlassPopup {
74    /// Move or resize using the same owner's local coordinates.
75    pub fn reposition(&self, bounds: Bounds<Pixels>, owner: &Window) -> anyhow::Result<()> {
76        check_bounds(bounds)?;
77        #[cfg(target_os = "macos")]
78        return self.inner.reposition(bounds, owner);
79        #[cfg(not(target_os = "macos"))]
80        {
81            let _ = owner;
82            anyhow::bail!("Native glass popups require macOS")
83        }
84    }
85}
86
87/// Wrap a GPUI-created popup's own renderer in real system glass.
88///
89/// Both windows must be AppKit windows and the foreground must be a separate
90/// `WindowKind::PopUp` window with a transparent background. This does not
91/// move a renderer between windows or replace its input/delegate ownership.
92/// Headless windows, older systems and reduced transparency return an error;
93/// callers retain their ordinary opaque GPUI presentation in those cases.
94pub fn install_glass_popup(
95    options: &GlassPanelOptions,
96    owner: &Window,
97    popup: &Window,
98    cx: &App,
99) -> anyhow::Result<NativeGlassPopup> {
100    let options = options.clone().checked()?;
101    anyhow::ensure!(
102        !cx.macos().reduce_transparency,
103        "Reduced transparency uses the opaque GPUI popup fallback"
104    );
105    #[cfg(target_os = "macos")]
106    {
107        Ok(NativeGlassPopup {
108            inner: super::macos::GlassPopup::new(
109                super::macos::GlassPopupOwner::new(owner)?,
110                popup,
111                &options,
112            )?,
113        })
114    }
115    #[cfg(not(target_os = "macos"))]
116    {
117        let _ = (options, owner, popup);
118        anyhow::bail!("Native glass popups require macOS")
119    }
120}
121
122type Dismiss = Rc<dyn Fn(&mut Window, &mut App)>;
123
124/// A GPUI root wrapping retained application content, never frame-local
125/// `AnyElement` values. Its own window draws the foreground over native glass.
126pub struct GlassPanel<V: Render + 'static> {
127    content: Entity<V>,
128    owner: AnyWindowHandle,
129    previous_focus: Option<FocusHandle>,
130    focus: FocusHandle,
131    options: GlassPanelOptions,
132    dismissed: Rc<Cell<bool>>,
133    on_dismiss: Dismiss,
134    material: Option<NativeGlassPopup>,
135    _subscriptions: Vec<Subscription>,
136    was_active: bool,
137}
138
139impl<V: Render + 'static> GlassPanel<V> {
140    fn finish(&self, cx: &mut App) {
141        if self.dismissed.replace(true) {
142            return;
143        }
144        let owner = self.owner;
145        let previous = self.previous_focus.clone();
146        let took_focus = self.options.focus || self.was_active;
147        let on_dismiss = self.on_dismiss.clone();
148        // The owner can still be borrowed by the caller that dismissed us.
149        // Defer restoration until both window updates have returned.
150        cx.defer(move |cx| {
151            let _ = owner.update(cx, |_, window, cx| {
152                let current = window.focused(cx);
153                // Hover panels must not steal focus, and a later owner Tab,
154                // click or blur takes precedence over stale popup restoration.
155                // Preserve that choice across adapter dismissal callbacks too.
156                let preserve_current = !took_focus || current != previous;
157                if !preserve_current && let Some(previous) = &previous {
158                    previous.focus(window, cx);
159                }
160                on_dismiss(window, cx);
161                if preserve_current {
162                    if let Some(current) = current {
163                        current.focus(window, cx);
164                    } else {
165                        window.blur(cx);
166                    }
167                }
168                window.refresh();
169            });
170        });
171    }
172}
173
174impl<V: Render + 'static> Render for GlassPanel<V> {
175    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
176        let native = self.material.is_some() && !cx.macos().reduce_transparency;
177        let mut fill = cx.macos().glass_fill();
178        fill.a = 1.;
179        let border = cx.macos().glass_border();
180        let foreground = cx.macos().foreground;
181        let font = cx.macos().font.clone();
182        let mut pane = div()
183            .id("native-glass-panel")
184            .size_full()
185            .rounded(self.options.corner_radius)
186            .text_size(px(13.))
187            .line_height(gpui_kit::relative(1.2))
188            .font_family(font)
189            .text_color(foreground)
190            .role(gpui_kit::accesskit::Role::Dialog)
191            .tab_group()
192            .track_focus(&self.focus)
193            .key_context("NativeGlassPanel")
194            .on_key_down(
195                cx.listener(|_, event: &gpui_kit::KeyDownEvent, window, cx| {
196                    if event.keystroke.key == "escape" {
197                        cx.stop_propagation();
198                        window.remove_window();
199                    }
200                }),
201            )
202            .child(self.content.clone());
203        if !native {
204            pane = pane.bg(fill).border_1().border_color(border);
205        }
206        pane
207    }
208}
209
210/// Keep this handle while a glass popup is open. Content remains an ordinary
211/// retained GPUI entity and can be updated normally by the application.
212pub struct GlassPanelHandle<V: Render + 'static> {
213    window: WindowHandle<gpui_kit::base::Root>,
214    panel: WeakEntity<GlassPanel<V>>,
215    content: Entity<V>,
216    dismissed: Rc<Cell<bool>>,
217    native: bool,
218    async_cx: AsyncApp,
219}
220
221impl<V: Render + 'static> Drop for GlassPanelHandle<V> {
222    fn drop(&mut self) {
223        if !self.dismissed.get() {
224            let window = self.window;
225            // Dropping a handle may occur inside another window update. A
226            // deferred main-thread task avoids reentering GPUI's App borrow.
227            self.async_cx
228                .spawn(async move |cx| {
229                    let _ = window.update(cx, |_, window, _| window.remove_window());
230                })
231                .detach();
232        }
233    }
234}
235
236impl<V: Render + 'static> GlassPanelHandle<V> {
237    pub fn content(&self) -> Entity<V> {
238        self.content.clone()
239    }
240
241    pub fn window_handle(&self) -> AnyWindowHandle {
242        self.window.into()
243    }
244
245    pub fn is_native(&self) -> bool {
246        self.native
247    }
248
249    pub fn is_open(&self) -> bool {
250        !self.dismissed.get()
251    }
252
253    /// Transfer keyboard focus without bringing the application forward.
254    pub fn focus(&self, cx: &mut App) -> anyhow::Result<()> {
255        self.window.update(cx, |_, window, cx| {
256            window.activate_window();
257            self.panel
258                .update(cx, |panel, cx| panel.focus.focus(window, cx))
259        })?
260    }
261
262    pub fn dismiss(&self, cx: &mut App) -> anyhow::Result<()> {
263        self.window
264            .update(cx, |_, window, _| window.remove_window())
265    }
266
267    pub fn reposition(
268        &self,
269        bounds: Bounds<Pixels>,
270        owner: &Window,
271        cx: &mut App,
272    ) -> anyhow::Result<()> {
273        check_bounds(bounds)?;
274        self.window.update(cx, |_, window, cx| {
275            self.panel.update(cx, |panel, _| {
276                if let Some(material) = panel.material.as_ref() {
277                    material.reposition(bounds, owner)?;
278                } else {
279                    anyhow::ensure!(
280                        bounds.origin == panel.options.bounds.origin,
281                        "This platform cannot reposition a fallback popup window"
282                    );
283                    window.resize(bounds.size);
284                }
285                panel.options.bounds = bounds;
286                Ok(())
287            })
288        })??
289    }
290}
291
292/// Open a GPUI foreground panel over a system glass backdrop.
293///
294/// The root factory runs in the new panel's own window and element arena.
295/// Retain entities/state, rather than moving an `AnyElement` from another
296/// window or frame. Escape closes the panel; deactivation can close it too.
297/// Closing the owner removes its panel. Dismissal restores the owner's GPUI
298/// focus unless the owner has since changed it, and invokes `on_dismiss` once.
299/// Nonfocusing panels preserve the owner's focus. Unsupported systems and accessibility
300/// preferences use the same interactive content with an opaque GPUI surface.
301pub fn open_glass_panel<V: Render + 'static>(
302    options: GlassPanelOptions,
303    owner: &mut Window,
304    cx: &mut App,
305    build: impl FnOnce(&mut Window, &mut App) -> Entity<V>,
306    on_dismiss: impl Fn(&mut Window, &mut App) + 'static,
307) -> anyhow::Result<GlassPanelHandle<V>> {
308    let options = options.checked()?;
309    let owner_handle = owner.window_handle();
310    let previous_focus = owner.focused(cx);
311    let bounds = Bounds::new(
312        owner.bounds().origin + options.bounds.origin,
313        options.bounds.size,
314    );
315    #[cfg(target_os = "macos")]
316    let native_owner = if cx.macos().reduce_transparency {
317        None
318    } else {
319        super::macos::GlassPopupOwner::new(owner).ok()
320    };
321    let dismissed = Rc::new(Cell::new(false));
322    let on_dismiss: Dismiss = Rc::new(on_dismiss);
323    let mut content = None;
324    let mut panel_entity = None;
325    let panel_options = options.clone();
326    let panel_dismissed = dismissed.clone();
327    let window = cx.open_window(
328        WindowOptions {
329            window_bounds: Some(WindowBounds::Windowed(bounds)),
330            titlebar: None,
331            focus: options.focus,
332            kind: WindowKind::PopUp,
333            is_movable: false,
334            is_resizable: false,
335            is_minimizable: false,
336            window_background: WindowBackgroundAppearance::Transparent,
337            ..Default::default()
338        },
339        |window, cx| {
340            let body = build(window, cx);
341            content = Some(body.clone());
342            let panel = cx.new(|cx| {
343                let focus = cx.focus_handle();
344                if panel_options.focus {
345                    focus.focus(window, cx);
346                }
347                let panel_handle = window.window_handle();
348                let owner_closed = cx.on_window_closed(move |cx, id| {
349                    if id == owner_handle.window_id() {
350                        let _ = panel_handle.update(cx, |_, window, _| window.remove_window());
351                    }
352                });
353                let activation =
354                    cx.observe_window_activation(window, |panel: &mut GlassPanel<V>, window, _| {
355                        let active = window.is_window_active();
356                        if panel.options.dismiss_on_deactivate && panel.was_active && !active {
357                            window.remove_window();
358                        }
359                        panel.was_active |= active;
360                    });
361                cx.on_release(|panel: &mut GlassPanel<V>, cx| panel.finish(cx))
362                    .detach();
363                GlassPanel {
364                    content: body,
365                    owner: owner_handle,
366                    previous_focus,
367                    focus,
368                    options: panel_options,
369                    dismissed: panel_dismissed,
370                    on_dismiss,
371                    material: None,
372                    _subscriptions: vec![owner_closed, activation],
373                    was_active: window.is_window_active(),
374                }
375            });
376            panel_entity = Some(panel.downgrade());
377            cx.new(|cx| {
378                gpui_kit::base::Root::new(panel, window, cx).bg(gpui_kit::transparent_black())
379            })
380        },
381    )?;
382    let panel_entity = panel_entity.expect("GPUI constructed the retained panel view");
383    let native = window.update(cx, |_, window, cx| {
384        panel_entity.update(cx, |panel, cx| {
385            #[cfg(target_os = "macos")]
386            if let Some(owner) = native_owner {
387                panel.material = super::macos::GlassPopup::new(owner, window, &options)
388                    .ok()
389                    .map(|inner| NativeGlassPopup { inner });
390            }
391            cx.notify();
392            window.refresh();
393            panel.material.is_some()
394        })
395    })??;
396    Ok(GlassPanelHandle {
397        window,
398        panel: panel_entity,
399        content: content.expect("GPUI invokes the root factory before returning its window"),
400        dismissed,
401        native,
402        async_cx: cx.to_async(),
403    })
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use crate::{ButtonVariant, Theme, button};
410    use gpui_kit::{KeyDownEvent, Keystroke, TestAppContext, VisualTestContext, point, size};
411
412    struct Owner {
413        focus: FocusHandle,
414    }
415
416    impl Render for Owner {
417        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
418            button("panel-trigger", "Open", ButtonVariant::Secondary, cx).track_focus(&self.focus)
419        }
420    }
421
422    struct FocusOwner {
423        first: FocusHandle,
424        second: FocusHandle,
425    }
426
427    impl Render for FocusOwner {
428        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
429            div()
430                .tab_group()
431                .child(
432                    button("first", "First", ButtonVariant::Secondary, cx).track_focus(&self.first),
433                )
434                .child(
435                    button("second", "Second", ButtonVariant::Secondary, cx)
436                        .track_focus(&self.second),
437                )
438        }
439    }
440
441    struct Content {
442        commands: usize,
443    }
444
445    impl Render for Content {
446        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
447            let _ = gpui_kit::base::Root::read(window, cx);
448            div().p(px(12.)).child(
449                button("panel-command", "Continue", ButtonVariant::Secondary, cx)
450                    .debug_selector(|| "panel-command".into())
451                    .on_click(cx.listener(|content, _, _, cx| {
452                        content.commands += 1;
453                        cx.notify();
454                    })),
455            )
456        }
457    }
458
459    fn options() -> GlassPanelOptions {
460        let mut options = GlassPanelOptions::new(Bounds::new(
461            point(px(100.), px(80.)),
462            size(px(240.), px(120.)),
463        ));
464        options.focus = true;
465        options
466    }
467
468    #[gpui_kit::test]
469    fn glass_panel_fallback_keeps_content_interactive_and_escape_restores_owner_focus(
470        cx: &mut TestAppContext,
471    ) {
472        cx.update(|cx| {
473            crate::init(cx);
474            Theme::light().with_reduce_transparency(true).apply(cx);
475        });
476        let (owner, owner_cx) = cx.add_window_view(|window, cx| {
477            let focus = cx.focus_handle();
478            focus.focus(window, cx);
479            Owner { focus }
480        });
481        let dismissed = Rc::new(Cell::new(0));
482        let observed = dismissed.clone();
483        let panel = owner_cx.update(|owner, cx| {
484            open_glass_panel(
485                options(),
486                owner,
487                cx,
488                |_, cx| cx.new(|_| Content { commands: 0 }),
489                move |_, _| observed.set(observed.get() + 1),
490            )
491            .unwrap()
492        });
493        assert!(!panel.is_native());
494        assert!(panel.is_open());
495        let mut panel_cx = VisualTestContext::from_window(panel.window_handle(), owner_cx);
496        panel_cx.update(|window, cx| {
497            window.draw(cx).clear(cx);
498            let surface = window
499                .painted_quads()
500                .into_iter()
501                .find(|quad| {
502                    quad.bounds.size == size(px(240.), px(120.)).scale(window.scale_factor())
503                })
504                .expect("Fallback paints its complete popup surface");
505            assert_eq!(surface.background.as_solid().unwrap().a, 1.);
506        });
507        let command = panel_cx.debug_bounds("panel-command").unwrap().center();
508        panel_cx.simulate_click(command, Default::default());
509        panel_cx.update(|window, cx| {
510            window.draw(cx).clear(cx);
511            assert_eq!(panel.content().read(cx).commands, 1);
512        });
513        panel_cx.simulate_event(KeyDownEvent {
514            keystroke: Keystroke::parse("escape").unwrap(),
515            is_held: false,
516            prefer_character_input: false,
517        });
518        assert!(!panel.is_open());
519        assert_eq!(dismissed.get(), 1);
520        owner_cx.update(|window, cx| assert!(owner.read(cx).focus.is_focused(window)));
521        owner_cx.update(|_, cx| assert!(panel.dismiss(cx).is_err()));
522        assert_eq!(dismissed.get(), 1, "Dismissal is reported exactly once");
523    }
524
525    #[gpui_kit::test]
526    fn glass_panel_closes_with_owner_and_rejects_invalid_geometry(cx: &mut TestAppContext) {
527        cx.update(crate::init);
528        let (_, owner_cx) = cx.add_window_view(|_, cx| Owner {
529            focus: cx.focus_handle(),
530        });
531        owner_cx.update(|owner, cx| {
532            let window_count = cx.windows().len();
533            let mut invalid = options();
534            invalid.bounds.size.width = px(f32::NAN);
535            assert!(
536                open_glass_panel(
537                    invalid,
538                    owner,
539                    cx,
540                    |_, cx| cx.new(|_| Content { commands: 0 }),
541                    |_, _| {},
542                )
543                .is_err()
544            );
545            assert_eq!(cx.windows().len(), window_count);
546        });
547        let panel = owner_cx.update(|owner, cx| {
548            open_glass_panel(
549                options(),
550                owner,
551                cx,
552                |_, cx| cx.new(|_| Content { commands: 0 }),
553                |_, _| {},
554            )
555            .unwrap()
556        });
557        owner_cx.update(|window, _| window.remove_window());
558        owner_cx.run_until_parked();
559        assert!(!panel.is_open(), "Owner closure releases its popup root");
560    }
561
562    #[gpui_kit::test]
563    fn glass_panel_deactivation_and_handle_drop_close_once(cx: &mut TestAppContext) {
564        cx.update(crate::init);
565        let (_, owner_cx) = cx.add_window_view(|_, cx| Owner {
566            focus: cx.focus_handle(),
567        });
568        let dismissed = Rc::new(Cell::new(0));
569        let observed = dismissed.clone();
570        let panel = owner_cx.update(|owner, cx| {
571            open_glass_panel(
572                options(),
573                owner,
574                cx,
575                |_, cx| cx.new(|_| Content { commands: 0 }),
576                move |_, _| observed.set(observed.get() + 1),
577            )
578            .unwrap()
579        });
580        let mut panel_cx = VisualTestContext::from_window(panel.window_handle(), owner_cx);
581        owner_cx.update(|_, cx| panel.focus(cx).unwrap());
582        owner_cx.run_until_parked();
583        panel_cx.deactivate_window();
584        assert!(!panel.is_open());
585        assert_eq!(dismissed.get(), 1);
586        drop(panel);
587        owner_cx.run_until_parked();
588        assert_eq!(dismissed.get(), 1);
589        let observed = dismissed.clone();
590        let panel = owner_cx.update(|owner, cx| {
591            let mut options = options();
592            options.focus = false;
593            open_glass_panel(
594                options,
595                owner,
596                cx,
597                |_, cx| cx.new(|_| Content { commands: 0 }),
598                move |_, _| observed.set(observed.get() + 1),
599            )
600            .unwrap()
601        });
602        assert!(panel.is_open());
603        drop(panel);
604        owner_cx.run_until_parked();
605        assert_eq!(dismissed.get(), 2);
606        assert_eq!(
607            owner_cx.windows().len(),
608            1,
609            "No unreachable panel window remains"
610        );
611    }
612
613    #[gpui_kit::test]
614    fn glass_panel_dismissal_preserves_later_owner_tab_and_intentional_blur(
615        cx: &mut TestAppContext,
616    ) {
617        cx.update(crate::init);
618        let (owner, owner_cx) = cx.add_window_view(|window, cx| {
619            let first = cx.focus_handle();
620            let second = cx.focus_handle();
621            first.focus(window, cx);
622            FocusOwner { first, second }
623        });
624        for (focus, blur) in [(false, false), (true, false), (true, true)] {
625            owner_cx.update(|window, cx| {
626                owner.read(cx).first.clone().focus(window, cx);
627                window.draw(cx).clear(cx);
628            });
629            let dismissed = Rc::new(Cell::new(0));
630            let observed = dismissed.clone();
631            let restore = owner.read_with(owner_cx, |owner, _| owner.first.clone());
632            let panel = owner_cx.update(|window, cx| {
633                let mut options = options();
634                options.focus = focus;
635                options.dismiss_on_deactivate = false;
636                open_glass_panel(
637                    options,
638                    window,
639                    cx,
640                    |_, cx| cx.new(|_| Content { commands: 0 }),
641                    move |window, cx| {
642                        // Existing adapters restore their trigger here. A newer
643                        // owner choice must survive that restoration too.
644                        restore.focus(window, cx);
645                        observed.set(observed.get() + 1);
646                    },
647                )
648                .unwrap()
649            });
650            owner_cx.update(|window, cx| {
651                if blur {
652                    window.blur(cx);
653                } else {
654                    window.focus_next(cx);
655                    assert!(owner.read(cx).second.is_focused(window));
656                }
657                panel.dismiss(cx).unwrap();
658            });
659            owner_cx.run_until_parked();
660            assert!(!panel.is_open());
661            assert_eq!(dismissed.get(), 1);
662            owner_cx.update(|window, cx| {
663                if blur {
664                    assert!(window.focused(cx).is_none());
665                } else {
666                    assert!(owner.read(cx).second.is_focused(window));
667                }
668            });
669        }
670    }
671}