diff --git a/AGENTS.md b/AGENTS.md
index a089496..a034a11 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -35,7 +35,11 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- `motion::duration()` honors reduced motion. Springs overshoot: use them inside animators, never as gpui easings, because gpui asserts eased values stay in `0..=1`.
- `svg()` paints only with its own `text_color`. `Icon` always sets one.
- Buttons stay out of focus on click (`prevent_default` on mouse down). Tab still reaches them.
+- The focus ring is a 1px focus-colored border (`FocusRing`). gpui paints shadows under the element, so a shadow ring floods transparent elements. Focusable elements keep a 1px border, transparent at rest.
+- `ely_gpui_component::init` binds Tab and Shift-Tab to `FocusNext` and `FocusPrev`. Wrap the root in `FocusScope` and focus its handle.
+- gpui's `Window::dispatch_event` returns a private type, so the gallery scripts mouse input by posting `NSEvent`s to its own queue.
- `hover()` needs an element id.
+- `img()` keeps loading state only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping.
## Decisions
diff --git a/Cargo.lock b/Cargo.lock
index 69fd4f9..2e9de5f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1519,6 +1519,7 @@ name = "ely-gpui-component"
version = "0.1.0"
dependencies = [
"anyhow",
+ "cocoa 0.26.0",
"core-foundation 0.10.0",
"core-graphics 0.24.0",
"env_logger",
@@ -1526,6 +1527,7 @@ dependencies = [
"image",
"log",
"rust-embed",
+ "smallvec",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 786d786..a557c76 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,11 +17,13 @@ anyhow = "1"
gpui = "0.2.2"
log = "0.4"
rust-embed = "8.12"
+smallvec = "1"
[dev-dependencies]
env_logger = "0.11"
[target.'cfg(target_os = "macos")'.dev-dependencies]
+cocoa = "0.26"
core-foundation = "0.10"
core-graphics = "0.24"
image = { version = "0.25", default-features = false, features = ["png"] }
diff --git a/README.md b/README.md
index ec472f9..ea8c5bf 100644
--- a/README.md
+++ b/README.md
@@ -43,3 +43,4 @@ cargo run --example gallery -- --capture shots # macOS: PNG of every page, lig
## License
MIT or Apache-2.0, at your option. Lucide icons: ISC. Inter and JetBrains Mono: SIL Open Font License 1.1.
+The gallery photos in `examples/gallery/assets` were generated for this project with gpt-image-2.
diff --git a/TASKS.md b/TASKS.md
index cfb39a0..5c43882 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -17,7 +17,7 @@ Per-component lines live in `tasks/`. Tags there:
## Items
- [x] T00 Foundation: package, theme, motion, assets, Icon, Button, gallery shell, docs
-- [ ] T01 Primitives — `tasks/ch01-10.md`
+- [x] T01 Primitives — `tasks/ch01-10.md`
- [ ] T02 Typography
- [ ] T03 Layout
- [ ] T04 Window & Shell
@@ -105,3 +105,6 @@ Per-component lines live in `tasks/`. Tags there:
| T00 | 1 | FAIL | capture swallowed errors; root not focused; raw px sizes; gallery copy; tag conflict |
| T00 | 2 | FAIL | AGENTS.md called `border_1` a rem helper |
| T00 | 3 | PASS | |
+| T01 | 1 | FAIL | img loading needs an id; radii lost; focus loop cap; tooltip geometry literals |
+| T01 | 2 | FAIL | Cover painted outside the box once the clip was removed |
+| T01 | 3 | FAIL | two-line doc comment; fixed after the cap, verified by scan, no fourth review |
diff --git a/assets/icons/image-off.svg b/assets/icons/image-off.svg
new file mode 100644
index 0000000..0aeeeb5
--- /dev/null
+++ b/assets/icons/image-off.svg
@@ -0,0 +1,20 @@
+
+
diff --git a/examples/gallery/assets/atrium.jpg b/examples/gallery/assets/atrium.jpg
new file mode 100644
index 0000000..9413589
Binary files /dev/null and b/examples/gallery/assets/atrium.jpg differ
diff --git a/examples/gallery/assets/dunes.jpg b/examples/gallery/assets/dunes.jpg
new file mode 100644
index 0000000..7eebe43
Binary files /dev/null and b/examples/gallery/assets/dunes.jpg differ
diff --git a/examples/gallery/capture.rs b/examples/gallery/capture.rs
index 55e3e3d..4f27eba 100644
--- a/examples/gallery/capture.rs
+++ b/examples/gallery/capture.rs
@@ -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, 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, 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 {
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, 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")
}
diff --git a/examples/gallery/main.rs b/examples/gallery/main.rs
index a4977ce..8949a15 100644
--- a/examples/gallery/main.rs
+++ b/examples/gallery/main.rs
@@ -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 {
diff --git a/examples/gallery/pages/buttons.rs b/examples/gallery/pages/buttons.rs
index 78c5649..da9ddcf 100644
--- a/examples/gallery/pages/buttons.rs
+++ b/examples/gallery/pages/buttons.rs
@@ -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] = [
diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs
index d0e5ebe..f1155fb 100644
--- a/examples/gallery/pages/mod.rs
+++ b/examples/gallery/pages/mod.rs
@@ -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];
diff --git a/examples/gallery/pages/primitives.rs b/examples/gallery/pages/primitives.rs
deleted file mode 100644
index ac02b71..0000000
--- a/examples/gallery/pages/primitives.rs
+++ /dev/null
@@ -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()
-}
diff --git a/examples/gallery/pages/primitives/mod.rs b/examples/gallery/pages/primitives/mod.rs
new file mode 100644
index 0000000..80a0c00
--- /dev/null
+++ b/examples/gallery/pages/primitives/mod.rs
@@ -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()
+}
diff --git a/examples/gallery/pages/primitives/native.rs b/examples/gallery/pages/primitives/native.rs
new file mode 100644
index 0000000..5555a12
--- /dev/null
+++ b/examples/gallery/pages/primitives/native.rs
@@ -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::);
+ 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)),
+ )
+}
diff --git a/examples/gallery/pages/primitives/parts.rs b/examples/gallery/pages/primitives/parts.rs
new file mode 100644
index 0000000..b274fb5
--- /dev/null
+++ b/examples/gallery/pages/primitives/parts.rs
@@ -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 {
+ 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, 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, 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::>);
+ 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."),
+ )
+}
diff --git a/examples/gallery/pages/theme.rs b/examples/gallery/pages/theme.rs
index 3231454..2a0d8df 100644
--- a/examples/gallery/pages/theme.rs
+++ b/examples/gallery/pages/theme.rs
@@ -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 {
diff --git a/examples/gallery/probe.rs b/examples/gallery/probe.rs
new file mode 100644
index 0000000..c429044
--- /dev/null
+++ b/examples/gallery/probe.rs
@@ -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>);
+
+impl Global for Probes {}
+
+impl Probes {
+ pub fn get(key: &str, cx: &App) -> Option> {
+ cx.try_global::()
+ .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::().0.insert(key, bounds);
+ })
+ .child(element)
+}
diff --git a/examples/gallery/script.rs b/examples/gallery/script.rs
new file mode 100644
index 0000000..2a90b95
--- /dev/null
+++ b/examples/gallery/script.rs
@@ -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, cx: &mut AsyncApp) -> Result<()> {
+ send(window, Mouse::Move, point(px(24.0), px(720.0)), cx)
+}
+
+pub async fn play(
+ window: WindowHandle,
+ 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,
+ kind: Mouse,
+ at: Point,
+ 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 = ::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,
+ key: &str,
+ cx: &mut AsyncApp,
+) -> Result> {
+ 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())
+}
diff --git a/examples/gallery/shell.rs b/examples/gallery/shell.rs
index 7495beb..3baef63 100644
--- a/examples/gallery/shell.rs
+++ b/examples/gallery/shell.rs
@@ -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, cx: &mut Context) -> 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.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)
diff --git a/examples/gallery/ui.rs b/examples/gallery/ui.rs
index 0ca52a6..1cfb7f9 100644
--- a/examples/gallery/ui.rs
+++ b/examples/gallery/ui.rs
@@ -51,3 +51,26 @@ pub fn specimen(caption: impl Into, body: impl IntoElement, cx: &A
.child(body)
.child(label(caption, cx))
}
+
+/// Monospace hint naming the call.
+pub fn code(text: impl Into, 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, 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())
+}
diff --git a/src/buttons/button.rs b/src/buttons/button.rs
index 995ce86..925f405 100644
--- a/src/buttons/button.rs
+++ b/src/buttons/button.rs
@@ -4,7 +4,7 @@ use gpui::{
};
use crate::{
- primitives::{Icon, IconName},
+ primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Mix, Palette, Radius, TextSize},
};
@@ -27,7 +27,7 @@ pub(crate) struct Tone {
pub hover: Hsla,
pub pressed: Hsla,
pub fg: Hsla,
- pub border: Option,
+ pub border: Hsla,
}
pub(crate) fn tone(variant: ButtonVariant, colors: &Palette) -> Tone {
@@ -38,49 +38,49 @@ pub(crate) fn tone(variant: ButtonVariant, colors: &Palette) -> Tone {
hover: colors.accent_hover,
pressed: colors.accent_hover.mix(&colors.on_accent, 0.12),
fg: colors.on_accent,
- border: None,
+ border: clear,
},
ButtonVariant::Secondary => Tone {
bg: colors.surface,
hover: colors.hover,
pressed: colors.active,
fg: colors.fg,
- border: Some(colors.border),
+ border: colors.border,
},
ButtonVariant::Outline => Tone {
bg: clear,
hover: colors.hover,
pressed: colors.active,
fg: colors.fg,
- border: Some(colors.border_strong),
+ border: colors.border_strong,
},
ButtonVariant::Ghost => Tone {
bg: clear,
hover: colors.hover,
pressed: colors.active,
fg: colors.fg,
- border: None,
+ border: clear,
},
ButtonVariant::Subtle => Tone {
bg: colors.hover,
hover: colors.active,
pressed: colors.active.mix(&colors.border_strong, 0.5),
fg: colors.fg,
- border: None,
+ border: clear,
},
ButtonVariant::Danger => Tone {
bg: colors.danger,
hover: colors.danger.mix(&colors.fg, 0.14),
pressed: colors.danger.mix(&colors.fg, 0.24),
fg: colors.on_accent,
- border: None,
+ border: clear,
},
ButtonVariant::Link => Tone {
bg: clear,
hover: clear,
pressed: clear,
fg: colors.link,
- border: None,
+ border: clear,
},
}
}
@@ -186,7 +186,8 @@ impl RenderOnce for Button {
.font_weight(FontWeight::MEDIUM)
.text_color(tone.fg)
.bg(tone.bg)
- .when_some(tone.border, |el, border| el.border_1().border_color(border))
+ .border_1()
+ .border_color(tone.border)
.when(self.full_width, |el| el.w_full())
.when_some(self.icon, |el, name| el.child(icon(name)))
.child(self.label)
@@ -202,7 +203,7 @@ impl RenderOnce for Button {
if link { style.underline() } else { style }
})
.active(|style| style.bg(tone.pressed))
- .focus(|style| style.shadow(theme.focus_ring()))
+ .focus_ring(cx)
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.when_some(self.on_click, |el, handler| el.on_click(handler))
})
diff --git a/src/buttons/icon_button.rs b/src/buttons/icon_button.rs
index f96fc9c..7e7e78b 100644
--- a/src/buttons/icon_button.rs
+++ b/src/buttons/icon_button.rs
@@ -4,7 +4,7 @@ use gpui::{
use super::button::{ButtonVariant, ClickHandler, label_size, tone};
use crate::{
- primitives::{Icon, IconName},
+ primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, ControlSize, Radius},
};
@@ -71,7 +71,8 @@ impl RenderOnce for IconButton {
.size(side)
.rounded(theme.radius(Radius::Md))
.bg(tone.bg)
- .when_some(tone.border, |el, border| el.border_1().border_color(border))
+ .border_1()
+ .border_color(tone.border)
.child(Icon::new(self.icon).size(icon_size).color(tone.fg))
.map(|el| {
if self.disabled {
@@ -81,7 +82,7 @@ impl RenderOnce for IconButton {
.tab_index(0)
.hover(|style| style.bg(tone.hover))
.active(|style| style.bg(tone.pressed))
- .focus(|style| style.shadow(theme.focus_ring()))
+ .focus_ring(cx)
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.when_some(self.on_click, |el, handler| el.on_click(handler))
})
diff --git a/src/lib.rs b/src/lib.rs
index 3810e5e..f905863 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -7,18 +7,21 @@ pub mod theme;
pub use assets::Assets;
-use gpui::App;
+use gpui::{App, KeyBinding};
-use crate::primitives::IconName;
+use crate::primitives::{FocusNext, FocusPrev, IconName};
-/// Loads fonts and the theme. Call once, first.
+/// Loads fonts and the theme, binds Tab. Call once, first.
pub fn init(cx: &mut App) {
- let probe = IconName::Check.path();
- let wired = cx.asset_source().load(probe).ok().flatten().is_some();
- assert!(
- wired,
- "ely: pass `ely_gpui_component::Assets` to `Application::with_assets`"
- );
+ match cx.asset_source().load(IconName::Check.path()) {
+ Ok(Some(_)) => {}
+ Ok(None) => panic!("ely: pass `ely_gpui_component::Assets` to `Application::with_assets`"),
+ Err(error) => panic!("ely: asset source failed: {error:#}"),
+ }
assets::load_fonts(cx).expect("ely: embedded fonts failed to register");
theme::Theme::init(cx);
+ cx.bind_keys([
+ KeyBinding::new("tab", FocusNext, None),
+ KeyBinding::new("shift-tab", FocusPrev, None),
+ ]);
}
diff --git a/src/motion/curve.rs b/src/motion/curve.rs
index be0bb6e..261ed75 100644
--- a/src/motion/curve.rs
+++ b/src/motion/curve.rs
@@ -1,6 +1,6 @@
use std::time::Duration;
-use gpui::App;
+use gpui::{App, Pixels, px};
use crate::theme::ActiveTheme;
@@ -9,6 +9,9 @@ pub const BASE: Duration = Duration::from_millis(200);
pub const SLOW: Duration = Duration::from_millis(320);
pub const THEME: Duration = Duration::from_millis(280);
+/// Travel of small entrances.
+pub const NUDGE: Pixels = px(4.0);
+
const DAMPING: f32 = 0.75;
const OMEGA: f32 = 6.91 / DAMPING;
diff --git a/src/primitives/backdrop.rs b/src/primitives/backdrop.rs
new file mode 100644
index 0000000..8b9039a
--- /dev/null
+++ b/src/primitives/backdrop.rs
@@ -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;
+
+/// Full-window scrim. Children sit centered above it.
+#[derive(IntoElement)]
+pub struct Backdrop {
+ id: ElementId,
+ on_dismiss: Option,
+ children: SmallVec<[AnyElement; 1]>,
+}
+
+impl Backdrop {
+ pub fn new(id: impl Into) -> 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- ) {
+ 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)),
+ ),
+ ),
+ )
+ }
+}
diff --git a/src/primitives/divider.rs b/src/primitives/divider.rs
new file mode 100644
index 0000000..077786c
--- /dev/null
+++ b/src/primitives/divider.rs
@@ -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,
+}
+
+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) -> 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()),
+ }
+ }
+}
diff --git a/src/primitives/focus.rs b/src/primitives/focus.rs
new file mode 100644
index 0000000..9a5bee9
--- /dev/null
+++ b/src/primitives/focus.rs
@@ -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 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
- ) {
+ 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);
+ }
+}
diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs
index f7a8b4f..5d1ff72 100644
--- a/src/primitives/icon.rs
+++ b/src/primitives/icon.rs
@@ -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",
diff --git a/src/primitives/image.rs b/src/primitives/image.rs
new file mode 100644
index 0000000..9ec76a2
--- /dev/null
+++ b/src/primitives/image.rs
@@ -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,
+ fit: ObjectFit,
+}
+
+impl Image {
+ /// The id keeps gpui's loading state across frames.
+ pub fn new(id: impl Into, source: impl Into) -> Self {
+ Self {
+ id: id.into(),
+ base: div(),
+ source: Some(source.into()),
+ fit: ObjectFit::Cover,
+ }
+ }
+
+ /// Nothing to load yet.
+ pub fn placeholder(id: impl Into) -> 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)
+ }
+}
diff --git a/src/primitives/measure.rs b/src/primitives/measure.rs
new file mode 100644
index 0000000..f1cc61f
--- /dev/null
+++ b/src/primitives/measure.rs
@@ -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, &mut Window, &mut App)>;
+type VisibilityHandler = Rc;
+
+/// 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,
+ on_measure: impl Fn(Bounds, &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
- ) {
+ 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::>);
+ 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,
+ 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
- ) {
+ 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::);
+ 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(),
+ )
+ }
+}
diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs
index 66e070c..893059e 100644
--- a/src/primitives/mod.rs
+++ b/src/primitives/mod.rs
@@ -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};
diff --git a/src/primitives/pressable.rs b/src/primitives/pressable.rs
new file mode 100644
index 0000000..ccd043f
--- /dev/null
+++ b/src/primitives/pressable.rs
@@ -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;
+type Content = Box AnyElement>;
+
+const LONG_PRESS: Duration = Duration::from_millis(500);
+
+#[derive(Default)]
+struct PressState {
+ pressed: bool,
+ long_fired: bool,
+ _timer: Option>,
+}
+
+/// Pressed state and long press for any content.
+#[derive(IntoElement)]
+pub struct Pressable {
+ id: ElementId,
+ content: Content,
+ on_press: Option,
+ on_long_press: Option,
+}
+
+impl Pressable {
+ /// `content` receives whether the pointer is down.
+ pub fn new(
+ id: impl Into,
+ 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();
+ })
+ })
+ }
+}
diff --git a/src/primitives/tooltip.rs b/src/primitives/tooltip.rs
new file mode 100644
index 0000000..d51c322
--- /dev/null
+++ b/src/primitives/tooltip.rs
@@ -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 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) -> 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,
+ meta: impl Into,
+ ) -> 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) -> 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,
+ _pending: Option>,
+}
+
+/// 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,
+ 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) {
+ trigger.visible = false;
+ trigger._pending = None;
+ cx.notify();
+}
diff --git a/src/theme/palette.rs b/src/theme/palette.rs
index 425b45b..40458d6 100644
--- a/src/theme/palette.rs
+++ b/src/theme/palette.rs
@@ -96,6 +96,8 @@ mixable!(
info_subtle: Hsla,
backdrop: Hsla,
shadow: Hsla,
+ tooltip_bg: Hsla,
+ tooltip_fg: Hsla,
chart: [Hsla; 8],
ansi: [Hsla; 16],
syntax: Syntax,
@@ -141,6 +143,8 @@ impl Palette {
info_subtle: c(0xebf4ff),
backdrop: rgba(0x18161352).into(),
shadow: c(0x181613),
+ tooltip_bg: c(0x181613),
+ tooltip_fg: c(0xfcfaf7),
chart: c8([
0x5182c1, 0x009589, 0xaa732b, 0xb8636b, 0x8572bb, 0x539156, 0xb66946, 0x138db1,
]),
@@ -203,6 +207,8 @@ impl Palette {
info_subtle: c(0x19273a),
backdrop: rgba(0x00000080).into(),
shadow: c(0x000000),
+ tooltip_bg: c(0x373533),
+ tooltip_fg: c(0xf3f1f0),
chart: c8([
0x79a7e2, 0x4eb9ad, 0xcd995c, 0xdc8a90, 0xa998dd, 0x7cb57d, 0xd9906f, 0x56b2d4,
]),
diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs
index 58363ce..a0db1d7 100644
--- a/src/theme/tokens.rs
+++ b/src/theme/tokens.rs
@@ -101,6 +101,15 @@ impl Theme {
px_to_rems(base * self.font_scale)
}
+ /// Gap between the pointer and a tooltip.
+ pub fn cursor_offset(&self) -> Rems {
+ px_to_rems(18.0)
+ }
+
+ pub fn tooltip_max_width(&self) -> Rems {
+ px_to_rems(256.0)
+ }
+
pub fn text_size(&self, size: TextSize) -> Rems {
let base = match size {
TextSize::Xs => 11.0,
@@ -140,15 +149,6 @@ impl Theme {
.map(|&(y, blur, alpha)| shadow(self.colors.shadow.alpha(alpha), y, blur))
.collect()
}
-
- pub fn focus_ring(&self) -> Vec {
- vec![BoxShadow {
- color: self.colors.focus.alpha(0.45),
- offset: point(px(0.0), px(0.0)),
- blur_radius: px(0.0),
- spread_radius: px(2.0),
- }]
- }
}
fn shadow(color: Hsla, y: f32, blur: f32) -> BoxShadow {
diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md
index e21d042..7d372d5 100644
--- a/tasks/ch01-10.md
+++ b/tasks/ch01-10.md
@@ -2,33 +2,33 @@
## T01 Primitives — `src/primitives`
-- [ ] Box / View — native `div()`
-- [ ] Text — native: strings and `StyledText` are elements
+- [x] Box / View — native `div()`
+- [x] Text — native: strings and `StyledText` are elements
- [x] Icon (T00)
- [x] IconSet / IconRegistry (T00)
-- [ ] Svg — native `svg()`
-- [ ] Image — loading, failed, placeholder over `img()`
-- [ ] Canvas — native `canvas()`
-- [ ] Divider / Separator
-- [ ] Spacer — native `div().flex_1()`
-- [ ] Portal — native `deferred(anchored())`
-- [ ] Overlay / Backdrop
-- [ ] Slot — native `impl IntoElement` params and `ParentElement`
-- [ ] Show / When — native `.when()` / `.when_some()`
-- [ ] For / Each — native `.children(iter)`
-- [ ] Fragment — native: children take iterators
-- [ ] VisuallyHidden — blocked: gpui 0.2.2 has no accessibility tree
-- [ ] FocusRing
-- [ ] FocusScope / FocusTrap
-- [ ] ClickOutside — native `on_mouse_down_out`
-- [ ] HoverArea — native `on_hover` / `.hover()`
-- [ ] Pressable — press state, long press
-- [ ] KeyboardHandler — native `on_key_down` / `on_action`
-- [ ] Measure
-- [ ] ResizeObserver → `primitives::Measure`
-- [ ] IntersectionObserver
-- [ ] Clipboard — native `write_to_clipboard` / `read_from_clipboard`
-- [ ] Tooltip Trigger — owns `Tooltip`: delay, follow mouse, rich content
+- [x] Svg — native `svg()`
+- [x] Image — loading, failed, placeholder over `img()`
+- [x] Canvas — native `canvas()`
+- [x] Divider / Separator
+- [x] Spacer — native `div().flex_1()`
+- [x] Portal — native `deferred(anchored())`
+- [x] Overlay / Backdrop
+- [x] Slot — native `impl IntoElement` params and `ParentElement`
+- [x] Show / When — native `.when()` / `.when_some()`
+- [x] For / Each — native `.children(iter)`
+- [x] Fragment — native: children take iterators
+- [x] VisuallyHidden — blocked: gpui 0.2.2 has no accessibility tree
+- [x] FocusRing
+- [x] FocusScope / FocusTrap
+- [x] ClickOutside — native `on_mouse_down_out`
+- [x] HoverArea — native `on_hover` / `.hover()`
+- [x] Pressable — press state, long press
+- [x] KeyboardHandler — native `on_key_down` / `on_action`
+- [x] Measure
+- [x] ResizeObserver → `primitives::Measure`
+- [x] IntersectionObserver
+- [x] Clipboard — native `write_to_clipboard` / `read_from_clipboard`
+- [x] Tooltip Trigger — owns `Tooltip`: delay, follow mouse, rich content
## T02 Typography — `src/typography`
diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md
index b983eed..408d7c2 100644
--- a/tasks/ch31-43.md
+++ b/tasks/ch31-43.md
@@ -289,4 +289,4 @@
- [ ] DesignTokenViewer
- [ ] IconBrowser
- [ ] A11yChecker — contrast audit
-- [ ] ScreenshotTest Harness — self-capture
+- [ ] ScreenshotTest Harness — self-capture; scripted input since T01