Skip to main content

gpui_ai/
popup.rs

1//! Where transient surfaces open, how soon, and what edge they draw.
2//!
3//! Dropdowns, menus, hover cards, and toolbars all float above the page,
4//! and a reader reads them as one family or as none. This module owns the
5//! three things that make them a family: the side they prefer to open on,
6//! how long a hover waits before one appears, and the edge that separates
7//! a floating surface from the content behind it.
8//!
9//! Placement is a *preference*, not a command. Every surface is positioned
10//! through the shared positioner, which flips to the opposite side when the
11//! preferred one does not fit and clamps to the window when neither does —
12//! so a composer at the bottom of a window opens its menu upward without
13//! the caller arranging it. [`PopupSide::Auto`] simply states that
14//! preference explicitly.
15//!
16//! # Example
17//!
18//! ```no_run
19//! # fn example(cx: &mut gpui::App) {
20//! gpui_ai::init(cx);
21//! gpui_ai::popup::PopupTokens::default()
22//!     .with_side(gpui_ai::popup::PopupSide::Above)
23//!     .set(cx);
24//! # }
25//! ```
26
27use std::time::Duration;
28
29use gpui::{App, Global};
30use gpui_base::Placement;
31use gpui_component::ActiveTheme as _;
32
33/// The side a floating surface prefers to open on.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum PopupSide {
36    /// Below the trigger, flipping above when it does not fit.
37    #[default]
38    Below,
39    /// Above the trigger, flipping below when it does not fit.
40    Above,
41    /// Whichever side has more room, decided per opening.
42    Auto,
43}
44
45impl PopupSide {
46    /// The placement to hand the positioner for a trigger at `anchor_top`
47    /// in a window `viewport_height` tall.
48    ///
49    /// [`Auto`](Self::Auto) resolves by which side of the trigger has more
50    /// room; the two fixed sides state a preference the positioner is
51    /// still free to flip when it does not fit.
52    pub fn placement(self, anchor_top: gpui::Pixels, viewport_height: gpui::Pixels) -> Placement {
53        match self {
54            Self::Below => Placement::Bottom,
55            Self::Above => Placement::Top,
56            Self::Auto => {
57                if anchor_top > viewport_height * 0.5 {
58                    Placement::Top
59                } else {
60                    Placement::Bottom
61                }
62            }
63        }
64    }
65}
66
67/// The crate's policy for floating surfaces.
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct PopupTokens {
70    side: PopupSide,
71    hover_open_delay: Duration,
72    hover_close_delay: Duration,
73}
74
75impl Global for PopupTokens {}
76
77impl Default for PopupTokens {
78    fn default() -> Self {
79        Self::DEFAULT
80    }
81}
82
83impl PopupTokens {
84    /// The crate's default: open below with a smart flip, and show a
85    /// hover surface almost at once.
86    ///
87    /// Upstream waits 600ms before a hover card appears, which reads as
88    /// the data being fetched rather than revealed. A hover surface over
89    /// data the application already holds should feel like a disclosure,
90    /// so the default is the quick tempo's own delay.
91    pub const DEFAULT: Self = Self {
92        side: PopupSide::Below,
93        hover_open_delay: Duration::from_millis(100),
94        hover_close_delay: Duration::from_millis(200),
95    };
96
97    /// The policy the application installed, or the crate's default.
98    pub fn read(cx: &App) -> Self {
99        cx.try_global::<Self>().copied().unwrap_or(Self::DEFAULT)
100    }
101
102    /// Makes this policy the application's, for every floating surface.
103    pub fn set(self, cx: &mut App) {
104        cx.set_global(self);
105    }
106
107    /// The side floating surfaces prefer.
108    pub const fn side(&self) -> PopupSide {
109        self.side
110    }
111
112    /// How long a pointer rests before a hover surface opens.
113    pub const fn hover_open_delay(&self) -> Duration {
114        self.hover_open_delay
115    }
116
117    /// How long a hover surface waits before closing.
118    pub const fn hover_close_delay(&self) -> Duration {
119        self.hover_close_delay
120    }
121
122    /// Replaces the [`side`](Self::side).
123    pub const fn with_side(mut self, side: PopupSide) -> Self {
124        self.side = side;
125        self
126    }
127
128    /// Replaces the [`hover_open_delay`](Self::hover_open_delay).
129    pub const fn with_hover_open_delay(mut self, delay: Duration) -> Self {
130        self.hover_open_delay = delay;
131        self
132    }
133
134    /// Replaces the [`hover_close_delay`](Self::hover_close_delay).
135    pub const fn with_hover_close_delay(mut self, delay: Duration) -> Self {
136        self.hover_close_delay = delay;
137        self
138    }
139}
140
141/// Installs the crate's default popup policy unless the application chose
142/// one first.
143pub(crate) fn install(cx: &mut App) {
144    if !cx.has_global::<PopupTokens>() {
145        PopupTokens::DEFAULT.set(cx);
146    }
147}
148
149/// The surface every floating panel draws.
150///
151/// Upstream's popover style carries a shadow and the shadow's own ring,
152/// which reads clearly on a light page and nearly disappears on a dark
153/// one — the 0.4.0 feel review asked for a visible edge. This keeps the
154/// shadow and states the border, so a floating panel is separable from
155/// whatever it covers on any theme.
156pub(crate) fn popover_surface<E>(surface: E, cx: &App) -> E
157where
158    E: gpui_component::ThemeStyled,
159{
160    surface
161        .popover_style(cx)
162        .border_1()
163        .border_color(cx.theme().border)
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use gpui::{TestAppContext, px};
170
171    /// A surface near the bottom of the window opens upward without the
172    /// caller arranging it; one near the top opens downward.
173    #[test]
174    fn auto_opens_away_from_the_nearer_edge() {
175        let viewport = px(800.);
176        assert_eq!(
177            PopupSide::Auto.placement(px(700.), viewport),
178            Placement::Top
179        );
180        assert_eq!(
181            PopupSide::Auto.placement(px(100.), viewport),
182            Placement::Bottom
183        );
184    }
185
186    /// Hover surfaces open at the crate's tempo, not upstream's 600ms.
187    #[gpui::test]
188    fn hover_surfaces_open_promptly(cx: &mut TestAppContext) {
189        cx.update(crate::init);
190        cx.update(|cx| {
191            assert!(PopupTokens::read(cx).hover_open_delay() <= Duration::from_millis(150));
192        });
193    }
194
195    /// The policy is a policy: an application that chooses first keeps it.
196    #[gpui::test]
197    fn install_respects_a_policy_the_application_chose_first(cx: &mut TestAppContext) {
198        cx.update(|cx| {
199            PopupTokens::default().with_side(PopupSide::Above).set(cx);
200            crate::init(cx);
201        });
202        cx.update(|cx| assert_eq!(PopupTokens::read(cx).side(), PopupSide::Above));
203    }
204}