T01 primitives: image, divider, backdrop, focus, pressable, measure, tooltip
Ely-built primitives plus gallery demos for every chapter-one entry, including gpui natives. The gallery now scripts real input by posting NSEvents to its own queue and shoots each interaction in both modes. Codex gpt-6-astra (max): three rounds; the last finding, a two-line doc comment, was fixed after the three-review cap.
This commit is contained in:
38 files changed
+2165
-157
No files matched your search
@@ -0,0 +1,83 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyElement, App, ElementId, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement, Point, RenderOnce, Styled, Window, anchored, deferred, div,
|
||||
prelude::*,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::{motion, theme::ActiveTheme};
|
||||
|
||||
type Dismiss = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// Full-window scrim. Children sit centered above it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Backdrop {
|
||||
id: ElementId,
|
||||
on_dismiss: Option<Dismiss>,
|
||||
children: SmallVec<[AnyElement; 1]>,
|
||||
}
|
||||
|
||||
impl Backdrop {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
on_dismiss: None,
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs on a click outside the children.
|
||||
pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_dismiss = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for Backdrop {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Backdrop {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let viewport = window.viewport_size();
|
||||
let fade = motion::duration(motion::BASE, cx);
|
||||
let scrim = div()
|
||||
.id(self.id.clone())
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full()
|
||||
.bg(cx.theme().colors.backdrop)
|
||||
.occlude()
|
||||
.when_some(self.on_dismiss, |scrim, dismiss| {
|
||||
scrim.on_mouse_down(MouseButton::Left, move |_, window, cx| dismiss(window, cx))
|
||||
})
|
||||
.with_animation(
|
||||
self.id,
|
||||
Animation::new(fade).with_easing(motion::ease_out_cubic),
|
||||
|scrim, t| scrim.opacity(t),
|
||||
);
|
||||
|
||||
deferred(
|
||||
anchored().position(Point::default()).child(
|
||||
div()
|
||||
.relative()
|
||||
.w(viewport.width)
|
||||
.h(viewport.height)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(scrim)
|
||||
.children(
|
||||
self.children
|
||||
.into_iter()
|
||||
.map(|child| div().relative().occlude().child(child)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use gpui::{App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div};
|
||||
|
||||
use crate::theme::{ActiveTheme, TextSize};
|
||||
|
||||
/// Hairline rule, across or down.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Divider {
|
||||
vertical: bool,
|
||||
label: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl Divider {
|
||||
pub fn horizontal() -> Self {
|
||||
Self {
|
||||
vertical: false,
|
||||
label: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn vertical() -> Self {
|
||||
Self {
|
||||
vertical: true,
|
||||
label: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Text centered on a horizontal rule.
|
||||
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||
self.label = Some(label.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Divider {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let line = || div().border_color(theme.colors.border);
|
||||
if self.vertical {
|
||||
return line().h_full().border_l_1();
|
||||
}
|
||||
match self.label {
|
||||
None => line().w_full().border_t_1(),
|
||||
Some(label) => div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
.child(line().flex_1().border_t_1())
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child(label),
|
||||
)
|
||||
.child(line().flex_1().border_t_1()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use gpui::{
|
||||
AnyElement, App, Div, FocusHandle, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
StyleRefinement, Styled, Window, actions, div,
|
||||
};
|
||||
|
||||
use crate::theme::ActiveTheme;
|
||||
|
||||
actions!(ely, [FocusNext, FocusPrev]);
|
||||
|
||||
/// Focus-colored border while focused. Give the element a 1px border.
|
||||
pub trait FocusRing: InteractiveElement + Sized {
|
||||
fn focus_ring(self, cx: &App) -> Self {
|
||||
let ring = cx.theme().colors.focus;
|
||||
self.focus(move |style| style.border_color(ring))
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: InteractiveElement> FocusRing for E {}
|
||||
|
||||
/// Owns Tab and Shift-Tab for its subtree. `trap` keeps focus inside.
|
||||
#[derive(IntoElement)]
|
||||
pub struct FocusScope {
|
||||
base: Div,
|
||||
handle: FocusHandle,
|
||||
trap: bool,
|
||||
}
|
||||
|
||||
impl FocusScope {
|
||||
pub fn new(handle: &FocusHandle) -> Self {
|
||||
Self {
|
||||
base: div().track_focus(handle),
|
||||
handle: handle.clone(),
|
||||
trap: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trap(mut self) -> Self {
|
||||
self.trap = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for FocusScope {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for FocusScope {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.base.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for FocusScope {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let (next, prev) = (self.handle.clone(), self.handle);
|
||||
let trap = self.trap;
|
||||
self.base
|
||||
.on_action(move |_: &FocusNext, window, cx| step(&next, trap, true, window, cx))
|
||||
.on_action(move |_: &FocusPrev, window, cx| step(&prev, trap, false, window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
fn step(scope: &FocusHandle, trap: bool, forward: bool, window: &mut Window, cx: &mut App) {
|
||||
let advance = |window: &mut Window| {
|
||||
if forward {
|
||||
window.focus_next()
|
||||
} else {
|
||||
window.focus_prev()
|
||||
}
|
||||
};
|
||||
if !trap {
|
||||
advance(window);
|
||||
return;
|
||||
}
|
||||
let origin = window.focused(cx);
|
||||
let mut first = None;
|
||||
loop {
|
||||
advance(window);
|
||||
let focused = window.focused(cx);
|
||||
if scope.contains_focused(window, cx) && focused.as_ref() != Some(scope) {
|
||||
return;
|
||||
}
|
||||
if focused.is_none() || focused == origin || (first.is_some() && focused == first) {
|
||||
break;
|
||||
}
|
||||
if first.is_none() {
|
||||
first = focused;
|
||||
}
|
||||
}
|
||||
log::warn!("focus scope: trap holds no tab stop; focus stays");
|
||||
if let Some(origin) = origin {
|
||||
window.focus(&origin);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,13 @@ macro_rules! icons {
|
||||
$(IconName::$variant => concat!("icons/", $file, ".svg"),)*
|
||||
}
|
||||
}
|
||||
|
||||
/// Lucide name, such as `chevron-down`.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
$(IconName::$variant => $file,)*
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -124,6 +131,7 @@ icons! {
|
||||
Hourglass => "hourglass",
|
||||
House => "house",
|
||||
Image => "image",
|
||||
ImageOff => "image-off",
|
||||
Inbox => "inbox",
|
||||
Info => "info",
|
||||
Italic => "italic",
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, App, Div, ElementId, ImageSource, InteractiveElement, IntoElement,
|
||||
ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, Window, div, img,
|
||||
pulsating_between,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize},
|
||||
};
|
||||
|
||||
const PULSE: Duration = Duration::from_millis(1600);
|
||||
|
||||
/// Themed `img()`; a `Cover` crop keeps square corners.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Image {
|
||||
id: ElementId,
|
||||
base: Div,
|
||||
source: Option<ImageSource>,
|
||||
fit: ObjectFit,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// The id keeps gpui's loading state across frames.
|
||||
pub fn new(id: impl Into<ElementId>, source: impl Into<ImageSource>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
base: div(),
|
||||
source: Some(source.into()),
|
||||
fit: ObjectFit::Cover,
|
||||
}
|
||||
}
|
||||
|
||||
/// Nothing to load yet.
|
||||
pub fn placeholder(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
base: div(),
|
||||
source: None,
|
||||
fit: ObjectFit::Cover,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fit(mut self, fit: ObjectFit) -> Self {
|
||||
self.fit = fit;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Image {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Image {
|
||||
fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let (sunken, subtle) = (theme.colors.sunken, theme.colors.fg_subtle);
|
||||
let still = theme.reduced_motion;
|
||||
let radii = self.base.style().corner_radii.clone();
|
||||
let pane = {
|
||||
let radii = radii.clone();
|
||||
move || {
|
||||
let mut pane = div().size_full().bg(sunken);
|
||||
pane.style().corner_radii = radii.clone();
|
||||
pane
|
||||
}
|
||||
};
|
||||
let loading_pane = pane.clone();
|
||||
let mark = move |name| {
|
||||
pane()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Icon::new(name).size(IconSize::Lg).color(subtle))
|
||||
};
|
||||
let Some(source) = self.source else {
|
||||
return self.base.overflow_hidden().child(mark(IconName::Image));
|
||||
};
|
||||
let mut picture = img(source)
|
||||
.id(self.id)
|
||||
.size_full()
|
||||
.object_fit(self.fit)
|
||||
.with_loading(move || {
|
||||
if still {
|
||||
return loading_pane().into_any_element();
|
||||
}
|
||||
loading_pane()
|
||||
.with_animation(
|
||||
"image-loading",
|
||||
Animation::new(PULSE)
|
||||
.repeat()
|
||||
.with_easing(pulsating_between(0.55, 1.0)),
|
||||
|pane, t| pane.opacity(t),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.with_fallback(move || mark(IconName::ImageOff).into_any_element());
|
||||
picture.style().corner_radii = radii;
|
||||
self.base.overflow_hidden().child(picture)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, Bounds, Div, ElementId, IntoElement, ParentElement, Pixels, RenderOnce,
|
||||
StyleRefinement, Styled, Window, canvas, div,
|
||||
};
|
||||
|
||||
type BoundsHandler = Rc<dyn Fn(Bounds<Pixels>, &mut Window, &mut App)>;
|
||||
type VisibilityHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// A div that reports its bounds whenever they change.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Measure {
|
||||
id: ElementId,
|
||||
base: Div,
|
||||
on_measure: BoundsHandler,
|
||||
}
|
||||
|
||||
impl Measure {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
on_measure: impl Fn(Bounds<Pixels>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
base: div(),
|
||||
on_measure: Rc::new(on_measure),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Measure {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for Measure {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.base.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Measure {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let last = window.use_keyed_state(self.id, cx, |_, _| None::<Bounds<Pixels>>);
|
||||
let on_measure = self.on_measure;
|
||||
self.base.relative().child(
|
||||
canvas(
|
||||
move |bounds, window, cx| {
|
||||
if *last.read(cx) != Some(bounds) {
|
||||
last.update(cx, |seen, _| *seen = Some(bounds));
|
||||
on_measure(bounds, window, cx);
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A div that reports when it enters or leaves the visible area.
|
||||
#[derive(IntoElement)]
|
||||
pub struct IntersectionObserver {
|
||||
id: ElementId,
|
||||
base: Div,
|
||||
on_change: VisibilityHandler,
|
||||
}
|
||||
|
||||
impl IntersectionObserver {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
on_change: impl Fn(bool, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
base: div(),
|
||||
on_change: Rc::new(on_change),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for IntersectionObserver {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for IntersectionObserver {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.base.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for IntersectionObserver {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let last = window.use_keyed_state(self.id, cx, |_, _| None::<bool>);
|
||||
let on_change = self.on_change;
|
||||
self.base.relative().child(
|
||||
canvas(
|
||||
move |bounds, window, cx| {
|
||||
let visible = window.content_mask().bounds.intersects(&bounds);
|
||||
if *last.read(cx) != Some(visible) {
|
||||
last.update(cx, |seen, _| *seen = Some(visible));
|
||||
on_change(visible, window, cx);
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,17 @@
|
||||
mod backdrop;
|
||||
mod divider;
|
||||
mod focus;
|
||||
mod icon;
|
||||
mod image;
|
||||
mod measure;
|
||||
mod pressable;
|
||||
mod tooltip;
|
||||
|
||||
pub use backdrop::Backdrop;
|
||||
pub use divider::Divider;
|
||||
pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope};
|
||||
pub use icon::{Icon, IconName};
|
||||
pub use image::Image;
|
||||
pub use measure::{IntersectionObserver, Measure};
|
||||
pub use pressable::Pressable;
|
||||
pub use tooltip::{Tooltip, TooltipTrigger};
|
||||
@@ -0,0 +1,111 @@
|
||||
use std::{rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||
RenderOnce, Task, Window, div,
|
||||
};
|
||||
|
||||
type Handler = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
type Content = Box<dyn Fn(bool, &mut Window, &mut App) -> AnyElement>;
|
||||
|
||||
const LONG_PRESS: Duration = Duration::from_millis(500);
|
||||
|
||||
#[derive(Default)]
|
||||
struct PressState {
|
||||
pressed: bool,
|
||||
long_fired: bool,
|
||||
_timer: Option<Task<()>>,
|
||||
}
|
||||
|
||||
/// Pressed state and long press for any content.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Pressable {
|
||||
id: ElementId,
|
||||
content: Content,
|
||||
on_press: Option<Handler>,
|
||||
on_long_press: Option<Handler>,
|
||||
}
|
||||
|
||||
impl Pressable {
|
||||
/// `content` receives whether the pointer is down.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
content: impl Fn(bool, &mut Window, &mut App) -> AnyElement + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
content: Box::new(content),
|
||||
on_press: None,
|
||||
on_long_press: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_press(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_press = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_long_press(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_long_press = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Pressable {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| PressState::default());
|
||||
let pressed = state.read(cx).pressed;
|
||||
let content = (self.content)(pressed, window, cx);
|
||||
let (down, up, out) = (state.clone(), state.clone(), state);
|
||||
let on_long_press = self.on_long_press;
|
||||
let on_press = self.on_press;
|
||||
|
||||
div()
|
||||
.id(self.id)
|
||||
.child(content)
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
let timer = on_long_press.clone().map(|handler| {
|
||||
let weak = down.downgrade();
|
||||
window.spawn(cx, async move |cx| {
|
||||
cx.background_executor().timer(LONG_PRESS).await;
|
||||
let fired = cx.update(|window, cx| {
|
||||
weak.update(cx, |press, cx| {
|
||||
press.long_fired = press.pressed;
|
||||
cx.notify();
|
||||
press.long_fired
|
||||
})
|
||||
.map(|fire| fire.then(|| handler(window, cx)))
|
||||
});
|
||||
if let Err(error) = fired.and_then(|inner| inner) {
|
||||
log::error!("pressable: long press lost its window: {error:#}");
|
||||
}
|
||||
})
|
||||
});
|
||||
down.update(cx, |press, cx| {
|
||||
*press = PressState {
|
||||
pressed: true,
|
||||
long_fired: false,
|
||||
_timer: timer,
|
||||
};
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.on_mouse_up(MouseButton::Left, move |_, window, cx| {
|
||||
let released = up.update(cx, |press, cx| {
|
||||
let short = press.pressed && !press.long_fired;
|
||||
*press = PressState::default();
|
||||
cx.notify();
|
||||
short
|
||||
});
|
||||
if let Some(handler) = on_press.as_ref().filter(|_| released) {
|
||||
handler(window, cx);
|
||||
}
|
||||
})
|
||||
.on_mouse_up_out(MouseButton::Left, move |_, _, cx| {
|
||||
out.update(cx, |press, cx| {
|
||||
*press = PressState::default();
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
use std::{rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyElement, AnyView, App, AppContext, Context, Div, ElementId,
|
||||
InteractiveElement, IntoElement, ParentElement, Pixels, Point, Render, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Task, Window, anchored, deferred, div, point,
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
motion,
|
||||
theme::{ActiveTheme, Elevation, Radius, TextSize},
|
||||
};
|
||||
|
||||
type Content = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
|
||||
|
||||
const DELAY: Duration = Duration::from_millis(400);
|
||||
|
||||
fn surface(content: AnyElement, cx: &App) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
let rise = motion::NUDGE;
|
||||
let body = div()
|
||||
.max_w(theme.tooltip_max_width())
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(theme.colors.tooltip_bg)
|
||||
.text_color(theme.colors.tooltip_fg)
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.shadow(theme.elevation(Elevation::Floating))
|
||||
.child(content);
|
||||
body.with_animation(
|
||||
"tooltip-in",
|
||||
Animation::new(motion::duration(motion::FAST, cx)).with_easing(motion::ease_out_cubic),
|
||||
move |body, t| body.opacity(t).mt(rise * (1.0 - t)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Tooltip body for gpui's `.tooltip()`.
|
||||
pub struct Tooltip {
|
||||
content: Content,
|
||||
}
|
||||
|
||||
impl Tooltip {
|
||||
pub fn text(title: impl Into<SharedString>) -> impl Fn(&mut Window, &mut App) -> AnyView {
|
||||
let title = title.into();
|
||||
Self::rich(move |_, _| title.clone().into_any_element())
|
||||
}
|
||||
|
||||
/// Title plus a quiet trailing hint, such as a shortcut.
|
||||
pub fn with_meta(
|
||||
title: impl Into<SharedString>,
|
||||
meta: impl Into<SharedString>,
|
||||
) -> impl Fn(&mut Window, &mut App) -> AnyView {
|
||||
let (title, meta) = (title.into(), meta.into());
|
||||
Self::rich(move |_, cx| meta_row(title.clone(), meta.clone(), cx).into_any_element())
|
||||
}
|
||||
|
||||
pub fn rich(
|
||||
content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
|
||||
) -> impl Fn(&mut Window, &mut App) -> AnyView {
|
||||
let content: Content = Rc::new(content);
|
||||
move |_, cx| {
|
||||
let content = content.clone();
|
||||
cx.new(|_| Tooltip { content }).into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Tooltip {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let content = (self.content)(window, cx);
|
||||
div()
|
||||
.pt(cx.theme().cursor_offset())
|
||||
.child(surface(content, cx))
|
||||
}
|
||||
}
|
||||
|
||||
fn meta_row(title: SharedString, meta: SharedString, cx: &App) -> Div {
|
||||
div().flex().items_center().gap_2().child(title).child(
|
||||
div()
|
||||
.text_color(cx.theme().colors.tooltip_fg.opacity(0.6))
|
||||
.child(meta),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TriggerState {
|
||||
visible: bool,
|
||||
mouse: Point<Pixels>,
|
||||
_pending: Option<Task<()>>,
|
||||
}
|
||||
|
||||
/// Tooltip with its own delay, and optional cursor tracking.
|
||||
#[derive(IntoElement)]
|
||||
pub struct TooltipTrigger {
|
||||
id: ElementId,
|
||||
child: AnyElement,
|
||||
content: Content,
|
||||
delay: Duration,
|
||||
follow: bool,
|
||||
}
|
||||
|
||||
impl TooltipTrigger {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
child: impl IntoElement,
|
||||
content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
child: child.into_any_element(),
|
||||
content: Rc::new(content),
|
||||
delay: DELAY,
|
||||
follow: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delay(mut self, delay: Duration) -> Self {
|
||||
self.delay = delay;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn follow_mouse(mut self) -> Self {
|
||||
self.follow = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TooltipTrigger {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| TriggerState::default());
|
||||
let (visible, mouse) = {
|
||||
let read = state.read(cx);
|
||||
(read.visible, read.mouse)
|
||||
};
|
||||
let (hover, track, press) = (state.clone(), state.clone(), state);
|
||||
let (delay, follow) = (self.delay, self.follow);
|
||||
let content = self.content;
|
||||
|
||||
div()
|
||||
.id(self.id)
|
||||
.child(self.child)
|
||||
.on_hover(move |hovered, window, cx| {
|
||||
if !*hovered {
|
||||
hover.update(cx, hide);
|
||||
return;
|
||||
}
|
||||
let weak = hover.downgrade();
|
||||
let task = window.spawn(cx, async move |cx| {
|
||||
cx.background_executor().timer(delay).await;
|
||||
let shown = cx.update(|window, cx| {
|
||||
weak.update(cx, |trigger, cx| {
|
||||
trigger.visible = true;
|
||||
trigger.mouse = window.mouse_position();
|
||||
trigger._pending = None;
|
||||
cx.notify();
|
||||
})
|
||||
});
|
||||
if let Err(error) = shown.and_then(|inner| inner) {
|
||||
log::error!("tooltip: trigger vanished before showing: {error:#}");
|
||||
}
|
||||
});
|
||||
hover.update(cx, |trigger, _| trigger._pending = Some(task));
|
||||
})
|
||||
.on_mouse_move(move |event, _, cx| {
|
||||
track.update(cx, |trigger, cx| {
|
||||
trigger.mouse = event.position;
|
||||
if follow && trigger.visible {
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
})
|
||||
.on_any_mouse_down(move |_, _, cx| press.update(cx, hide))
|
||||
.when(visible, |trigger| {
|
||||
let body = content(window, cx);
|
||||
let below = cx.theme().cursor_offset().to_pixels(window.rem_size());
|
||||
trigger.child(deferred(
|
||||
anchored()
|
||||
.position(mouse + point(Pixels::ZERO, below))
|
||||
.snap_to_window()
|
||||
.child(surface(body, cx)),
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn hide(trigger: &mut TriggerState, cx: &mut Context<TriggerState>) {
|
||||
trigger.visible = false;
|
||||
trigger._pending = None;
|
||||
cx.notify();
|
||||
}
|
||||
Reference in new issue
Block a user