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

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

+23 -8
View File
@@ -8,7 +8,7 @@ use ely_gpui_component::theme::Theme;
use gpui::{App, AsyncApp, WindowHandle, px};
use crate::{
pages,
pages, script,
shell::{Choice, Gallery},
};
@@ -36,6 +36,7 @@ async fn shoot_all(window: WindowHandle<Gallery>, dir: &Path, cx: &mut AsyncApp)
gallery.select(ix, cx);
gallery.choose(choice, window, cx);
})?;
script::park(window, cx)?;
cx.background_executor().timer(SETTLE).await;
let (viewport, max) = window.update(cx, |gallery, _, _| gallery.scroll_extent())?;
if viewport <= px(0.0) {
@@ -58,23 +59,27 @@ async fn shoot_all(window: WindowHandle<Gallery>, dir: &Path, cx: &mut AsyncApp)
window.update(cx, |gallery, _, cx| gallery.scroll_to(offset, cx))?;
cx.background_executor().timer(SCROLL_SETTLE).await;
}
if !page.script.is_empty() {
window.update(cx, |gallery, _, cx| gallery.select(ix, cx))?;
cx.background_executor().timer(SETTLE).await;
let shot = |step: &str| dir.join(format!("{}-{step}-{name}.png", page.slug));
script::play(window, page.script, shot, cx).await?;
}
}
}
Ok(())
}
/// This process's on-screen window, as CoreGraphics numbers it.
#[cfg(target_os = "macos")]
fn snapshot(path: &Path) -> Result<()> {
pub fn window_number() -> Result<u32> {
use core_foundation::{
base::{CFType, TCFType},
dictionary::CFDictionary,
number::CFNumber,
string::CFString,
};
use core_graphics::{
geometry::{CGPoint, CGRect, CGSize},
window,
};
use core_graphics::window;
let number = |dict: &CFDictionary<CFString, CFType>, key: &'static str| {
dict.find(CFString::from_static_string(key))
@@ -93,10 +98,20 @@ fn snapshot(path: &Path) -> Result<()> {
.find(|dict| number(dict, "kCGWindowOwnerPID") == Some(pid))
.and_then(|dict| number(&dict, "kCGWindowNumber"))
.context("gallery window not on screen")?;
u32::try_from(id).context("window number out of range")
}
#[cfg(target_os = "macos")]
pub fn snapshot(path: &Path) -> Result<()> {
use core_graphics::{
geometry::{CGPoint, CGRect, CGSize},
window,
};
let image = window::create_image(
CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(0.0, 0.0)),
window::kCGWindowListOptionIncludingWindow,
id as u32,
window_number()?,
window::kCGWindowImageBoundsIgnoreFraming | window::kCGWindowImageBestResolution,
)
.context("window capture returned nothing")?;
@@ -127,6 +142,6 @@ fn snapshot(path: &Path) -> Result<()> {
}
#[cfg(not(target_os = "macos"))]
fn snapshot(_: &Path) -> Result<()> {
pub fn snapshot(_: &Path) -> Result<()> {
bail!("capture needs macOS window APIs")
}
+4 -6
View File
@@ -1,5 +1,7 @@
mod capture;
mod pages;
mod probe;
mod script;
mod shell;
mod ui;
@@ -12,7 +14,7 @@ use gpui::{
actions, point, px, size,
};
actions!(gallery, [Quit, FocusNext, FocusPrev]);
actions!(gallery, [Quit]);
struct Args {
page: usize,
@@ -47,11 +49,7 @@ fn main() -> Result<()> {
.with_assets(Assets)
.run(move |cx: &mut App| {
ely_gpui_component::init(cx);
cx.bind_keys([
KeyBinding::new("cmd-q", Quit, None),
KeyBinding::new("tab", FocusNext, None),
KeyBinding::new("shift-tab", FocusPrev, None),
]);
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
cx.on_action(|_: &Quit, cx| cx.quit());
let bounds = Bounds::centered(None, size(px(1280.0), px(820.0)), cx);
let options = WindowOptions {
+1
View File
@@ -14,6 +14,7 @@ pub const PAGE: Page = Page {
title: "Buttons & Actions",
summary: "Seven variants, three sizes, one focus ring.",
render,
script: &[],
};
const VARIANTS: [(ButtonVariant, &str); 7] = [
+4
View File
@@ -4,12 +4,16 @@ mod theme;
use gpui::{AnyElement, App, Window};
use crate::script::Step;
pub struct Page {
pub number: u8,
pub slug: &'static str,
pub title: &'static str,
pub summary: &'static str,
pub render: fn(&mut Window, &mut App) -> AnyElement,
/// Interactions shot after the static pages.
pub script: &'static [Step],
}
pub const ALL: &[Page] = &[primitives::PAGE, buttons::PAGE, theme::PAGE];
-73
View File
@@ -1,73 +0,0 @@
use ely_gpui_component::{
primitives::{Icon, IconName},
theme::{ActiveTheme, IconSize},
};
use gpui::{AnyElement, App, InteractiveElement, IntoElement, ParentElement, Styled, Window, div};
use super::Page;
use crate::ui::{section, specimen, specimens};
pub const PAGE: Page = Page {
number: 1,
slug: "primitives",
title: "Primitives",
summary: "The smallest parts. Icons, dividers, overlays, focus.",
render,
};
fn render(_: &mut Window, cx: &mut App) -> AnyElement {
let colors = &cx.theme().colors;
let sizes = [
(IconSize::Xs, "12"),
(IconSize::Sm, "14"),
(IconSize::Md, "16"),
(IconSize::Lg, "20"),
(IconSize::Xl, "24"),
(IconSize::Xxl, "32"),
];
let tones = [
(colors.fg, "fg"),
(colors.fg_muted, "muted"),
(colors.focus, "focus"),
(colors.success, "success"),
(colors.warning, "warning"),
(colors.danger, "danger"),
];
div()
.child(
section(
"Icon",
"Lucide, drawn as a mask. Color comes from the theme.",
cx,
)
.child(specimens().children(
sizes.map(|(size, name)| {
specimen(name, Icon::new(IconName::Sparkles).size(size), cx)
}),
))
.child(specimens().children(tones.map(|(color, name)| {
specimen(name, Icon::new(IconName::CircleCheck).color(color), cx)
}))),
)
.child(
section("Icon set", "Every bundled icon.", cx).child(
div()
.flex()
.flex_wrap()
.gap_1()
.children(IconName::ALL.iter().enumerate().map(|(ix, &name)| {
div()
.id(ix)
.size_9()
.flex()
.items_center()
.justify_center()
.rounded_md()
.hover(|style| style.bg(colors.hover))
.child(Icon::new(name).color(colors.fg_muted))
})),
),
)
.into_any_element()
}
+80
View File
@@ -0,0 +1,80 @@
mod native;
mod parts;
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
use super::Page;
use crate::script::Step;
pub const PAGE: Page = Page {
number: 1,
slug: "primitives",
title: "Primitives",
summary: "The smallest parts. Most are gpui itself; Ely adds the rest.",
render,
script: SCRIPT,
};
const SCRIPT: &[Step] = &[
Step::Rest,
Step::Hover("tip-plain"),
Step::Wait(700),
Step::Shot("tooltip"),
Step::Hover("tip-meta"),
Step::Wait(700),
Step::Shot("tooltip-meta"),
Step::Hover("tip-rich"),
Step::Wait(700),
Step::Shot("tooltip-rich"),
Step::Hover("tip-follow"),
Step::Wait(200),
Step::Shot("tooltip-follow"),
Step::Rest,
Step::Down("prim-press"),
Step::Wait(700),
Step::Shot("pressed"),
Step::Up("prim-press"),
Step::Rest,
Step::Click("portal-toggle"),
Step::Shot("portal"),
Step::Click("portal-toggle"),
Step::Click("backdrop-open"),
Step::Wait(300),
Step::Shot("backdrop"),
Step::Click("backdrop-open"),
Step::Click("trap-c"),
Step::Key("tab"),
Step::Shot("trap-wrap"),
Step::Rest,
];
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
div()
.child(native::boxes(cx))
.child(native::text(cx))
.child(parts::icon(cx))
.child(parts::icon_set(cx))
.child(native::svg_demo(cx))
.child(parts::image(cx))
.child(native::canvas_demo(cx))
.child(parts::divider(cx))
.child(native::spacer(cx))
.child(native::portal(window, cx))
.child(parts::backdrop(window, cx))
.child(native::slot(cx))
.child(native::show(window, cx))
.child(native::each(cx))
.child(native::fragment(cx))
.child(parts::visually_hidden(cx))
.child(parts::focus_ring(cx))
.child(parts::focus_trap(window, cx))
.child(native::click_outside(window, cx))
.child(native::hover_area(window, cx))
.child(parts::pressable(window, cx))
.child(native::keyboard(window, cx))
.child(parts::measure(window, cx))
.child(parts::intersection(window, cx))
.child(native::clipboard(window, cx))
.child(parts::tooltip(cx))
.into_any_element()
}
+478
View File
@@ -0,0 +1,478 @@
use std::f32::consts::TAU;
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, Radius, TextSize},
};
use gpui::{
AnyElement, App, ClipboardItem, FontWeight, HighlightStyle, InteractiveElement, IntoElement,
ParentElement, PathBuilder, SharedString, StatefulInteractiveElement, Styled, StyledText,
Transformation, Window, anchored, canvas, deferred, div, point, prelude::*, px, radians, svg,
};
use crate::{
probe::probe,
ui::{code, row, section},
};
pub fn boxes(cx: &App) -> impl IntoElement + use<> {
let colors = &cx.theme().colors;
section(
"Box / View",
"gpui's div() is the box. Ely adds nothing.",
cx,
)
.child(
row()
.gap_4()
.child(
div()
.size_16()
.rounded_lg()
.border_1()
.border_color(colors.border)
.bg(colors.surface),
)
.child(div().w_32().h_16().rounded_lg().bg(colors.sunken))
.child(code("div()", cx)),
)
}
pub fn text(cx: &App) -> impl IntoElement + use<> {
let bold = HighlightStyle {
font_weight: Some(FontWeight::SEMIBOLD),
..Default::default()
};
section("Text", "A string is an element. StyledText adds runs.", cx).child(
row()
.gap_6()
.child("Plain text, as a child.")
.child(StyledText::new("One accent, used once.").with_highlights([(4..10, bold)]))
.child(code("StyledText::new(…)", cx)),
)
}
pub fn svg_demo(cx: &App) -> impl IntoElement + use<> {
let color = cx.theme().colors.fg_muted;
section(
"Svg",
"gpui's svg(). Any path from the asset source, tinted.",
cx,
)
.child(
row()
.gap_6()
.child(
svg()
.path(IconName::Compass.path())
.size_12()
.text_color(color),
)
.child(
svg()
.path(IconName::Compass.path())
.size_12()
.text_color(color)
.with_transformation(Transformation::rotate(radians(0.6))),
)
.child(code("svg().path(…)", cx)),
)
}
pub fn canvas_demo(cx: &App) -> impl IntoElement + use<> {
let color = cx.theme().colors.focus;
section("Canvas", "gpui's canvas(). Paint with paths.", cx).child(
row()
.gap_6()
.child(
div().w(px(320.0)).h(px(80.0)).child(
canvas(
|_, _, _| {},
move |bounds, _, window, _| {
let mut wave = PathBuilder::stroke(px(1.5));
for step in 0..=96 {
let t = step as f32 / 96.0;
let x = bounds.origin.x + bounds.size.width * t;
let lift = 0.5 - 0.38 * (t * TAU * 1.5).sin() * (1.0 - t * 0.5);
let y = bounds.origin.y + bounds.size.height * lift;
if step == 0 {
wave.move_to(point(x, y));
} else {
wave.line_to(point(x, y));
}
}
window.paint_path(wave.build().expect("wave path"), color);
},
)
.size_full(),
),
)
.child(code("canvas(prepaint, paint)", cx)),
)
}
pub fn spacer(cx: &App) -> impl IntoElement + use<> {
let colors = &cx.theme().colors;
section(
"Spacer",
"flex_1() takes the slack. No component needed.",
cx,
)
.child(
div()
.flex()
.items_center()
.w(px(420.0))
.h_10()
.px_3()
.rounded(cx.theme().radius(Radius::Md))
.border_1()
.border_color(colors.border)
.child(Icon::new(IconName::Menu).color(colors.fg_muted))
.child(div().flex_1())
.child(code("div().flex_1()", cx))
.child(div().flex_1())
.child(Icon::new(IconName::Settings).color(colors.fg_muted)),
)
}
pub fn portal(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let open = window.use_keyed_state("prim-portal", cx, |_, _| false);
let shown = *open.read(cx);
let theme = cx.theme();
let note = div()
.w(px(260.0))
.p_3()
.rounded(theme.radius(Radius::Lg))
.bg(theme.colors.overlay)
.border_1()
.border_color(theme.colors.border)
.shadow(theme.elevation(ely_gpui_component::theme::Elevation::Floating))
.text_size(theme.text_size(TextSize::Sm))
.child("Painted last, above the clipped box.");
section(
"Portal",
"deferred(anchored()) escapes clipping and paints on top.",
cx,
)
.child(
row()
.gap_6()
.child(
div()
.flex()
.items_start()
.w(px(220.0))
.h_16()
.p_3()
.overflow_hidden()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(theme.colors.border)
.child(probe(
"portal-toggle",
Button::new(
"portal-toggle",
if shown { "Hide note" } else { "Show note" },
)
.on_click(move |_, _, cx| {
open.update(cx, |open, cx| {
*open = !*open;
cx.notify();
})
}),
))
.when(shown, |clip| {
clip.child(deferred(
anchored().offset(point(px(0.0), px(8.0))).child(note),
))
}),
)
.child(code("deferred(anchored())", cx)),
)
}
fn framed(title: &'static str, body: impl IntoElement, cx: &App) -> impl IntoElement {
let theme = cx.theme();
div()
.flex()
.flex_col()
.gap_2()
.p_3()
.w(px(180.0))
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_subtle)
.child(title),
)
.child(body)
}
pub fn slot(cx: &App) -> impl IntoElement + use<> {
section(
"Slot",
"Take impl IntoElement. The caller fills the hole.",
cx,
)
.child(
row()
.gap_4()
.child(framed("slot: icon", Icon::new(IconName::Sparkles), cx))
.child(framed(
"slot: button",
Button::new("slot-button", "Save").primary(),
cx,
))
.child(code("fn framed(body: impl IntoElement)", cx)),
)
}
pub fn show(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let on = window.use_keyed_state("prim-show", cx, |_, _| true);
let shown = *on.read(cx);
let muted = cx.theme().colors.fg_muted;
section(
"Show / When",
".when() and .when_some() branch inside a builder.",
cx,
)
.child(
row()
.gap_4()
.child(
Button::new("show-toggle", if shown { "Hide" } else { "Show" }).on_click(
move |_, _, cx| {
on.update(cx, |on, cx| {
*on = !*on;
cx.notify();
})
},
),
)
.when(shown, |row| {
row.child(div().text_color(muted).child("Here while true."))
})
.child(code(".when(cond, |el| …)", cx)),
)
}
pub fn each(cx: &App) -> impl IntoElement + use<> {
let theme = cx.theme();
let steps = ["Draft", "Review", "Ship"];
section("For / Each", ".children() takes any iterator.", cx).child(
row()
.gap_6()
.children(steps.iter().enumerate().map(|(ix, step)| {
div()
.flex()
.items_center()
.gap_2()
.child(
div()
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_subtle)
.child(SharedString::from(format!("{:02}", ix + 1))),
)
.child(*step)
}))
.child(code(".children(iter)", cx)),
)
}
fn pair(cx: &App) -> [AnyElement; 2] {
let muted = cx.theme().colors.fg_muted;
[
Icon::new(IconName::Layers).color(muted).into_any_element(),
div()
.text_color(muted)
.child("two siblings, no wrapper")
.into_any_element(),
]
}
pub fn fragment(cx: &App) -> impl IntoElement + use<> {
section(
"Fragment",
"Return several elements; the parent adopts them.",
cx,
)
.child(
row()
.gap_2()
.children(pair(cx))
.child(div().w_6())
.child(code(".children([a, b])", cx)),
)
}
pub fn click_outside(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let open = window.use_keyed_state("prim-outside", cx, |_, _| false);
let shown = *open.read(cx);
let theme = cx.theme();
let close = open.clone();
section(
"ClickOutside",
"on_mouse_down_out closes what the pointer left.",
cx,
)
.child(
row()
.gap_4()
.child(
Button::new("outside-open", "Open panel")
.disabled(shown)
.on_click(move |_, _, cx| {
open.update(cx, |open, cx| {
*open = true;
cx.notify();
})
}),
)
.when(shown, |row| {
row.child(
div()
.id("outside-panel")
.px_3()
.py_2()
.rounded(theme.radius(Radius::Md))
.bg(theme.colors.sunken)
.child("Click anywhere else.")
.on_mouse_down_out(move |_, _, cx| {
close.update(cx, |open, cx| {
*open = false;
cx.notify();
})
}),
)
})
.child(code(".on_mouse_down_out(…)", cx)),
)
}
pub fn hover_area(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let inside = window.use_keyed_state("prim-hover", cx, |_, _| false);
let hovered = *inside.read(cx);
let theme = cx.theme();
section(
"HoverArea",
"on_hover reports enter and leave. .hover() restyles.",
cx,
)
.child(
row()
.gap_4()
.child(
div()
.id("hover-area")
.w(px(220.0))
.h_16()
.flex()
.items_center()
.justify_center()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.hover(|style| style.bg(theme.colors.hover))
.child(if hovered {
"Pointer inside"
} else {
"Pointer outside"
})
.on_hover(move |hovered, _, cx| {
inside.update(cx, |inside, cx| {
*inside = *hovered;
cx.notify();
})
}),
)
.child(code(".on_hover(…)", cx)),
)
}
pub fn keyboard(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let focus = window.use_keyed_state("prim-keys-focus", cx, |_, cx| cx.focus_handle());
let last = window.use_keyed_state("prim-keys", cx, |_, _| SharedString::from("—"));
let handle = focus.read(cx).clone();
let shown = last.read(cx).clone();
let theme = cx.theme();
section(
"KeyboardHandler",
"on_key_down on a focused element. Click, then type.",
cx,
)
.child(
row()
.gap_4()
.child(
div()
.id("keys")
.track_focus(&handle)
.w(px(220.0))
.h_16()
.flex()
.items_center()
.justify_center()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.font_family(theme.mono_family.clone())
.focus_ring(cx)
.child(shown)
.on_key_down(move |event, _, cx| {
let stroke = SharedString::from(event.keystroke.to_string());
last.update(cx, |last, cx| {
*last = stroke;
cx.notify();
})
}),
)
.child(code(".on_key_down(…)", cx)),
)
}
pub fn clipboard(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let pasted = window.use_keyed_state("prim-clip", cx, |_, _| None::<SharedString>);
let shown = pasted.read(cx).clone();
let muted = cx.theme().colors.fg_muted;
section(
"Clipboard",
"write_to_clipboard and read_from_clipboard on App.",
cx,
)
.child(
row()
.gap_3()
.child(
Button::new("clip-copy", "Copy “Ely”")
.icon(IconName::Copy)
.on_click(|_, _, cx| {
cx.write_to_clipboard(ClipboardItem::new_string("Ely".into()))
}),
)
.child(
Button::new("clip-paste", "Paste")
.variant(ButtonVariant::Ghost)
.icon(IconName::Clipboard)
.on_click(move |_, _, cx| {
let text = cx.read_from_clipboard().and_then(|item| item.text());
pasted.update(cx, |pasted, cx| {
*pasted = Some(match text {
Some(text) => text.into(),
None => "The clipboard holds no text.".into(),
});
cx.notify();
})
}),
)
.when_some(shown, |row, text| {
row.child(div().text_color(muted).child(text))
})
.child(code("cx.write_to_clipboard(…)", cx)),
)
}
+464
View File
@@ -0,0 +1,464 @@
use std::{path::Path, time::Duration};
use ely_gpui_component::{
buttons::{Button, IconButton},
primitives::{
Backdrop, Divider, FocusRing, FocusScope, Icon, IconName, Image, IntersectionObserver,
Measure, Pressable, Tooltip, TooltipTrigger,
},
theme::{ActiveTheme, IconSize, Radius, TextSize},
};
use gpui::{
App, FontWeight, InteractiveElement, IntoElement, ParentElement, Pixels, SharedString, Size,
StatefulInteractiveElement, Styled, Window, div, prelude::*, px,
};
use crate::{
probe::probe,
ui::{blocked, code, row, section, specimen, specimens},
};
const ATRIUM: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/examples/gallery/assets/atrium.jpg"
);
fn counter(window: &mut Window, cx: &mut App, key: &'static str) -> gpui::Entity<u32> {
window.use_keyed_state(key, cx, |_, _| 0u32)
}
pub fn icon(cx: &App) -> impl IntoElement + use<> {
let colors = &cx.theme().colors;
let sizes = [
(IconSize::Xs, "12"),
(IconSize::Sm, "14"),
(IconSize::Md, "16"),
(IconSize::Lg, "20"),
(IconSize::Xl, "24"),
(IconSize::Xxl, "32"),
];
let tones = [
(colors.fg, "fg"),
(colors.fg_muted, "muted"),
(colors.focus, "focus"),
(colors.success, "success"),
(colors.warning, "warning"),
(colors.danger, "danger"),
];
section(
"Icon",
"Lucide, drawn as a mask. Color comes from the theme.",
cx,
)
.child(specimens().children(
sizes.map(|(size, name)| specimen(name, Icon::new(IconName::Sparkles).size(size), cx)),
))
.child(
specimens().children(tones.map(|(color, name)| {
specimen(name, Icon::new(IconName::CircleCheck).color(color), cx)
})),
)
}
pub fn icon_set(cx: &App) -> impl IntoElement + use<> {
let colors = &cx.theme().colors;
section(
"IconSet / IconRegistry",
"IconName lists every bundled icon. Hover for its name.",
cx,
)
.child(
div()
.flex()
.flex_wrap()
.gap_1()
.children(IconName::ALL.iter().map(|&name| {
div()
.id(name.name())
.size_9()
.flex()
.items_center()
.justify_center()
.rounded_md()
.hover(|style| style.bg(colors.hover))
.tooltip(Tooltip::text(name.name()))
.child(Icon::new(name).color(colors.fg_muted))
})),
)
}
fn tile(image: Image, cx: &App) -> Image {
image
.w(px(198.0))
.h(px(132.0))
.rounded(cx.theme().radius(Radius::Lg))
}
pub fn image(cx: &App) -> impl IntoElement + use<> {
section(
"Image",
"Loading pulses. Failure and emptiness show a quiet mark.",
cx,
)
.child(
specimens()
.child(specimen(
"loaded",
tile(Image::new("img-atrium", Path::new(ATRIUM)), cx),
cx,
))
.child(specimen(
"failed",
tile(Image::new("img-missing", Path::new("/missing/ely.png")), cx),
cx,
))
.child(specimen(
"empty",
tile(Image::placeholder("img-empty"), cx),
cx,
)),
)
}
pub fn divider(cx: &App) -> impl IntoElement + use<> {
section(
"Divider / Separator",
"One hairline. Across, down, or holding a word.",
cx,
)
.child(
div()
.flex()
.flex_col()
.gap_5()
.w(px(420.0))
.child(Divider::horizontal())
.child(Divider::horizontal().label("or"))
.child(
row()
.h_6()
.child("Left")
.child(Divider::vertical())
.child("Right"),
),
)
}
pub fn backdrop(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let open = window.use_keyed_state("prim-backdrop", cx, |_, _| false);
let shown = *open.read(cx);
let theme = cx.theme();
let (show, dismiss, close) = (open.clone(), open.clone(), open);
let set = |state: &gpui::Entity<bool>, value: bool, cx: &mut App| {
state.update(cx, |open, cx| {
*open = value;
cx.notify();
})
};
let card = div()
.w(px(320.0))
.p_5()
.flex()
.flex_col()
.gap_3()
.rounded(theme.radius(Radius::Xl))
.bg(theme.colors.overlay)
.shadow(theme.elevation(ely_gpui_component::theme::Elevation::Modal))
.child(div().font_weight(FontWeight::SEMIBOLD).child("Backdrop"))
.child(
div()
.text_color(theme.colors.fg_muted)
.child("Click outside to close."),
)
.child(div().flex().justify_end().child(
Button::new("backdrop-close", "Close").on_click(move |_, _, cx| set(&close, false, cx)),
));
section(
"Overlay / Backdrop",
"A scrim that fades in, blocks the page, and hears outside clicks.",
cx,
)
.child(
row()
.child(probe(
"backdrop-open",
Button::new("backdrop-open", "Show backdrop")
.on_click(move |_, _, cx| set(&show, true, cx)),
))
.when(shown, |row| {
row.child(
Backdrop::new("prim-backdrop-layer")
.on_dismiss(move |_, cx| set(&dismiss, false, cx))
.child(card),
)
}),
)
}
pub fn visually_hidden(cx: &App) -> impl IntoElement + use<> {
section("VisuallyHidden", "Text for screen readers only.", cx).child(blocked(
"Blocked: gpui 0.2.2 has no accessibility tree to speak to.",
cx,
))
}
fn chip(id: &'static str, label: &'static str, cx: &App) -> impl IntoElement + use<> {
let theme = cx.theme();
div()
.id(id)
.tab_index(0)
.px_3()
.py_1p5()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(theme.colors.border)
.focus_ring(cx)
.child(label)
}
pub fn focus_ring(cx: &App) -> impl IntoElement + use<> {
section(
"FocusRing",
"Any focusable element. Press Tab to walk these.",
cx,
)
.child(
row()
.child(chip("ring-a", "First", cx))
.child(chip("ring-b", "Second", cx))
.child(chip("ring-c", "Third", cx))
.child(code(".focus_ring(cx)", cx)),
)
}
pub fn focus_trap(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let handle = window
.use_keyed_state("prim-trap", cx, |_, cx| cx.focus_handle())
.read(cx)
.clone();
let theme = cx.theme();
section(
"FocusScope / FocusTrap",
"Tab into the box. Tab then circles inside it.",
cx,
)
.child(
FocusScope::new(&handle).trap().flex().child(
row()
.p_3()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border_strong)
.child(chip("trap-a", "One", cx))
.child(chip("trap-b", "Two", cx))
.child(probe("trap-c", chip("trap-c", "Three", cx))),
),
)
}
pub fn pressable(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let taps = counter(window, cx, "prim-taps");
let holds = counter(window, cx, "prim-holds");
let (tap_count, hold_count) = (*taps.read(cx), *holds.read(cx));
let bump = |count: &gpui::Entity<u32>, cx: &mut App| {
count.update(cx, |count, cx| {
*count += 1;
cx.notify();
})
};
section(
"Pressable",
"Knows when it is held. Hold half a second for a long press.",
cx,
)
.child(
row()
.gap_6()
.child(probe(
"prim-press",
Pressable::new("prim-press", |pressed, _, cx| {
let theme = cx.theme();
div()
.w(px(160.0))
.h_16()
.flex()
.items_center()
.justify_center()
.rounded(theme.radius(Radius::Lg))
.bg(if pressed {
theme.colors.active
} else {
theme.colors.sunken
})
.border_1()
.border_color(theme.colors.border)
.child(if pressed { "Holding" } else { "Press" })
.into_any_element()
})
.on_press(move |_, cx| bump(&taps, cx))
.on_long_press(move |_, cx| bump(&holds, cx)),
))
.child(code(format!("taps {tap_count} · holds {hold_count}"), cx)),
)
}
pub fn measure(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let size = window.use_keyed_state("prim-size", cx, |_, _| None::<Size<Pixels>>);
let wide = window.use_keyed_state("prim-wide", cx, |_, _| false);
let (measured, is_wide) = (*size.read(cx), *wide.read(cx));
let theme = cx.theme();
let readout = match measured {
Some(size) => format!(
"{:.0} × {:.0}",
f32::from(size.width),
f32::from(size.height)
),
None => "measuring…".to_string(),
};
section(
"Measure",
"Reports bounds when they change. This is also the ResizeObserver.",
cx,
)
.child(
row()
.child(
Button::new("measure-toggle", if is_wide { "Narrow" } else { "Widen" }).on_click(
move |_, _, cx| {
wide.update(cx, |wide, cx| {
*wide = !*wide;
cx.notify();
})
},
),
)
.child(code(readout, cx)),
)
.child(
Measure::new("prim-measured", move |bounds, _, cx| {
size.update(cx, |size, cx| {
*size = Some(bounds.size);
cx.notify();
})
})
.w(px(if is_wide { 420.0 } else { 220.0 }))
.p_3()
.rounded(theme.radius(Radius::Lg))
.bg(theme.colors.sunken)
.text_color(theme.colors.fg_muted)
.child("Text wraps to the width it gets. The box reports each new size once."),
)
}
pub fn intersection(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let seen = window.use_keyed_state("prim-seen", cx, |_, _| false);
let visible = *seen.read(cx);
let theme = cx.theme();
let spacer = || div().h(px(140.0));
let status = if visible { "in view" } else { "out of view" };
section(
"IntersectionObserver",
"Scroll the box. The marker reports entering and leaving.",
cx,
)
.child(
row()
.gap_6()
.child(
div()
.id("prim-scroller")
.w(px(260.0))
.h(px(120.0))
.overflow_y_scroll()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.child(spacer())
.child(
IntersectionObserver::new("prim-marker", move |visible, _, cx| {
seen.update(cx, |seen, cx| {
*seen = visible;
cx.notify();
})
})
.mx_3()
.p_2()
.rounded(theme.radius(Radius::Md))
.bg(theme.colors.info_subtle)
.text_color(theme.colors.info)
.child("Marker"),
)
.child(spacer()),
)
.child(code(status, cx)),
)
}
pub fn tooltip(cx: &App) -> impl IntoElement + use<> {
let theme = cx.theme();
let muted = theme.colors.tooltip_fg.opacity(0.7);
let lane = div()
.w(px(260.0))
.h_12()
.flex()
.items_center()
.justify_center()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.text_color(theme.colors.fg_muted)
.child("Move across me");
section(
"Tooltip Trigger",
"gpui's trigger with Ely's body, or Ely's own trigger for delay and tracking.",
cx,
)
.child(
row()
.gap_2()
.child(probe(
"tip-plain",
div()
.id("tip-plain")
.child(IconButton::new("tip-a", IconName::Copy))
.tooltip(Tooltip::text("Copy")),
))
.child(probe(
"tip-meta",
div()
.id("tip-meta")
.child(IconButton::new("tip-b", IconName::Search))
.tooltip(Tooltip::with_meta("Search", "⌘K")),
))
.child(probe(
"tip-rich",
div()
.id("tip-rich")
.child(IconButton::new("tip-c", IconName::Info))
.tooltip(Tooltip::rich(move |_, _| {
div()
.flex()
.flex_col()
.gap_0p5()
.child(div().font_weight(FontWeight::MEDIUM).child("Rich content"))
.child(div().text_color(muted).child("Any element fits here."))
.into_any_element()
})),
))
.child(div().w_4())
.child(probe(
"tip-follow",
TooltipTrigger::new("tip-follow", lane, |_, _| {
SharedString::from("No delay. I follow.").into_any_element()
})
.delay(Duration::ZERO)
.follow_mouse(),
)),
)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_subtle)
.child("Ely's trigger waits 400ms by default; gpui's waits 500ms."),
)
}
+1
View File
@@ -18,6 +18,7 @@ pub const PAGE: Page = Page {
title: "Theme",
summary: "Tokens every component reads. Light and dark share one grammar.",
render,
script: &[],
};
fn render(_: &mut Window, cx: &mut App) -> AnyElement {
+25
View File
@@ -0,0 +1,25 @@
use std::collections::HashMap;
use ely_gpui_component::primitives::Measure;
use gpui::{App, Bounds, Global, IntoElement, ParentElement, Pixels};
/// Window bounds of named demo elements, for scripted capture.
#[derive(Default)]
pub struct Probes(HashMap<&'static str, Bounds<Pixels>>);
impl Global for Probes {}
impl Probes {
pub fn get(key: &str, cx: &App) -> Option<Bounds<Pixels>> {
cx.try_global::<Probes>()
.and_then(|probes| probes.0.get(key).copied())
}
}
/// Wraps `element` so scripts can find it by `key`.
pub fn probe(key: &'static str, element: impl IntoElement) -> Measure {
Measure::new(key, move |bounds, _, cx| {
cx.default_global::<Probes>().0.insert(key, bounds);
})
.child(element)
}
+148
View File
@@ -0,0 +1,148 @@
use std::{path::PathBuf, time::Duration};
use anyhow::{Context as _, Result};
use gpui::{AsyncApp, Keystroke, Pixels, Point, WindowHandle, point, px};
use crate::{capture::snapshot, probe::Probes, shell::Gallery};
/// One scripted input, aimed at a probe by key.
pub enum Step {
Rest,
Hover(&'static str),
Down(&'static str),
Up(&'static str),
Click(&'static str),
Key(&'static str),
Wait(u64),
Shot(&'static str),
}
const FRAME: Duration = Duration::from_millis(120);
#[derive(Clone, Copy)]
enum Mouse {
Move,
Down,
Up,
}
/// Parks the pointer on empty sidebar space.
pub fn park(window: WindowHandle<Gallery>, cx: &mut AsyncApp) -> Result<()> {
send(window, Mouse::Move, point(px(24.0), px(720.0)), cx)
}
pub async fn play(
window: WindowHandle<Gallery>,
steps: &[Step],
path: impl Fn(&str) -> PathBuf,
cx: &mut AsyncApp,
) -> Result<()> {
for step in steps {
match *step {
Step::Rest => park(window, cx)?,
Step::Hover(key) => {
let at = target(window, key, cx).await?;
send(window, Mouse::Move, at, cx)?;
}
Step::Down(key) => {
let at = target(window, key, cx).await?;
send(window, Mouse::Move, at, cx)?;
send(window, Mouse::Down, at, cx)?;
}
Step::Up(key) => {
let at = target(window, key, cx).await?;
send(window, Mouse::Up, at, cx)?;
}
Step::Click(key) => {
let at = target(window, key, cx).await?;
send(window, Mouse::Move, at, cx)?;
send(window, Mouse::Down, at, cx)?;
send(window, Mouse::Up, at, cx)?;
}
Step::Key(stroke) => {
let stroke =
Keystroke::parse(stroke).with_context(|| format!("bad keystroke {stroke}"))?;
window.update(cx, |_, window, cx| {
window.dispatch_keystroke(stroke, cx);
})?;
}
Step::Wait(ms) => {
cx.background_executor()
.timer(Duration::from_millis(ms))
.await
}
Step::Shot(name) => {
let file = path(name);
snapshot(&file)?;
log::info!("script: wrote {}", file.display());
}
}
cx.background_executor().timer(FRAME).await;
}
Ok(())
}
fn send(
window: WindowHandle<Gallery>,
kind: Mouse,
at: Point<Pixels>,
cx: &mut AsyncApp,
) -> Result<()> {
let height = window.update(cx, |_, window, _| window.viewport_size().height)?;
post(kind, f64::from(at.x), f64::from(height - at.y))
}
/// Queues a real mouse event on this app. No permission needed.
#[cfg(target_os = "macos")]
fn post(kind: Mouse, x: f64, y: f64) -> Result<()> {
use cocoa::{
appkit::{NSApp, NSApplication, NSEvent, NSEventModifierFlags, NSEventType},
base::{NO, id, nil},
foundation::NSPoint,
};
let number = crate::capture::window_number()?;
let kind = match kind {
Mouse::Move => NSEventType::NSMouseMoved,
Mouse::Down => NSEventType::NSLeftMouseDown,
Mouse::Up => NSEventType::NSLeftMouseUp,
};
unsafe {
let event = <id as NSEvent>::mouseEventWithType_location_modifierFlags_timestamp_windowNumber_context_eventNumber_clickCount_pressure_(
nil,
kind,
NSPoint::new(x, y),
NSEventModifierFlags::empty(),
0.0,
i64::from(number),
nil,
0,
1,
1.0,
);
anyhow::ensure!(event != nil, "AppKit refused a synthetic mouse event");
NSApp().postEvent_atStart_(event, NO);
}
Ok(())
}
#[cfg(not(target_os = "macos"))]
fn post(_: Mouse, _: f64, _: f64) -> Result<()> {
anyhow::bail!("scripted mouse input needs macOS")
}
async fn target(
window: WindowHandle<Gallery>,
key: &str,
cx: &mut AsyncApp,
) -> Result<Point<Pixels>> {
let bounds = cx
.update(|cx| Probes::get(key, cx))?
.with_context(|| format!("probe {key} never rendered"))?;
if window.update(cx, |gallery, _, cx| gallery.reveal(bounds, cx))? {
cx.background_executor().timer(FRAME).await;
}
let bounds = cx
.update(|cx| Probes::get(key, cx))?
.with_context(|| format!("probe {key} vanished after scrolling"))?;
Ok(bounds.center())
}
+21 -10
View File
@@ -1,15 +1,15 @@
use ely_gpui_component::{
buttons::{ButtonVariant, IconButton},
primitives::IconName,
primitives::{FocusScope, IconName},
theme::{ActiveTheme, ControlSize, Mode, Radius, TextSize, Theme},
};
use gpui::{
Context, FocusHandle, FontWeight, InteractiveElement, IntoElement, ParentElement, Pixels,
Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
div, point, prelude::*, px,
Bounds, Context, FocusHandle, FontWeight, InteractiveElement, IntoElement, ParentElement,
Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription,
Window, div, point, prelude::*, px,
};
use crate::{FocusNext, FocusPrev, pages};
use crate::pages;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Choice {
@@ -59,6 +59,21 @@ impl Gallery {
)
}
/// Scrolls until `target` sits inside the page. True if it moved.
pub fn reveal(&mut self, target: Bounds<Pixels>, cx: &mut Context<Self>) -> bool {
let (view, margin) = (self.scroll.bounds(), px(96.0));
let shift = if target.top() < view.top() + margin {
target.top() - view.top() - margin
} else if target.bottom() > view.bottom() - margin {
target.bottom() - view.bottom() + margin
} else {
return false;
};
let y = (-self.scroll.offset().y + shift).clamp(px(0.0), self.scroll.max_offset().height);
self.scroll_to(y, cx);
true
}
pub fn scroll_to(&mut self, y: Pixels, cx: &mut Context<Self>) {
self.scroll.set_offset(point(px(0.0), -y));
cx.notify();
@@ -183,11 +198,7 @@ impl Render for Gallery {
let theme = cx.theme();
let colors = &theme.colors;
div()
.id("gallery")
.track_focus(&self.focus)
.on_action(|_: &FocusNext, window, _| window.focus_next())
.on_action(|_: &FocusPrev, window, _| window.focus_prev())
FocusScope::new(&self.focus)
.size_full()
.flex()
.bg(colors.bg)
+23
View File
@@ -51,3 +51,26 @@ pub fn specimen(caption: impl Into<SharedString>, body: impl IntoElement, cx: &A
.child(body)
.child(label(caption, cx))
}
/// Monospace hint naming the call.
pub fn code(text: impl Into<SharedString>, cx: &App) -> impl IntoElement {
let theme = cx.theme();
div()
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_muted)
.child(text.into())
}
/// Honest note for an entry gpui cannot support.
pub fn blocked(text: impl Into<SharedString>, cx: &App) -> impl IntoElement {
let theme = cx.theme();
div()
.flex()
.items_center()
.gap_2()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.warning)
.child(div().size_1p5().rounded_full().bg(theme.colors.warning))
.child(text.into())
}