From b0b357dd7d954e7fd8a038e4506f8f1d3299c8ec Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 10 Jun 2026 21:47:36 +0800 Subject: [PATCH] draft popover --- crates/story/src/gallery.rs | 1 + crates/story/src/stories/mod.rs | 2 + .../story/src/stories/native_popover_story.rs | 262 ++++++++++++ crates/ui/Cargo.toml | 8 +- crates/ui/src/lib.rs | 1 + crates/ui/src/native_popover/macos.rs | 398 ++++++++++++++++++ crates/ui/src/native_popover/mod.rs | 114 +++++ 7 files changed, 785 insertions(+), 1 deletion(-) create mode 100644 crates/story/src/stories/native_popover_story.rs create mode 100644 crates/ui/src/native_popover/macos.rs create mode 100644 crates/ui/src/native_popover/mod.rs diff --git a/crates/story/src/gallery.rs b/crates/story/src/gallery.rs index b97ce0121..6965fac5b 100644 --- a/crates/story/src/gallery.rs +++ b/crates/story/src/gallery.rs @@ -71,6 +71,7 @@ impl Gallery { StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), + StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), diff --git a/crates/story/src/stories/mod.rs b/crates/story/src/stories/mod.rs index 3f2c5a013..6f404bb1c 100644 --- a/crates/story/src/stories/mod.rs +++ b/crates/story/src/stories/mod.rs @@ -32,6 +32,7 @@ mod label_story; mod list_story; mod menu_story; mod native_menu_story; +mod native_popover_story; mod notification_story; mod number_input_story; mod otp_input_story; @@ -95,6 +96,7 @@ pub use label_story::LabelStory; pub use list_story::ListStory; pub use menu_story::MenuStory; pub use native_menu_story::NativeMenuStory; +pub use native_popover_story::NativePopoverStory; pub use notification_story::NotificationStory; pub use number_input_story::NumberInputStory; pub use otp_input_story::OtpInputStory; diff --git a/crates/story/src/stories/native_popover_story.rs b/crates/story/src/stories/native_popover_story.rs new file mode 100644 index 000000000..b52236d77 --- /dev/null +++ b/crates/story/src/stories/native_popover_story.rs @@ -0,0 +1,262 @@ +use std::cell::Cell; +use std::rc::Rc; + +use gpui::{ + Action, App, AppContext as _, Bounds, ClickEvent, Context, Entity, FocusHandle, Focusable, + InteractiveElement, IntoElement, ParentElement as _, Pixels, Render, SharedString, Styled as _, + Window, div, img, px, size, +}; +use gpui_component::{ + ActiveTheme as _, ElementExt as _, IconName, StyledExt as _, + avatar::Avatar, + button::*, + h_flex, + input::{Input, InputState}, + native_popover::{self, NativePopover}, + switch::Switch, + v_flex, +}; +use serde::Deserialize; + +use crate::section; + +/// Dispatched by every popover button; payload is the button label so the story +/// can report which one was clicked. +#[derive(Action, Clone, PartialEq, Deserialize)] +#[action(namespace = native_popover_story, no_json)] +struct PopoverClick(SharedString); + +const CONTEXT: &str = "NativePopoverStory"; + +/// A button dispatching `PopoverClick(label)`. +fn click(label: &str) -> Box { + Box::new(PopoverClick(label.to_string().into())) +} + +/// SPIKE content: arbitrary GPUI rendered inside a native `NSPopover` (via +/// `native_popover::show_view` reparenting). The counter button proves both +/// rendering and interaction work after reparenting. +struct SpikeContent { + count: usize, + enabled: bool, + input: Entity, +} + +impl SpikeContent { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let input = + cx.new(|cx| InputState::new(window, cx).placeholder("Type inside a native popover…")); + Self { + count: 0, + enabled: true, + input, + } + } +} + +impl Render for SpikeContent { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .w_full() + .p_4() + .gap_4() + // Header: avatar + title / subtitle. + .child( + h_flex() + .gap_3() + .items_center() + .child(Avatar::new().name("GP")) + .child( + v_flex() + .gap_1() + .child(div().font_bold().child("Arbitrary GPUI content")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Real GPUI widgets in a native NSPopover"), + ), + ), + ) + // A real (bitmap) image, centered. + .child( + h_flex().justify_center().child( + img("https://avatars.githubusercontent.com/u/5518?v=4") + .size_20() + .rounded_lg(), + ), + ) + // A row of icons. + .child( + h_flex() + .gap_4() + .justify_center() + .text_color(cx.theme().muted_foreground) + .child(IconName::Star) + .child(IconName::Heart) + .child(IconName::Bell) + .child(IconName::Calendar) + .child(IconName::Github), + ) + // A text input — verifies keyboard focus works inside the popover. + .child(Input::new(&self.input)) + // An interactive switch. + .child( + Switch::new("spike-switch") + .checked(self.enabled) + .label("Enable feature") + .on_click(cx.listener(|this, checked: &bool, _, cx| { + this.enabled = *checked; + cx.notify(); + })), + ) + // Buttons with live state. + .child( + h_flex() + .gap_2() + .child( + Button::new("spike-inc") + .primary() + .label(SharedString::from(format!("Count: {}", self.count))) + .on_click(cx.listener(|this, _, _, cx| { + this.count += 1; + cx.notify(); + })), + ) + .child( + Button::new("spike-reset") + .outline() + .label("Reset") + .on_click(cx.listener(|this, _, _, cx| { + this.count = 0; + cx.notify(); + })), + ), + ) + } +} + +pub struct NativePopoverStory { + focus_handle: FocusHandle, + message: String, + /// Persisted across popover open/close so its state (the counter) survives. + spike_content: Entity, +} + +impl super::Story for NativePopoverStory { + fn title() -> &'static str { + "NativePopover" + } + + fn description() -> &'static str { + "A popover rendered natively by the OS (macOS `NSPopover`): system arrow, vibrant \ + backdrop, show/dismiss animation, and transient behavior (click outside to dismiss). \ + It can extend beyond the window. Content is native (a title and buttons), so it carries \ + GPUI actions rather than arbitrary GPUI views." + } + + fn new_view(window: &mut Window, cx: &mut App) -> Entity { + Self::view(window, cx) + } +} + +impl NativePopoverStory { + pub fn view(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Self::new(window, cx)) + } + + fn new(window: &mut Window, cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + message: String::new(), + spike_content: cx.new(|cx| SpikeContent::new(window, cx)), + } + } + + fn on_click(&mut self, click: &PopoverClick, _: &mut Window, cx: &mut Context) { + self.message = format!("Clicked: {}", click.0); + cx.notify(); + } + + /// A trigger button that captures its own bounds (so the popover can anchor + /// to it) and opens a native popover below it on click. + fn trigger(&self, id: &'static str, label: &'static str) -> impl IntoElement { + let bounds: Rc>> = Rc::new(Cell::new(Bounds::default())); + let writer = bounds.clone(); + let focus_handle = self.focus_handle.clone(); + + div().on_prepaint(move |b, _, _| writer.set(b)).child( + Button::new(id) + .outline() + .label(label) + .on_click(move |_: &ClickEvent, window, cx| { + // Focus the story so the dispatched action reaches `on_click`. + focus_handle.focus(window, cx); + NativePopover::new() + .title("Quick actions") + .button("Duplicate", click("Duplicate")) + .button("Rename", click("Rename")) + .button("Delete", click("Delete")) + .show(bounds.get(), window, cx); + }), + ) + } + + /// SPIKE trigger: open a native popover whose content is arbitrary GPUI. + /// Reuses the persisted `spike_content` entity so its counter survives + /// across open/close. + fn spike_trigger(&self) -> impl IntoElement { + let bounds: Rc>> = Rc::new(Cell::new(Bounds::default())); + let writer = bounds.clone(); + let content = self.spike_content.clone(); + + div().on_prepaint(move |b, _, _| writer.set(b)).child( + Button::new("spike") + .outline() + .label("Open GPUI-content popover (spike)") + .on_click(move |_: &ClickEvent, window, cx| { + let content = content.clone(); + native_popover::show_view( + bounds.get(), + size(px(320.), px(320.)), + window, + cx, + move |_, _| content, + ); + }), + ) + } +} + +impl Focusable for NativePopoverStory { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for NativePopoverStory { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let result = if self.message.is_empty() { + "Click a trigger to open a native popover; click outside to dismiss.".to_string() + } else { + self.message.clone() + }; + + v_flex() + .track_focus(&self.focus_handle) + .key_context(CONTEXT) + .on_action(cx.listener(Self::on_click)) + .size_full() + .gap_6() + .child( + section("SPIKE: arbitrary GPUI content (reparented into NSPopover)") + .child(self.spike_trigger()), + ) + .child(section("Click to open").child(self.trigger("open-1", "Open Popover"))) + .child( + section("Near the window edge (proves it overflows the window)") + .child(self.trigger("open-2", "Open at edge")), + ) + .child(section("Result").child(SharedString::from(result))) + } +} diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index bd45543e1..52496f9f3 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -185,7 +185,8 @@ tree-sitter-zig = { version = "1.1.2", optional = true } [target.'cfg(target_os = "macos")'.dependencies] core-text = "=21.0.0" -# Native menu (NativeMenu) — drives AppKit NSMenu via objc2. +# Native menu / popover (NativeMenu, NativePopover) — drive AppKit NSMenu / +# NSPopover via objc2. raw-window-handle = { workspace = true } objc2 = "0.6" objc2-app-kit = { version = "0.3", features = [ @@ -194,6 +195,11 @@ objc2-app-kit = { version = "0.3", features = [ "NSView", "NSResponder", "NSEvent", + "NSPopover", + "NSViewController", + "NSButton", + "NSControl", + "NSTextField", ] } objc2-foundation = { version = "0.3", features = ["NSString", "NSGeometry"] } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index a6fe7f6e9..10bc0c9c5 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -49,6 +49,7 @@ pub mod link; pub mod list; pub mod menu; pub mod native_menu; +pub mod native_popover; pub mod notification; pub mod pagination; pub mod plot; diff --git a/crates/ui/src/native_popover/macos.rs b/crates/ui/src/native_popover/macos.rs new file mode 100644 index 000000000..7e83e6899 --- /dev/null +++ b/crates/ui/src/native_popover/macos.rs @@ -0,0 +1,398 @@ +//! macOS native popover implementation (AppKit `NSPopover` via objc2). +//! +//! Unlike [`crate::native_menu`], whose `NSMenu` runs a blocking tracking loop, +//! `NSPopover` is *modeless*: `show` returns immediately and the popover stays +//! up until a button is clicked or the user clicks outside (transient +//! behavior). We therefore: +//! +//! - keep the live `NSPopover` (and its content objects) in a thread-local so +//! they outlive `show`; +//! - deliver button clicks back to GPUI over a channel (the AppKit target +//! can't reach `cx` directly); +//! - poll from a foreground task for either a click or an external dismissal, +//! then dispatch the action and tear the popover down. + +use std::cell::RefCell; +use std::time::Duration; + +use gpui::{ + Action, App, AppContext as _, Bounds, Entity, Pixels, Render, SharedString, Size, Window, + WindowBackgroundAppearance, WindowBounds, WindowHandle, WindowKind, WindowOptions, +}; +use objc2::rc::Retained; +use objc2::runtime::{AnyObject, NSObject}; +use objc2::{AnyThread, DefinedClass, MainThreadMarker, define_class, msg_send, sel}; +use objc2_app_kit::{NSButton, NSPopover, NSPopoverBehavior, NSTextField, NSView, NSViewController}; +use objc2_foundation::{NSPoint, NSRect, NSRectEdge, NSSize, NSString}; +use raw_window_handle::{HasWindowHandle, RawWindowHandle}; +use smol::Timer; +use smol::channel::{Sender, unbounded}; + +use super::NativePopoverButton; + +/// Content layout constants (logical points, AppKit coordinates). +const WIDTH: f64 = 260.0; +const PAD: f64 = 14.0; +const GAP: f64 = 8.0; +const TITLE_H: f64 = 18.0; +const BUTTON_H: f64 = 28.0; +/// Poll interval while the popover is open. +const POLL: Duration = Duration::from_millis(30); + +/// Ivars for [`PopoverTarget`]: the channel a click's tag is sent over. +struct PopoverTargetIvars { + tx: Sender, +} + +define_class!( + // A throwaway Objective-C object that receives button clicks and forwards + // the clicked button's tag (its index) over a channel. + #[unsafe(super(NSObject))] + #[name = "GPUIComponentNativePopoverTarget"] + #[ivars = PopoverTargetIvars] + struct PopoverTarget; + + impl PopoverTarget { + #[unsafe(method(buttonClicked:))] + fn button_clicked(&self, sender: &AnyObject) { + let tag: isize = unsafe { msg_send![sender, tag] }; + let _ = self.ivars().tx.try_send(tag as usize); + } + } +); + +impl PopoverTarget { + fn new(tx: Sender) -> Retained { + let this = Self::alloc().set_ivars(PopoverTargetIvars { tx }); + unsafe { msg_send![super(this), init] } + } +} + +/// The live popover plus the objects that must outlive `show`. +struct ActivePopover { + popover: Retained, + /// Present only for the native-content path (button click target). + _target: Option>, + _controller: Retained, +} + +thread_local! { + static ACTIVE: RefCell> = const { RefCell::new(None) }; +} + +/// Close and release the active popover, if any. Main thread only. +fn close_active() { + if let Some(active) = ACTIVE.with(|a| a.borrow_mut().take()) { + if active.popover.isShown() { + unsafe { active.popover.performClose(None) }; + } + } +} + +/// Whether the active popover is still on screen. Main thread only. +fn active_is_shown() -> bool { + ACTIVE.with(|a| { + a.borrow() + .as_ref() + .map(|active| active.popover.isShown()) + .unwrap_or(false) + }) +} + +pub(super) fn show( + title: Option, + buttons: Vec, + anchor: Bounds, + window: &mut Window, + cx: &mut App, +) { + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + let Some(view_ptr) = ns_view_ptr(window) else { + return; + }; + // Inherent `Window::window_handle` (GPUI's `AnyWindowHandle`), not the + // `raw_window_handle` trait method in scope below. + let handle = Window::window_handle(window); + + // Replace any previous popover. + close_active(); + + let (tx, rx) = unbounded::(); + let target = PopoverTarget::new(tx); + + // SAFETY: `view_ptr` came from the window's AppKit handle, and the window + // outlives this synchronous build. + let view: &NSView = unsafe { &*(view_ptr as *const NSView) }; + + // --- Build the content view (AppKit bottom-left coordinates: larger y is + // higher on screen) --- + let n = buttons.len(); + let title_block = if title.is_some() { TITLE_H + GAP } else { 0.0 }; + let buttons_block = if n > 0 { + n as f64 * BUTTON_H + (n as f64 - 1.0) * GAP + } else { + 0.0 + }; + let content_h = PAD + title_block + buttons_block + PAD; + let inner_w = WIDTH - 2.0 * PAD; + + let content = NSView::new(mtm); + content.setFrameSize(NSSize::new(WIDTH, content_h)); + + if let Some(title) = &title { + let label = NSTextField::labelWithString(&NSString::from_str(title), mtm); + label.setFrameSize(NSSize::new(inner_w, TITLE_H)); + label.setFrameOrigin(NSPoint::new(PAD, content_h - PAD - TITLE_H)); + content.addSubview(&label); + } + + // Buttons stacked downward from just under the title. + let buttons_top = content_h - PAD - title_block; + let mut actions: Vec> = Vec::with_capacity(n); + for (i, button) in buttons.into_iter().enumerate() { + let ns_button = unsafe { + NSButton::buttonWithTitle_target_action( + &NSString::from_str(&button.label), + Some(&*target as &AnyObject), + Some(sel!(buttonClicked:)), + mtm, + ) + }; + ns_button.setTag(i as isize); + ns_button.setFrameSize(NSSize::new(inner_w, BUTTON_H)); + let y = buttons_top - (i as f64 + 1.0) * BUTTON_H - i as f64 * GAP; + ns_button.setFrameOrigin(NSPoint::new(PAD, y)); + content.addSubview(&ns_button); + actions.push(button.action); + } + + // --- Controller + popover --- + let controller = NSViewController::new(mtm); + controller.setView(&content); + + let popover = NSPopover::new(mtm); + popover.setBehavior(NSPopoverBehavior::Transient); + popover.setAnimates(true); + popover.setContentViewController(Some(&controller)); + popover.setContentSize(NSSize::new(WIDTH, content_h)); + + // Positioning rect: window coordinates (top-left origin, GPUI) -> view + // coordinates (bottom-left origin, non-flipped GPUI content view). + let view_h = view.bounds().size.height; + let ax = f32::from(anchor.origin.x) as f64; + let ay = f32::from(anchor.origin.y) as f64; + let aw = f32::from(anchor.size.width) as f64; + let ah = f32::from(anchor.size.height) as f64; + let rect = NSRect::new(NSPoint::new(ax, view_h - (ay + ah)), NSSize::new(aw, ah)); + + // Prefer anchoring to the bottom edge so the popover appears below the + // trigger (arrow pointing up at it). + popover.showRelativeToRect_ofView_preferredEdge(rect, view, NSRectEdge::MinY); + + ACTIVE.with(|a| { + *a.borrow_mut() = Some(ActivePopover { + popover, + _target: Some(target), + _controller: controller, + }); + }); + + // The whole task runs on the foreground (main) thread, so the thread-local + // and AppKit calls below are main-thread safe. + cx.spawn(async move |cx| { + loop { + if let Ok(tag) = rx.try_recv() { + let _ = cx.update(|app| { + let _ = handle.update(app, |_, window, app| { + if let Some(action) = actions.get(tag) { + window.dispatch_action(action.boxed_clone(), app); + } + window.refresh(); + }); + }); + close_active(); + break; + } + + Timer::after(POLL).await; + + // Externally dismissed (clicked outside / transient close). + if !active_is_shown() { + close_active(); + break; + } + } + }) + .detach(); +} + +/// SPIKE: show arbitrary GPUI content inside a native `NSPopover`. +/// +/// Strategy: open a hidden GPUI `PopUp` window that renders `build(...)`, then +/// *reparent* its AppKit `NSView` (which carries the Metal layer and the input +/// responders) into the `NSPopover`'s content view controller. GPUI keeps +/// driving rendering/input through that view; the popover provides the native +/// shell. The source GPUI window is kept alive (it owns the GPUI window state) +/// and torn down when the popover closes. +pub(super) fn show_view( + anchor: Bounds, + size: Size, + window: &mut Window, + cx: &mut App, + build: impl FnOnce(&mut Window, &mut App) -> Entity + 'static, +) { + let Some(main_view_ptr) = ns_view_ptr(window) else { + return; + }; + + // Run open + reparent + show from a foreground task so we don't re-enter + // GPUI's window borrow while the caller's event is still dispatching + // (opening a window mid-borrow triggers "RefCell already borrowed"). + cx.spawn(async move |cx| { + let Some(child) = + cx.update(|app| open_reparent_show(main_view_ptr, anchor, size, app, build)) + else { + return; + }; + + // Poll for external (transient) dismissal, then tear everything down. + loop { + Timer::after(POLL).await; + if cx.update(|_| active_is_shown()) { + continue; + } + let _ = cx.update(|app| { + let _ = child.update(app, |_, w, _| w.remove_window()); + close_active(); + }); + break; + } + }) + .detach(); +} + +/// Open the source GPUI window, reparent its view into a fresh `NSPopover`, show +/// it, and return the source window handle (kept alive until the popover closes). +fn open_reparent_show( + main_view_ptr: usize, + anchor: Bounds, + size: Size, + cx: &mut App, + build: impl FnOnce(&mut Window, &mut App) -> Entity, +) -> Option> { + let mtm = MainThreadMarker::new()?; + close_active(); + + // Open a GPUI PopUp window rendering the arbitrary content. It is shown (so + // the display link runs) but its view is immediately reparented away. + let child = cx + .open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds { + origin: anchor.origin, + size, + })), + titlebar: None, + kind: WindowKind::PopUp, + focus: false, + show: true, + is_movable: false, + is_resizable: false, + is_minimizable: false, + window_background: WindowBackgroundAppearance::Transparent, + ..Default::default() + }, + // The child window's root must be a `Root` (like any GPUI Component + // window) — content such as `Input` calls `Root::read`/`update`. + |w, cx| { + let content = build(w, cx); + cx.new(|cx| crate::Root::new(content, w, cx)) + }, + ) + .ok()?; + + let child_view_ptr = match child.update(cx, |_, w, _| ns_view_ptr(w)) { + Ok(Some(ptr)) => ptr, + _ => { + let _ = child.update(cx, |_, w, _| w.remove_window()); + return None; + } + }; + + // SAFETY: both pointers come from live windows' AppKit handles. + let child_view: &NSView = unsafe { &*(child_view_ptr as *const NSView) }; + let main_view: &NSView = unsafe { &*(main_view_ptr as *const NSView) }; + + // Hide the source window visually but keep it "visible" to AppKit, so its + // CVDisplayLink keeps driving frames and its input/responder routing stays + // intact (ordering it out breaks event delivery entirely). + let child_window: *mut AnyObject = unsafe { msg_send![child_view, window] }; + if !child_window.is_null() { + unsafe { + let _: () = msg_send![child_window, setAlphaValue: 0.0f64]; + } + } + + child_view.removeFromSuperview(); + + let controller = NSViewController::new(mtm); + controller.setView(child_view); + + // Round the GPUI content layer to match the NSPopover's rounded shell — the + // square Metal layer would otherwise show hard corners over the popover's + // curves on a transparent/vibrant background. + let layer: *mut AnyObject = unsafe { msg_send![child_view, layer] }; + if !layer.is_null() { + unsafe { + let _: () = msg_send![layer, setCornerRadius: 10.0f64]; + let _: () = msg_send![layer, setMasksToBounds: true]; + } + } + + let popover = NSPopover::new(mtm); + popover.setBehavior(NSPopoverBehavior::Transient); + popover.setAnimates(true); + popover.setContentViewController(Some(&controller)); + popover.setContentSize(NSSize::new( + f32::from(size.width) as f64, + f32::from(size.height) as f64, + )); + + let view_h = main_view.bounds().size.height; + let ax = f32::from(anchor.origin.x) as f64; + let ay = f32::from(anchor.origin.y) as f64; + let aw = f32::from(anchor.size.width) as f64; + let ah = f32::from(anchor.size.height) as f64; + let rect = NSRect::new(NSPoint::new(ax, view_h - (ay + ah)), NSSize::new(aw, ah)); + popover.showRelativeToRect_ofView_preferredEdge(rect, main_view, NSRectEdge::MinY); + + // Make the reparented GPUI view the first responder of the popover's window + // so keyboard events (e.g. typing into an `Input`) reach GPUI. + let popover_window: *mut AnyObject = unsafe { msg_send![child_view, window] }; + if !popover_window.is_null() { + unsafe { + let _: bool = msg_send![popover_window, makeFirstResponder: child_view]; + } + } + + ACTIVE.with(|a| { + *a.borrow_mut() = Some(ActivePopover { + popover, + _target: None, + _controller: controller, + }); + }); + + Some(child) +} + +/// Extract the AppKit `NSView` pointer from the window's raw handle. +fn ns_view_ptr(window: &Window) -> Option { + let handle = HasWindowHandle::window_handle(window).ok()?; + let RawWindowHandle::AppKit(handle) = handle.as_raw() else { + return None; + }; + Some(handle.ns_view.as_ptr() as usize) +} diff --git a/crates/ui/src/native_popover/mod.rs b/crates/ui/src/native_popover/mod.rs new file mode 100644 index 000000000..32f0a71a7 --- /dev/null +++ b/crates/ui/src/native_popover/mod.rs @@ -0,0 +1,114 @@ +//! A popover rendered natively by the operating system. +//! +//! Unlike [`crate::popover::Popover`], which is drawn by GPUI inside the window +//! (and clipped to it), [`NativePopover`] is a real OS popover. On macOS it is +//! an `NSPopover`: it has the system arrow, corner radius, vibrant (frosted) +//! background, show/dismiss animation, and transient behavior (clicking outside +//! dismisses it) — and it can extend beyond the window bounds. +//! +//! The trade-off versus [`crate::popover::Popover`]: because the content is +//! rendered by AppKit (not GPUI), it is limited to native controls. A +//! [`NativePopover`] is therefore described declaratively — a title and a set of +//! buttons, each carrying a GPUI [`Action`] dispatched via +//! [`Window::dispatch_action`] when clicked. +//! +//! ```ignore +//! use gpui_component::native_popover::NativePopover; +//! +//! NativePopover::new() +//! .title("Delete this item?") +//! .button("Delete", Box::new(Delete)) +//! .button("Cancel", Box::new(Cancel)) +//! .show(trigger_bounds, window, cx); +//! ``` +//! +//! Platform support: macOS (native `NSPopover`). Other platforms currently do +//! nothing; a GPUI [`crate::popover::Popover`] fallback is planned. + +use gpui::{Action, App, Bounds, Entity, Pixels, Render, SharedString, Size, Window}; + +#[cfg(target_os = "macos")] +mod macos; + +/// SPIKE (macOS only): show arbitrary GPUI content inside a native `NSPopover` +/// by reparenting a hidden GPUI window's view into the popover. Verifies whether +/// "native shell + arbitrary GPUI content" is viable. No-op off macOS. +/// +/// `anchor` is the trigger's window-relative bounds; `size` is the content size. +pub fn show_view( + anchor: Bounds, + size: Size, + window: &mut Window, + cx: &mut App, + build: impl FnOnce(&mut Window, &mut App) -> Entity + 'static, +) { + #[cfg(target_os = "macos")] + macos::show_view(anchor, size, window, cx, build); + + #[cfg(not(target_os = "macos"))] + { + let _ = (anchor, size, window, cx, build); + } +} + +/// A single actionable button in a [`NativePopover`]. +struct NativePopoverButton { + label: SharedString, + /// Action dispatched when the button is clicked. + action: Box, +} + +/// A popover rendered by the operating system. +/// +/// Build it with [`NativePopover::title`] / [`NativePopover::button`], then call +/// [`NativePopover::show`] anchored to a trigger's bounds. +#[derive(Default)] +pub struct NativePopover { + title: Option, + buttons: Vec, +} + +impl NativePopover { + /// Create an empty native popover. + pub fn new() -> Self { + Self::default() + } + + /// Set the (single-line) title shown at the top of the popover. + pub fn title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + /// Append a button that dispatches `action` when clicked. + pub fn button(mut self, label: impl Into, action: Box) -> Self { + self.buttons.push(NativePopoverButton { + label: label.into(), + action, + }); + self + } + + /// Whether the popover has no title and no buttons. + pub fn is_empty(&self) -> bool { + self.title.is_none() && self.buttons.is_empty() + } + + /// Show the popover anchored to `anchor` (the trigger's window-relative + /// bounds, in logical pixels). On macOS the system positions it adjacent to + /// that rect with an arrow, and dismisses it when the user clicks outside. + pub fn show(self, anchor: Bounds, window: &mut Window, cx: &mut App) { + if self.is_empty() { + return; + } + + #[cfg(target_os = "macos")] + macos::show(self.title, self.buttons, anchor, window, cx); + + #[cfg(not(target_os = "macos"))] + { + // TODO: fall back to a GPUI-drawn `Popover` on non-macOS platforms. + let _ = (anchor, window, cx); + } + } +}