1use 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#[derive(Clone, Debug)]
17pub struct GlassPanelOptions {
18 pub bounds: Bounds<Pixels>,
20 pub corner_radius: Pixels,
21 pub clear: bool,
22 pub tint: Option<Hsla>,
23 pub dismiss_on_deactivate: bool,
25 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
65pub struct NativeGlassPopup {
69 #[cfg(target_os = "macos")]
70 inner: super::macos::GlassPopup,
71}
72
73impl NativeGlassPopup {
74 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
87pub 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
124pub 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 cx.defer(move |cx| {
151 let _ = owner.update(cx, |_, window, cx| {
152 let current = window.focused(cx);
153 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
210pub 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 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 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
292pub 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 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}