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:
ZacharyZhang-NY committed 2026-09-25 00:39:06 -04:00
1 parent abf4cbcfc6
commit a13343c2a5
38 files changed
+2165 -157

No files matched your search

+83
View File
@@ -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)),
),
),
)
}
}
+58
View File
@@ -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()),
}
}
}
+96
View File
@@ -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);
}
}
+8
View File
@@ -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",
+105
View File
@@ -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)
}
}
+120
View File
@@ -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(),
)
}
}
+14
View File
@@ -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};
+111
View File
@@ -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();
})
})
}
}
+192
View File
@@ -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();
}