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}