use std::rc::Rc;
use std::time::Duration;
use gpui::{
AnyElement, App, Context, EventEmitter, FocusHandle, Focusable, InteractiveElement,
IntoElement, KeyDownEvent, ParentElement, Render, SharedString, StatefulInteractiveElement,
Styled, Window, div, px,
};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, Elevation, Space};
use web_time::Instant;
use crate::foundation::{FocusRing, Ident, StyledExt};
use crate::overlay::layer::{Overlay, Placement, surface};
use crate::overlay::popover::anchored_slot;
pub const DEFAULT_OPEN_DELAY: Duration = Duration::from_millis(400);
pub const DEFAULT_GRACE: Duration = Duration::from_millis(300);
const CARD_MAX_WIDTH: f32 = 320.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Phase {
Opening,
Leaving,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HoverCardEvent {
Opened,
Closed,
}
impl EventEmitter<HoverCardEvent> for HoverCard {}
type Content = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
pub struct HoverCard {
ident: Ident,
focus_handle: FocusHandle,
trigger_focus: FocusHandle,
trigger: Option<Content>,
name: Option<SharedString>,
content: Option<Content>,
placement: Placement,
open_delay: Duration,
grace: Duration,
over_trigger: bool,
over_card: bool,
open: bool,
countdown: Option<(Phase, Duration)>,
last_tick: Option<Instant>,
pending_focus: bool,
}
impl std::fmt::Debug for HoverCard {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("HoverCard")
.field("ident", &self.ident)
.field("open", &self.open)
.field("over_trigger", &self.over_trigger)
.field("over_card", &self.over_card)
.field("countdown", &self.countdown)
.finish()
}
}
impl HoverCard {
pub fn new(ident: impl Into<Ident>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
ident: ident.into(),
focus_handle: cx.focus_handle(),
trigger_focus: cx.focus_handle(),
trigger: None,
name: None,
content: None,
placement: Placement::Below,
open_delay: DEFAULT_OPEN_DELAY,
grace: DEFAULT_GRACE,
over_trigger: false,
over_card: false,
open: false,
countdown: None,
last_tick: None,
pending_focus: false,
}
}
pub fn trigger(
mut self,
trigger: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
) -> Self {
self.trigger = Some(Rc::new(trigger));
self
}
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
self.name = Some(name.into());
self
}
pub fn content(
mut self,
content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
) -> Self {
self.content = Some(Rc::new(content));
self
}
pub fn placement(mut self, placement: Placement) -> Self {
self.placement = placement;
self
}
pub fn open_delay(mut self, delay: Duration) -> Self {
self.open_delay = delay;
self
}
pub fn grace(mut self, grace: Duration) -> Self {
self.grace = grace;
self
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn is_leaving(&self) -> bool {
matches!(self.countdown, Some((Phase::Leaving, _)))
}
pub fn grace_period(&self) -> Duration {
self.grace
}
pub fn open(&mut self, cx: &mut Context<Self>) {
self.countdown = None;
self.last_tick = None;
if self.open {
return;
}
self.open = true;
self.pending_focus = true;
cx.emit(HoverCardEvent::Opened);
cx.notify();
}
pub fn close(&mut self, cx: &mut Context<Self>) {
self.countdown = None;
self.last_tick = None;
if !self.open {
return;
}
self.open = false;
self.pending_focus = false;
cx.emit(HoverCardEvent::Closed);
cx.notify();
}
pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.open {
return;
}
self.close(cx);
self.trigger_focus.focus(window, cx);
}
fn set_over_trigger(&mut self, over: bool, cx: &mut Context<Self>) {
if self.over_trigger == over {
return;
}
self.over_trigger = over;
self.reconsider(cx);
}
fn set_over_card(&mut self, over: bool, cx: &mut Context<Self>) {
if self.over_card == over {
return;
}
self.over_card = over;
self.reconsider(cx);
}
fn reconsider(&mut self, cx: &mut Context<Self>) {
let inside = self.over_trigger || self.over_card;
match (self.open, inside) {
(true, true) => {
if self.countdown.is_some() {
self.countdown = None;
self.last_tick = None;
cx.notify();
}
}
(true, false) => self.start(Phase::Leaving, self.grace, cx),
(false, true) => self.start(Phase::Opening, self.open_delay, cx),
(false, false) => {
if self.countdown.is_some() {
self.countdown = None;
self.last_tick = None;
cx.notify();
}
}
}
}
fn start(&mut self, phase: Phase, duration: Duration, cx: &mut Context<Self>) {
if matches!(self.countdown, Some((current, _)) if current == phase) {
return;
}
if duration.is_zero() {
match phase {
Phase::Opening => self.open(cx),
Phase::Leaving => self.close(cx),
}
return;
}
self.countdown = Some((phase, duration));
self.last_tick = None;
cx.notify();
}
fn tick(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some((phase, remaining)) = self.countdown else {
self.last_tick = None;
return;
};
let now = cx.background_executor().now();
let spent = self
.last_tick
.map(|last| now.saturating_duration_since(last))
.unwrap_or_default();
let left = remaining.saturating_sub(spent);
if left.is_zero() {
match phase {
Phase::Opening => self.open(cx),
Phase::Leaving => self.close(cx),
}
return;
}
self.countdown = Some((phase, left));
self.last_tick = Some(now);
window.request_animation_frame();
}
fn on_trigger_key(
&mut self,
event: &KeyDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event.keystroke.key.as_str() {
"enter" | "space" => {
if self.open {
self.dismiss(window, cx);
} else {
self.open(cx);
}
cx.stop_propagation();
}
"escape" if self.open => {
self.dismiss(window, cx);
cx.stop_propagation();
}
_ => {}
}
}
fn on_card_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
if event.keystroke.key.as_str() != "escape" {
return;
}
self.dismiss(window, cx);
cx.stop_propagation();
}
}
impl Focusable for HoverCard {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.trigger_focus.clone()
}
}
impl Render for HoverCard {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.tick(window, cx);
let theme = cx.theme().clone();
let trigger_ident = self.ident.child("trigger");
let card_ident = self.ident.child("card");
let trigger_body = self.trigger.clone().map(|build| build(window, cx));
let trigger = div()
.id(trigger_ident.element_id())
.flex()
.flex_none()
.items_center()
.tab_index(0)
.track_focus(&self.trigger_focus)
.focus_ring(&theme)
.on_hover(cx.listener(|card, hovered: &bool, _, cx| {
card.set_over_trigger(*hovered, cx);
}))
.on_key_down(cx.listener(Self::on_trigger_key))
.children(trigger_body)
.semantic_in(cx, {
let mut spec = NodeSpec::new(trigger_ident.semantic_id(), Role::Button)
.parent(self.ident.semantic_id())
.expanded(self.open)
.focus(&self.trigger_focus);
if let Some(name) = self.name.clone() {
spec = spec.text(name);
}
spec
})
.into_any_element();
let overlay = self.open.then(|| {
if self.pending_focus {
self.pending_focus = false;
}
let body = self.content.clone().map(|build| build(window, cx));
let card = surface(&theme, Elevation::Overlay)
.id(card_ident.element_id())
.max_w(px(CARD_MAX_WIDTH))
.p_token(&theme, Space::Sm)
.gap_token(&theme, Space::Xs)
.tab_index(0)
.track_focus(&self.focus_handle)
.focus_ring(&theme)
.on_hover(cx.listener(|card, hovered: &bool, _, cx| {
card.set_over_card(*hovered, cx);
}))
.on_key_down(cx.listener(Self::on_card_key))
.children(body)
.semantic_in(
cx,
NodeSpec::new(card_ident.semantic_id(), Role::Group)
.parent(self.ident.semantic_id())
.focus(&self.focus_handle),
);
Overlay::new(self.ident.child("overlay"))
.placement(self.placement)
.child(card)
.into_any_element()
});
anchored_slot(self.placement, trigger, overlay).semantic_in(
cx,
NodeSpec::new(self.ident.semantic_id(), Role::Group).expanded(self.open),
)
}
}