From 4ae6a23cd9514039d86002f5c369287899c164ca Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 29 Oct 2026 18:13:42 +0700 Subject: [PATCH] . --- crates/core/src/popover.rs | 254 +++++++++++++++++++++++++++++++++++ crates/core/tests/popover.rs | 157 ++++++++++++++++++++++ 2 files changed, 411 insertions(+) create mode 100644 crates/core/src/popover.rs create mode 100644 crates/core/tests/popover.rs diff --git a/crates/core/src/popover.rs b/crates/core/src/popover.rs new file mode 100644 index 0000000..5d774b9 --- /dev/null +++ b/crates/core/src/popover.rs @@ -0,0 +1,254 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + Animation, AnimationExt as _, AnyElement, App, Bounds, DispatchPhase, ElementId, + InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, + ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, + Styled as _, Window, anchored, canvas, deferred, div, ease_out_quint, +}; +use gpui_native_theme::ActiveTheme as _; + +use crate::common::v_flex; + +/// Reports the next open state of a [`Popover`]: `true` to open, `false` to close. +type ChangeHandler = Rc; +type TriggerFn = Box AnyElement>; +type ContentFn = Box AnyElement>; +/// Bounds captured during the last paint, shared with the dismissal listener. +type BoundsCell = Rc>>>; + +/// A floating panel anchored below its trigger. +#[derive(IntoElement)] +pub struct Popover { + id: ElementId, + open: bool, + a11y_label: Option, + on_change: Option, + trigger: Option, + content: Option, +} + +impl Popover { + /// Creates a closed popover. + pub fn new(id: I) -> Self + where + I: Into, + { + Self { + id: id.into(), + open: false, + a11y_label: None, + on_change: None, + trigger: None, + content: None, + } + } + + /// Sets the current open state. + pub fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + /// Reports the next open state, from dismissals and app changes alike. + pub fn on_change(mut self, handler: F) -> Self + where + F: Fn(bool, &mut Window, &mut App) + 'static, + { + self.on_change = Some(Rc::new(handler)); + self + } + + /// Sets the element the panel anchors below, rendered in place. + pub fn trigger(mut self, trigger: F) -> Self + where + F: Fn(&mut Window, &mut App) -> AnyElement + 'static, + { + self.trigger = Some(Box::new(trigger)); + self + } + + /// Sets the panel's contents. + pub fn content(mut self, content: F) -> Self + where + F: Fn(&mut Window, &mut App) -> AnyElement + 'static, + { + self.content = Some(Box::new(content)); + self + } + + /// Overrides the accessibility label of the panel. + pub fn accessibility_label(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } +} + +impl RenderOnce for Popover { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let (colors, radius, shadows, enter_duration) = { + let theme = cx.theme(); + ( + theme.colors, + theme.radius, + theme.elevations.popover.shadows(), + theme.motion.overlay, + ) + }; + + let trigger_bounds: BoundsCell = Rc::new(RefCell::new(None)); + let panel_bounds: BoundsCell = Rc::new(RefCell::new(None)); + + let fallback_label = self + .a11y_label + .clone() + .unwrap_or_else(|| SharedString::from(self.id.to_string())); + + let mut wrapper = div() + .relative() + .child( + canvas( + { + let cell = trigger_bounds.clone(); + move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds) + }, + { + let on_change = self.on_change.clone(); + let open = self.open; + let cell = trigger_bounds.clone(); + + move |_, _, window, _cx| { + window.on_mouse_event({ + let on_change = on_change.clone(); + let trigger_bounds = cell; + move |event: &MouseDownEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble + || event.button != MouseButton::Left + { + return; + } + + let on_trigger = trigger_bounds + .borrow() + .is_some_and(|bounds| bounds.contains(&event.position)); + + if !on_trigger { + return; + } + + if let Some(on_change) = &on_change { + on_change(!open, window, cx); + } + + cx.stop_propagation(); + } + }); + } + }, + ) + .absolute() + .inset_0(), + ) + .when_some(self.trigger, |wrapper, trigger| { + wrapper.child(trigger(window, cx)) + }); + + if self.open { + let on_change = self.on_change; + let label = fallback_label.clone(); + + let focus_handle = + window.use_keyed_state("popover-focus", cx, |_, cx| cx.focus_handle()); + + let content = self.content.map(|content| content(window, cx)); + + let panel = v_flex() + .id("popover-panel") + .track_focus(&focus_handle.read(cx).clone()) + .role(Role::Dialog) + .aria_label(label) + .mt_1() + .min_w_32() + .bg(colors.surface) + .text_color(colors.surface_foreground) + .rounded(radius.overlay) + .border_1() + .border_color(colors.border) + .shadow(shadows) + .overflow_hidden() + .on_key_down({ + let on_change = on_change.clone(); + move |event: &KeyDownEvent, window, cx| { + if event.keystroke.key == "escape" { + if let Some(on_change) = &on_change { + on_change(false, window, cx); + } + cx.stop_propagation(); + } + } + }) + .when_some(content, |panel, content| panel.child(content)) + .child( + canvas( + { + let cell = panel_bounds.clone(); + move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds) + }, + { + let on_change = on_change.clone(); + let focus = focus_handle; + move |_, _, window, cx| { + if !focus.read(cx).contains_focused(window, cx) { + window.focus(&focus.read(cx).clone(), cx); + } + + window.on_mouse_event({ + let on_change = on_change.clone(); + move |event: &MouseDownEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble + || event.button != MouseButton::Left + { + return; + } + + let on_panel = panel_bounds + .borrow() + .is_some_and(|bounds| bounds.contains(&event.position)); + + let on_trigger = trigger_bounds + .borrow() + .is_some_and(|bounds| bounds.contains(&event.position)); + + if on_panel || on_trigger { + return; + } + + if let Some(on_change) = &on_change { + on_change(false, window, cx); + } + + cx.stop_propagation(); + } + }); + } + }, + ) + .absolute() + .inset_0(), + ); + + wrapper = wrapper.child(deferred(anchored().child(panel.with_animation( + "popover-enter", + Animation::new(enter_duration).with_easing(ease_out_quint()), + |panel, delta| panel.opacity(delta), + )))); + } + + wrapper + } +} diff --git a/crates/core/tests/popover.rs b/crates/core/tests/popover.rs new file mode 100644 index 0000000..ec91f26 --- /dev/null +++ b/crates/core/tests/popover.rs @@ -0,0 +1,157 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + Context, IntoElement, Modifiers, MouseButton, ParentElement as _, Render, Styled as _, + TestAppContext, VisualTestContext, Window, WindowHandle, div, point, px, rgba, +}; +use gpui_native_core::popover::Popover; +use gpui_native_theme::Theme; + +struct Harness { + open: bool, + changes: Rc>>, +} + +impl Render for Harness { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let changes = self.changes.clone(); + let view = cx.entity(); + div().size_full().child( + Popover::new("test-popover") + .open(self.open) + .on_change(move |next, _, cx| { + changes.borrow_mut().push(next); + view.update(cx, |harness, cx| { + harness.open = next; + cx.notify(); + }); + }) + .trigger(|_, _| { + div() + .w(px(100.)) + .h(px(32.)) + .bg(rgba(0x000000ff)) + .into_any_element() + }) + .content(|_, _| { + div() + .w(px(200.)) + .h(px(80.)) + .child("Popover content") + .into_any_element() + }), + ) + } +} + +/// A window whose popover trigger sits in the top-left corner: the panel +/// hangs below it around `(0..200, 36..116)`, so clicks at `(10, 16)` land +/// on the trigger, `(100, 70)` on the panel, and anything past `(300, 300)` +/// is outside both. +fn popover_window( + open: bool, +) -> ( + TestAppContext, + VisualTestContext, + WindowHandle, + Rc>>, +) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let changes = Rc::new(RefCell::new(Vec::new())); + let harness_changes = changes.clone(); + let window = cx.add_window(move |_, _| Harness { + open, + changes: harness_changes, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + (cx, visual, window, changes) +} + +fn draw(visual: &mut VisualTestContext) { + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); +} + +fn click(visual: &mut VisualTestContext, position: gpui::Point) { + visual.simulate_mouse_down(position, MouseButton::Left, Modifiers::default()); + visual.simulate_mouse_up(position, MouseButton::Left, Modifiers::default()); +} + +#[test] +fn clicking_outside_dismisses_and_reports_closed() { + let (_cx, mut visual, _window, changes) = popover_window(true); + + click(&mut visual, point(px(600.), px(600.))); + assert_eq!(*changes.borrow(), vec![false]); + + // The dismissal swallowed the click and the owner closed the popover: + // the redraw renders without a panel, and nothing further fires. + draw(&mut visual); + click(&mut visual, point(px(600.), px(600.))); + assert_eq!(*changes.borrow(), vec![false]); +} + +#[test] +fn clicking_the_trigger_toggles_and_clicking_the_panel_does_nothing() { + let (_cx, mut visual, _window, changes) = popover_window(true); + + // On the open panel's trigger: toggles closed. + click(&mut visual, point(px(10.), px(16.))); + assert_eq!(*changes.borrow(), vec![false]); + + // On the panel itself: nothing. + click(&mut visual, point(px(100.), px(70.))); + assert_eq!(*changes.borrow(), vec![false]); +} + +#[test] +fn clicking_the_trigger_opens_a_closed_popover() { + let (_cx, mut visual, _window, changes) = popover_window(false); + + click(&mut visual, point(px(10.), px(16.))); + assert_eq!(*changes.borrow(), vec![true]); +} + +#[test] +fn escape_dismisses_while_the_panel_holds_focus() { + let (_cx, mut visual, _window, changes) = popover_window(true); + draw(&mut visual); + + // The panel claims focus on open, so escape reaches its key listener. + visual.simulate_keystrokes("escape"); + assert_eq!(*changes.borrow(), vec![false]); + + // A closed popover is inert: no further dismissal events. + draw(&mut visual); + visual.simulate_keystrokes("escape"); + assert_eq!(*changes.borrow(), vec![false]); +} + +#[test] +fn the_controlled_loop_reopens_from_the_app_side() { + let (mut cx, mut visual, window, changes) = popover_window(true); + + // Dismiss once. + click(&mut visual, point(px(600.), px(600.))); + assert_eq!(*changes.borrow(), vec![false]); + draw(&mut visual); + + // Reopen through the harness view: the app-driven half of the loop + // reports no event of its own. + window + .update(&mut cx, |harness, _, cx| { + harness.open = true; + cx.notify(); + }) + .unwrap(); + draw(&mut visual); + + // The panel is back and holds focus again: escape dismisses once more. + visual.simulate_keystrokes("escape"); + assert_eq!(*changes.borrow(), vec![false, false]); +}