Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 84 additions & 7 deletions crates/base/src/hover_card.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ type ContentBuilder = Box<
>;
type OpenChangeHandler = Rc<dyn Fn(&bool, &mut Window, &mut App)>;

/// An unstyled hover-triggered popup with delayed open and close behavior.
/// An unstyled popup with delayed hover behavior on desktop.
/// On iOS and Android, tapping toggles it and tapping outside dismisses it.
#[derive(IntoElement)]
pub struct HoverCard {
id: ElementId,
anchor: Anchor,
tap_to_open: bool,
trigger: Option<AnyElement>,
content: Option<ContentBuilder>,
open_delay: Duration,
Expand All @@ -35,6 +37,7 @@ impl HoverCard {
Self {
id: id.into(),
anchor: Anchor::TopCenter,
tap_to_open: crate::is_mobile(),
trigger: None,
content: None,
open_delay: Duration::from_secs_f64(0.6),
Expand Down Expand Up @@ -221,11 +224,22 @@ impl RenderOnce for HoverCard {
let trigger = self.trigger.unwrap_or_else(|| div().into_any_element());
let popup = Popup::new(
self.id,
div().id("trigger").child(trigger).on_hover(
window.listener_for(&state, |state, hovered, window, cx| {
state.on_trigger_hover(*hovered, window, cx)
div()
.id("trigger")
.child(trigger)
.when(self.tap_to_open, |trigger| {
trigger.on_click(window.listener_for(&state, move |state, _, window, cx| {
state.cancel_tasks();
// Toggle the state rendered by this trigger even if
// outside dismissal handles the same release first.
state.set_open(!open, window, cx);
}))
})
.when(!self.tap_to_open, |trigger| {
trigger.on_hover(window.listener_for(&state, |state, hovered, window, cx| {
state.on_trigger_hover(*hovered, window, cx)
}))
}),
),
)
.anchor(self.anchor);

Expand All @@ -237,7 +251,17 @@ impl RenderOnce for HoverCard {
let hover = window.listener_for(&state, |state, hovered, window, cx| {
state.on_content_hover(*hovered, window, cx)
});
popup.content(state.update(cx, |state, cx| content(state, window, cx).on_hover(hover)))
let dismiss = window.listener_for(&state, |state, _, window, cx| {
state.cancel_tasks();
state.set_open(false, window, cx);
});
popup.content(state.update(cx, |state, cx| {
content(state, window, cx)
.when(self.tap_to_open, |content| {
content.on_mouse_up_out(gpui::MouseButton::Left, dismiss)
})
.when(!self.tap_to_open, |content| content.on_hover(hover))
}))
})
}
}
Expand All @@ -253,13 +277,18 @@ mod tests {
#[derive(Default)]
struct Harness {
open_changes: Rc<RefCell<Vec<bool>>>,
tap_to_open: bool,
}

impl Render for Harness {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let delay = Duration::from_millis(100);
let open_changes = self.open_changes.clone();
HoverCard::new("hover-card")
.map(|mut card| {
card.tap_to_open = self.tap_to_open;
card
})
.open_delay(delay)
.close_delay(delay)
.on_open_change(move |open, _, _| open_changes.borrow_mut().push(*open))
Expand Down Expand Up @@ -305,7 +334,10 @@ mod tests {
let open_changes = Rc::new(RefCell::new(Vec::new()));
let (_, cx) = cx.add_window_view({
let open_changes = open_changes.clone();
move |_, _| Harness { open_changes }
move |_, _| Harness {
open_changes,
..Default::default()
}
});
cx.update(|window, cx| window.draw(cx).clear(cx));

Expand All @@ -326,4 +358,49 @@ mod tests {
cx.update(|window, cx| window.draw(cx).clear(cx));
assert_eq!(*open_changes.borrow(), vec![true, false]);
}
#[gpui::test]
fn tap_card_ignores_hover_and_toggles_and_dismisses(cx: &mut TestAppContext) {
let changes = Rc::new(RefCell::new(Vec::new()));
let (_, cx) = cx.add_window_view({
let changes = changes.clone();
move |_, _| Harness {
tap_to_open: true,
open_changes: changes,
}
});
cx.update(|window, cx| window.draw(cx).clear(cx));
cx.simulate_mouse_move(point(px(10.), px(10.)), None, Default::default());
cx.executor().advance_clock(Duration::from_secs(1));
cx.run_until_parked();
assert!(changes.borrow().is_empty());

cx.simulate_click(point(px(10.), px(10.)), Default::default());
cx.update(|window, cx| {
window.draw(cx).clear(cx);
window.draw(cx).clear(cx);
});
assert!(cx.debug_bounds("hover-card-content").is_some());
cx.simulate_mouse_move(point(px(100.), px(100.)), None, Default::default());
cx.executor().advance_clock(Duration::from_secs(1));
cx.run_until_parked();
assert_eq!(*changes.borrow(), vec![true]);

cx.simulate_click(point(px(10.), px(10.)), Default::default());
cx.update(|window, cx| window.draw(cx).clear(cx));
assert!(cx.debug_bounds("hover-card-content").is_none());
assert_eq!(*changes.borrow(), vec![true, false]);

cx.simulate_click(point(px(10.), px(10.)), Default::default());
cx.update(|window, cx| {
window.draw(cx).clear(cx);
window.draw(cx).clear(cx);
});
let bounds = cx.debug_bounds("hover-card-content").unwrap();
cx.simulate_click(bounds.center(), Default::default());
assert_eq!(*changes.borrow(), vec![true, false, true]);
cx.simulate_click(point(px(100.), px(100.)), Default::default());
cx.update(|window, cx| window.draw(cx).clear(cx));
assert!(cx.debug_bounds("hover-card-content").is_none());
assert_eq!(*changes.borrow(), vec![true, false, true, false]);
}
}
8 changes: 8 additions & 0 deletions crates/base/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,14 @@ pub use virtual_list::{VirtualList, VirtualListScrollHandle, h_virtual_list, v_v

use gpui::App;

/// Returns whether the application is compiled for iOS or Android.
///
/// This is a compile-time platform check, not a screen-size or input-device check.
#[inline]
pub const fn is_mobile() -> bool {
cfg!(any(target_os = "ios", target_os = "android"))
}

/// Initializes global infrastructure owned by the base layer.
pub fn init(cx: &mut App) {
let _ = Theme::global_mut(cx);
Expand Down
39 changes: 39 additions & 0 deletions crates/base/src/tooltip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,11 @@ pub enum TooltipTransition {
}

/// Per-window tooltip provider and overlay.
///
/// Show requests are ignored on iOS and Android, where touch input must not
/// open hover tooltips. This does not control GPUI's native `.tooltip()` API.
pub struct TooltipOverlay {
enabled: bool,
content: Option<TooltipRequest>,
previous_bounds: Option<Bounds<Pixels>>,
epoch: usize,
Expand All @@ -106,6 +110,7 @@ pub struct TooltipOverlay {
impl TooltipOverlay {
pub fn new() -> Self {
Self {
enabled: !crate::is_mobile(),
content: None,
previous_bounds: None,
epoch: 0,
Expand Down Expand Up @@ -137,6 +142,11 @@ impl TooltipOverlay {
window: &mut Window,
cx: &mut Context<Self>,
) {
// Gate both delayed display and the immediate grace-period switch.
// Keep this in Base so every managed component shares the policy.
if !self.enabled {
return;
}
self.hide_task = None;
let was_visible = self.content.is_some();
if was_visible || self.had_recent_tooltip {
Expand Down Expand Up @@ -305,4 +315,33 @@ mod tests {
fn tooltip_priority_exceeds_popup_layer() {
assert!(TOOLTIP_PRIORITY > crate::POPUP_PRIORITY);
}

#[gpui::test]
fn disabled_provider_ignores_delayed_and_immediate_requests(cx: &mut gpui::TestAppContext) {
let state = cx.update(|cx| {
cx.new(|_| TooltipOverlay {
enabled: false,
..TooltipOverlay::new()
})
});
let cx = cx.add_empty_window();
for had_recent_tooltip in [false, true] {
cx.update(|window, cx| {
state.update(cx, |tooltip, cx| {
tooltip.had_recent_tooltip = had_recent_tooltip;
tooltip.request_show(
TooltipRequest::new(bounds(0., 0., 20., 20.), |_, _| {
panic!("disabled tooltips must not build content")
}),
window,
cx,
);
assert!(tooltip.content.is_none());
assert!(tooltip.show_task.is_none());
assert!(tooltip.hide_task.is_none());
assert_eq!(tooltip.animation_epoch, 0);
});
});
}
}
}
3 changes: 2 additions & 1 deletion crates/component/src/hover_card.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ use crate::{StyledExt as _, popover::Popover};
/// A hover card element that displays content when hovering over a trigger element.
///
/// Similar to Popover but triggered by mouse hover instead of click, with configurable delays
/// for showing and hiding the content.
/// for showing and hiding the content. On iOS and Android, tapping the trigger
/// toggles the card and tapping outside dismisses it; hover delays are ignored.
#[derive(IntoElement)]
pub struct HoverCard {
id: ElementId,
Expand Down
4 changes: 3 additions & 1 deletion crates/kit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,13 @@ tree-sitter-zig = ["component", "gpui-component/tree-sitter-zig"]

[dependencies]
gpui.workspace = true
gpui_platform.workspace = true
gpui-base.workspace = true
gpui-component = { workspace = true, optional = true }
gpui-kit-assets = { workspace = true, optional = true }

[target.'cfg(not(any(target_os = "ios", target_os = "android")))'.dependencies]
gpui_platform.workspace = true

[target.'cfg(target_family = "wasm")'.dependencies]
gpui_web.workspace = true

Expand Down
9 changes: 7 additions & 2 deletions crates/kit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@
//! | Path | Crate | Feature |
//! | --------------- | ----------------- | ---------------- |
//! | `gpui_kit::*` | `gpui` | always |
//! | [`platform`] | `gpui_platform` | always |
//! | `platform` | `gpui_platform` | desktop / web |
//! | [`base`] | `gpui-base` | always |
//! | [`component`] | `gpui-component` | `component` (on) |
//! | [`assets`] | `gpui-kit-assets` | `assets` (on) |
//!
//! [`application`] opens the platform and [`init`] initializes the enabled
//! On desktop and web, `application` opens the platform. Mobile applications
//! supply their backend to `Application::with_platform`. [`init`] initializes the enabled
//! layers:
//!
//! ```no_run
Expand Down Expand Up @@ -103,9 +104,11 @@ pub use ::gpui;
pub mod test;

pub use ::gpui_base as base;
#[cfg(not(any(target_os = "ios", target_os = "android")))]
pub use ::gpui_platform as platform;
#[cfg(target_family = "wasm")]
pub use ::gpui_web as web;
pub use gpui_base::is_mobile;

/// The styled component library.
///
Expand Down Expand Up @@ -141,6 +144,8 @@ pub use ::gpui_component as component;
#[cfg(feature = "assets")]
pub use ::gpui_kit_assets as assets;

// Mobile applications provide their platform with `Application::with_platform`.
#[cfg(not(any(target_os = "ios", target_os = "android")))]
pub use ::gpui_platform::application;

/// Initializes every enabled layer. Call it once, before using anything else.
Expand Down
2 changes: 2 additions & 0 deletions website/base/primitives/hover-card.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ A delayed floating card associated with a pointer or keyboard trigger.

Like every `gpui-base` primitive, Hover Card supplies behavior and semantic structure without imposing a product visual language. Apply GPUI styles and compose the exported parts to match your design system.

On iOS and Android, the trigger toggles the card on click and an outside click dismisses it. Hover and its open/close delays are ignored.

## Example

The [single native Cargo entrypoint](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/native/src/bin/components.rs) selects this primitive from the [shared showcase implementation](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/mod.rs). The same showcase is compiled once for the WASM preview above.
Expand Down
2 changes: 2 additions & 0 deletions website/component/hover-card.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ HoverCard component for displaying rich content that appears when the mouse hove

This is most like the [Popover] component, but triggered by hover instead of click, and with timing controls for a smoother user experience.

On iOS and Android, tap the trigger to open or close the card. Tapping outside closes it; tapping inside keeps it open. Hover delays do not apply. Tooltip hints remain disabled; see [Mobile](/docs/mobile).

## Import

```rust
Expand Down
4 changes: 4 additions & 0 deletions website/component/tooltip.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ description: Display helpful information on hover or focus, with support for key

A versatile tooltip component that displays helpful information when hovering over or focusing on elements. Supports text content, custom elements, keyboard shortcuts, different trigger methods, and positioning options.

## Mobile behavior

On iOS and Android, tooltips managed by the GPUI Base overlay are disabled. Shared components may keep their tooltip configuration, but mobile actions still need visible or accessible labels. Direct GPUI `.tooltip()` calls, including the basic `div()` example below, bypass this overlay and are not disabled by this policy. See [Mobile](/docs/mobile) for integration guidance.

## Import

```rust
Expand Down
2 changes: 2 additions & 0 deletions website/docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ gpui-kit = "0.6"

`gpui-kit` depends on the matching GPUI crates for you, so your application never lists GPUI itself. `use gpui_kit::*;` is GPUI, and the layers are reachable by name: `gpui_kit::component` (the styled components), `gpui_kit::base`, `gpui_kit::assets` and `gpui_kit::platform`.

For experimental iOS support and Swift UIView embedding, see [Mobile](/docs/mobile). Mobile uses `gpui-pre-mobile` and a different application bootstrap from the desktop setup above.

## Faster development builds

Debug builds compile GPUI, the component library and the text stack without
Expand Down
Loading
Loading