1use std::rc::Rc;
46use std::time::Duration;
47
48use gpui::{
49 AnyElement, App, Context, EventEmitter, FocusHandle, Focusable, InteractiveElement,
50 IntoElement, KeyDownEvent, ParentElement, Render, SharedString, StatefulInteractiveElement,
51 Styled, Window, div, px,
52};
53use gpui_kit_semantics::{NodeSpec, Role, Semantic};
54use gpui_kit_theme::{ActiveTheme, Elevation, Space};
55use web_time::Instant;
56
57use crate::foundation::{FocusRing, Ident, StyledExt};
58use crate::overlay::layer::{Overlay, Placement, surface};
59use crate::overlay::popover::anchored_slot;
60
61pub const DEFAULT_OPEN_DELAY: Duration = Duration::from_millis(400);
66
67pub const DEFAULT_GRACE: Duration = Duration::from_millis(300);
72
73const CARD_MAX_WIDTH: f32 = 320.0;
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum Phase {
79 Opening,
80 Leaving,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum HoverCardEvent {
86 Opened,
87 Closed,
88}
89
90impl EventEmitter<HoverCardEvent> for HoverCard {}
91
92type Content = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
94
95pub struct HoverCard {
97 ident: Ident,
98 focus_handle: FocusHandle,
99 trigger_focus: FocusHandle,
100 trigger: Option<Content>,
101 name: Option<SharedString>,
103 content: Option<Content>,
104 placement: Placement,
105 open_delay: Duration,
106 grace: Duration,
107 over_trigger: bool,
108 over_card: bool,
109 open: bool,
110 countdown: Option<(Phase, Duration)>,
112 last_tick: Option<Instant>,
114 pending_focus: bool,
116}
117
118impl std::fmt::Debug for HoverCard {
119 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 formatter
121 .debug_struct("HoverCard")
122 .field("ident", &self.ident)
123 .field("open", &self.open)
124 .field("over_trigger", &self.over_trigger)
125 .field("over_card", &self.over_card)
126 .field("countdown", &self.countdown)
127 .finish()
128 }
129}
130
131impl HoverCard {
132 pub fn new(ident: impl Into<Ident>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
133 Self {
134 ident: ident.into(),
135 focus_handle: cx.focus_handle(),
136 trigger_focus: cx.focus_handle(),
137 trigger: None,
138 name: None,
139 content: None,
140 placement: Placement::Below,
141 open_delay: DEFAULT_OPEN_DELAY,
142 grace: DEFAULT_GRACE,
143 over_trigger: false,
144 over_card: false,
145 open: false,
146 countdown: None,
147 last_tick: None,
148 pending_focus: false,
149 }
150 }
151
152 pub fn trigger(
154 mut self,
155 trigger: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
156 ) -> Self {
157 self.trigger = Some(Rc::new(trigger));
158 self
159 }
160
161 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
165 self.name = Some(name.into());
166 self
167 }
168
169 pub fn content(
171 mut self,
172 content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
173 ) -> Self {
174 self.content = Some(Rc::new(content));
175 self
176 }
177
178 pub fn placement(mut self, placement: Placement) -> Self {
179 self.placement = placement;
180 self
181 }
182
183 pub fn open_delay(mut self, delay: Duration) -> Self {
185 self.open_delay = delay;
186 self
187 }
188
189 pub fn grace(mut self, grace: Duration) -> Self {
191 self.grace = grace;
192 self
193 }
194
195 pub fn is_open(&self) -> bool {
196 self.open
197 }
198
199 pub fn is_leaving(&self) -> bool {
202 matches!(self.countdown, Some((Phase::Leaving, _)))
203 }
204
205 pub fn grace_period(&self) -> Duration {
206 self.grace
207 }
208
209 pub fn open(&mut self, cx: &mut Context<Self>) {
210 self.countdown = None;
211 self.last_tick = None;
212 if self.open {
213 return;
214 }
215 self.open = true;
216 self.pending_focus = true;
217 cx.emit(HoverCardEvent::Opened);
218 cx.notify();
219 }
220
221 pub fn close(&mut self, cx: &mut Context<Self>) {
224 self.countdown = None;
225 self.last_tick = None;
226 if !self.open {
227 return;
228 }
229 self.open = false;
230 self.pending_focus = false;
231 cx.emit(HoverCardEvent::Closed);
232 cx.notify();
233 }
234
235 pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
238 if !self.open {
239 return;
240 }
241 self.close(cx);
242 self.trigger_focus.focus(window, cx);
243 }
244
245 fn set_over_trigger(&mut self, over: bool, cx: &mut Context<Self>) {
246 if self.over_trigger == over {
247 return;
248 }
249 self.over_trigger = over;
250 self.reconsider(cx);
251 }
252
253 fn set_over_card(&mut self, over: bool, cx: &mut Context<Self>) {
254 if self.over_card == over {
255 return;
256 }
257 self.over_card = over;
258 self.reconsider(cx);
259 }
260
261 fn reconsider(&mut self, cx: &mut Context<Self>) {
263 let inside = self.over_trigger || self.over_card;
264 match (self.open, inside) {
265 (true, true) => {
268 if self.countdown.is_some() {
269 self.countdown = None;
270 self.last_tick = None;
271 cx.notify();
272 }
273 }
274 (true, false) => self.start(Phase::Leaving, self.grace, cx),
275 (false, true) => self.start(Phase::Opening, self.open_delay, cx),
276 (false, false) => {
277 if self.countdown.is_some() {
278 self.countdown = None;
279 self.last_tick = None;
280 cx.notify();
281 }
282 }
283 }
284 }
285
286 fn start(&mut self, phase: Phase, duration: Duration, cx: &mut Context<Self>) {
287 if matches!(self.countdown, Some((current, _)) if current == phase) {
288 return;
289 }
290 if duration.is_zero() {
291 match phase {
292 Phase::Opening => self.open(cx),
293 Phase::Leaving => self.close(cx),
294 }
295 return;
296 }
297 self.countdown = Some((phase, duration));
298 self.last_tick = None;
299 cx.notify();
300 }
301
302 fn tick(&mut self, window: &mut Window, cx: &mut Context<Self>) {
304 let Some((phase, remaining)) = self.countdown else {
305 self.last_tick = None;
306 return;
307 };
308 let now = cx.background_executor().now();
309 let spent = self
310 .last_tick
311 .map(|last| now.saturating_duration_since(last))
312 .unwrap_or_default();
313 let left = remaining.saturating_sub(spent);
314 if left.is_zero() {
315 match phase {
316 Phase::Opening => self.open(cx),
317 Phase::Leaving => self.close(cx),
318 }
319 return;
320 }
321 self.countdown = Some((phase, left));
322 self.last_tick = Some(now);
323 window.request_animation_frame();
324 }
325
326 fn on_trigger_key(
327 &mut self,
328 event: &KeyDownEvent,
329 window: &mut Window,
330 cx: &mut Context<Self>,
331 ) {
332 match event.keystroke.key.as_str() {
333 "enter" | "space" => {
334 if self.open {
335 self.dismiss(window, cx);
336 } else {
337 self.open(cx);
338 }
339 cx.stop_propagation();
340 }
341 "escape" if self.open => {
342 self.dismiss(window, cx);
343 cx.stop_propagation();
344 }
345 _ => {}
346 }
347 }
348
349 fn on_card_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
350 if event.keystroke.key.as_str() != "escape" {
351 return;
352 }
353 self.dismiss(window, cx);
354 cx.stop_propagation();
355 }
356}
357
358impl Focusable for HoverCard {
359 fn focus_handle(&self, _cx: &App) -> FocusHandle {
360 self.trigger_focus.clone()
361 }
362}
363
364impl Render for HoverCard {
365 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
366 self.tick(window, cx);
367 let theme = cx.theme().clone();
368 let trigger_ident = self.ident.child("trigger");
369 let card_ident = self.ident.child("card");
370
371 let trigger_body = self.trigger.clone().map(|build| build(window, cx));
372 let trigger = div()
373 .id(trigger_ident.element_id())
374 .flex()
375 .flex_none()
376 .items_center()
377 .tab_index(0)
378 .track_focus(&self.trigger_focus)
379 .focus_ring(&theme)
380 .on_hover(cx.listener(|card, hovered: &bool, _, cx| {
381 card.set_over_trigger(*hovered, cx);
382 }))
383 .on_key_down(cx.listener(Self::on_trigger_key))
384 .children(trigger_body)
385 .semantic_in(cx, {
386 let mut spec = NodeSpec::new(trigger_ident.semantic_id(), Role::Button)
387 .parent(self.ident.semantic_id())
388 .expanded(self.open)
389 .focus(&self.trigger_focus);
390 if let Some(name) = self.name.clone() {
391 spec = spec.text(name);
392 }
393 spec
394 })
395 .into_any_element();
396
397 let overlay = self.open.then(|| {
398 if self.pending_focus {
399 self.pending_focus = false;
403 }
404 let body = self.content.clone().map(|build| build(window, cx));
405 let card = surface(&theme, Elevation::Overlay)
406 .id(card_ident.element_id())
407 .max_w(px(CARD_MAX_WIDTH))
408 .p_token(&theme, Space::Sm)
409 .gap_token(&theme, Space::Xs)
410 .tab_index(0)
411 .track_focus(&self.focus_handle)
412 .focus_ring(&theme)
413 .on_hover(cx.listener(|card, hovered: &bool, _, cx| {
414 card.set_over_card(*hovered, cx);
415 }))
416 .on_key_down(cx.listener(Self::on_card_key))
417 .children(body)
418 .semantic_in(
419 cx,
420 NodeSpec::new(card_ident.semantic_id(), Role::Group)
421 .parent(self.ident.semantic_id())
422 .focus(&self.focus_handle),
423 );
424
425 Overlay::new(self.ident.child("overlay"))
426 .placement(self.placement)
427 .child(card)
428 .into_any_element()
429 });
430
431 anchored_slot(self.placement, trigger, overlay).semantic_in(
432 cx,
433 NodeSpec::new(self.ident.semantic_id(), Role::Group).expanded(self.open),
434 )
435 }
436}