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

+4
View File
@@ -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`. - `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. - `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. - 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. - `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 ## Decisions
Generated
+2
View File
@@ -1519,6 +1519,7 @@ name = "ely-gpui-component"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cocoa 0.26.0",
"core-foundation 0.10.0", "core-foundation 0.10.0",
"core-graphics 0.24.0", "core-graphics 0.24.0",
"env_logger", "env_logger",
@@ -1526,6 +1527,7 @@ dependencies = [
"image", "image",
"log", "log",
"rust-embed", "rust-embed",
"smallvec",
] ]
[[package]] [[package]]
+2
View File
@@ -17,11 +17,13 @@ anyhow = "1"
gpui = "0.2.2" gpui = "0.2.2"
log = "0.4" log = "0.4"
rust-embed = "8.12" rust-embed = "8.12"
smallvec = "1"
[dev-dependencies] [dev-dependencies]
env_logger = "0.11" env_logger = "0.11"
[target.'cfg(target_os = "macos")'.dev-dependencies] [target.'cfg(target_os = "macos")'.dev-dependencies]
cocoa = "0.26"
core-foundation = "0.10" core-foundation = "0.10"
core-graphics = "0.24" core-graphics = "0.24"
image = { version = "0.25", default-features = false, features = ["png"] } image = { version = "0.25", default-features = false, features = ["png"] }
+1
View File
@@ -43,3 +43,4 @@ cargo run --example gallery -- --capture shots # macOS: PNG of every page, lig
## License ## License
MIT or Apache-2.0, at your option. Lucide icons: ISC. Inter and JetBrains Mono: SIL Open Font License 1.1. 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.
+4 -1
View File
@@ -17,7 +17,7 @@ Per-component lines live in `tasks/`. Tags there:
## Items ## Items
- [x] T00 Foundation: package, theme, motion, assets, Icon, Button, gallery shell, docs - [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 - [ ] T02 Typography
- [ ] T03 Layout - [ ] T03 Layout
- [ ] T04 Window & Shell - [ ] 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 | 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 | 2 | FAIL | AGENTS.md called `border_1` a rem helper |
| T00 | 3 | PASS | | | 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 |
+20
View File
@@ -0,0 +1,20 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-image-off"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="2" x2="22" y1="2" y2="22" />
<path d="M10.41 10.41a2 2 0 1 1-2.83-2.83" />
<line x1="13.5" x2="6" y1="13.5" y2="21" />
<line x1="18" x2="21" y1="12" y2="15" />
<path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59" />
<path d="M21 15V5a2 2 0 0 0-2-2H9" />
</svg>

After

Width:  |  Height:  |  Size: 597 B

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 gpui::{App, AsyncApp, WindowHandle, px};
use crate::{ use crate::{
pages, pages, script,
shell::{Choice, Gallery}, shell::{Choice, Gallery},
}; };
@@ -36,6 +36,7 @@ async fn shoot_all(window: WindowHandle<Gallery>, dir: &Path, cx: &mut AsyncApp)
gallery.select(ix, cx); gallery.select(ix, cx);
gallery.choose(choice, window, cx); gallery.choose(choice, window, cx);
})?; })?;
script::park(window, cx)?;
cx.background_executor().timer(SETTLE).await; cx.background_executor().timer(SETTLE).await;
let (viewport, max) = window.update(cx, |gallery, _, _| gallery.scroll_extent())?; let (viewport, max) = window.update(cx, |gallery, _, _| gallery.scroll_extent())?;
if viewport <= px(0.0) { 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))?; window.update(cx, |gallery, _, cx| gallery.scroll_to(offset, cx))?;
cx.background_executor().timer(SCROLL_SETTLE).await; 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(()) Ok(())
} }
/// This process's on-screen window, as CoreGraphics numbers it.
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn snapshot(path: &Path) -> Result<()> { pub fn window_number() -> Result<u32> {
use core_foundation::{ use core_foundation::{
base::{CFType, TCFType}, base::{CFType, TCFType},
dictionary::CFDictionary, dictionary::CFDictionary,
number::CFNumber, number::CFNumber,
string::CFString, string::CFString,
}; };
use core_graphics::{ use core_graphics::window;
geometry::{CGPoint, CGRect, CGSize},
window,
};
let number = |dict: &CFDictionary<CFString, CFType>, key: &'static str| { let number = |dict: &CFDictionary<CFString, CFType>, key: &'static str| {
dict.find(CFString::from_static_string(key)) dict.find(CFString::from_static_string(key))
@@ -93,10 +98,20 @@ fn snapshot(path: &Path) -> Result<()> {
.find(|dict| number(dict, "kCGWindowOwnerPID") == Some(pid)) .find(|dict| number(dict, "kCGWindowOwnerPID") == Some(pid))
.and_then(|dict| number(&dict, "kCGWindowNumber")) .and_then(|dict| number(&dict, "kCGWindowNumber"))
.context("gallery window not on screen")?; .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( let image = window::create_image(
CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(0.0, 0.0)), CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(0.0, 0.0)),
window::kCGWindowListOptionIncludingWindow, window::kCGWindowListOptionIncludingWindow,
id as u32, window_number()?,
window::kCGWindowImageBoundsIgnoreFraming | window::kCGWindowImageBestResolution, window::kCGWindowImageBoundsIgnoreFraming | window::kCGWindowImageBestResolution,
) )
.context("window capture returned nothing")?; .context("window capture returned nothing")?;
@@ -127,6 +142,6 @@ fn snapshot(path: &Path) -> Result<()> {
} }
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
fn snapshot(_: &Path) -> Result<()> { pub fn snapshot(_: &Path) -> Result<()> {
bail!("capture needs macOS window APIs") bail!("capture needs macOS window APIs")
} }
+4 -6
View File
@@ -1,5 +1,7 @@
mod capture; mod capture;
mod pages; mod pages;
mod probe;
mod script;
mod shell; mod shell;
mod ui; mod ui;
@@ -12,7 +14,7 @@ use gpui::{
actions, point, px, size, actions, point, px, size,
}; };
actions!(gallery, [Quit, FocusNext, FocusPrev]); actions!(gallery, [Quit]);
struct Args { struct Args {
page: usize, page: usize,
@@ -47,11 +49,7 @@ fn main() -> Result<()> {
.with_assets(Assets) .with_assets(Assets)
.run(move |cx: &mut App| { .run(move |cx: &mut App| {
ely_gpui_component::init(cx); ely_gpui_component::init(cx);
cx.bind_keys([ cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
KeyBinding::new("cmd-q", Quit, None),
KeyBinding::new("tab", FocusNext, None),
KeyBinding::new("shift-tab", FocusPrev, None),
]);
cx.on_action(|_: &Quit, cx| cx.quit()); cx.on_action(|_: &Quit, cx| cx.quit());
let bounds = Bounds::centered(None, size(px(1280.0), px(820.0)), cx); let bounds = Bounds::centered(None, size(px(1280.0), px(820.0)), cx);
let options = WindowOptions { let options = WindowOptions {
+1
View File
@@ -14,6 +14,7 @@ pub const PAGE: Page = Page {
title: "Buttons & Actions", title: "Buttons & Actions",
summary: "Seven variants, three sizes, one focus ring.", summary: "Seven variants, three sizes, one focus ring.",
render, render,
script: &[],
}; };
const VARIANTS: [(ButtonVariant, &str); 7] = [ const VARIANTS: [(ButtonVariant, &str); 7] = [
+4
View File
@@ -4,12 +4,16 @@ mod theme;
use gpui::{AnyElement, App, Window}; use gpui::{AnyElement, App, Window};
use crate::script::Step;
pub struct Page { pub struct Page {
pub number: u8, pub number: u8,
pub slug: &'static str, pub slug: &'static str,
pub title: &'static str, pub title: &'static str,
pub summary: &'static str, pub summary: &'static str,
pub render: fn(&mut Window, &mut App) -> AnyElement, 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]; 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", title: "Theme",
summary: "Tokens every component reads. Light and dark share one grammar.", summary: "Tokens every component reads. Light and dark share one grammar.",
render, render,
script: &[],
}; };
fn render(_: &mut Window, cx: &mut App) -> AnyElement { 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::{ use ely_gpui_component::{
buttons::{ButtonVariant, IconButton}, buttons::{ButtonVariant, IconButton},
primitives::IconName, primitives::{FocusScope, IconName},
theme::{ActiveTheme, ControlSize, Mode, Radius, TextSize, Theme}, theme::{ActiveTheme, ControlSize, Mode, Radius, TextSize, Theme},
}; };
use gpui::{ use gpui::{
Context, FocusHandle, FontWeight, InteractiveElement, IntoElement, ParentElement, Pixels, Bounds, Context, FocusHandle, FontWeight, InteractiveElement, IntoElement, ParentElement,
Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription,
div, point, prelude::*, px, Window, div, point, prelude::*, px,
}; };
use crate::{FocusNext, FocusPrev, pages}; use crate::pages;
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub enum Choice { 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>) { pub fn scroll_to(&mut self, y: Pixels, cx: &mut Context<Self>) {
self.scroll.set_offset(point(px(0.0), -y)); self.scroll.set_offset(point(px(0.0), -y));
cx.notify(); cx.notify();
@@ -183,11 +198,7 @@ impl Render for Gallery {
let theme = cx.theme(); let theme = cx.theme();
let colors = &theme.colors; let colors = &theme.colors;
div() FocusScope::new(&self.focus)
.id("gallery")
.track_focus(&self.focus)
.on_action(|_: &FocusNext, window, _| window.focus_next())
.on_action(|_: &FocusPrev, window, _| window.focus_prev())
.size_full() .size_full()
.flex() .flex()
.bg(colors.bg) .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(body)
.child(label(caption, cx)) .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())
}
+12 -11
View File
@@ -4,7 +4,7 @@ use gpui::{
}; };
use crate::{ use crate::{
primitives::{Icon, IconName}, primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Mix, Palette, Radius, TextSize}, theme::{ActiveTheme, ControlSize, IconSize, Mix, Palette, Radius, TextSize},
}; };
@@ -27,7 +27,7 @@ pub(crate) struct Tone {
pub hover: Hsla, pub hover: Hsla,
pub pressed: Hsla, pub pressed: Hsla,
pub fg: Hsla, pub fg: Hsla,
pub border: Option<Hsla>, pub border: Hsla,
} }
pub(crate) fn tone(variant: ButtonVariant, colors: &Palette) -> Tone { 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, hover: colors.accent_hover,
pressed: colors.accent_hover.mix(&colors.on_accent, 0.12), pressed: colors.accent_hover.mix(&colors.on_accent, 0.12),
fg: colors.on_accent, fg: colors.on_accent,
border: None, border: clear,
}, },
ButtonVariant::Secondary => Tone { ButtonVariant::Secondary => Tone {
bg: colors.surface, bg: colors.surface,
hover: colors.hover, hover: colors.hover,
pressed: colors.active, pressed: colors.active,
fg: colors.fg, fg: colors.fg,
border: Some(colors.border), border: colors.border,
}, },
ButtonVariant::Outline => Tone { ButtonVariant::Outline => Tone {
bg: clear, bg: clear,
hover: colors.hover, hover: colors.hover,
pressed: colors.active, pressed: colors.active,
fg: colors.fg, fg: colors.fg,
border: Some(colors.border_strong), border: colors.border_strong,
}, },
ButtonVariant::Ghost => Tone { ButtonVariant::Ghost => Tone {
bg: clear, bg: clear,
hover: colors.hover, hover: colors.hover,
pressed: colors.active, pressed: colors.active,
fg: colors.fg, fg: colors.fg,
border: None, border: clear,
}, },
ButtonVariant::Subtle => Tone { ButtonVariant::Subtle => Tone {
bg: colors.hover, bg: colors.hover,
hover: colors.active, hover: colors.active,
pressed: colors.active.mix(&colors.border_strong, 0.5), pressed: colors.active.mix(&colors.border_strong, 0.5),
fg: colors.fg, fg: colors.fg,
border: None, border: clear,
}, },
ButtonVariant::Danger => Tone { ButtonVariant::Danger => Tone {
bg: colors.danger, bg: colors.danger,
hover: colors.danger.mix(&colors.fg, 0.14), hover: colors.danger.mix(&colors.fg, 0.14),
pressed: colors.danger.mix(&colors.fg, 0.24), pressed: colors.danger.mix(&colors.fg, 0.24),
fg: colors.on_accent, fg: colors.on_accent,
border: None, border: clear,
}, },
ButtonVariant::Link => Tone { ButtonVariant::Link => Tone {
bg: clear, bg: clear,
hover: clear, hover: clear,
pressed: clear, pressed: clear,
fg: colors.link, fg: colors.link,
border: None, border: clear,
}, },
} }
} }
@@ -186,7 +186,8 @@ impl RenderOnce for Button {
.font_weight(FontWeight::MEDIUM) .font_weight(FontWeight::MEDIUM)
.text_color(tone.fg) .text_color(tone.fg)
.bg(tone.bg) .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(self.full_width, |el| el.w_full())
.when_some(self.icon, |el, name| el.child(icon(name))) .when_some(self.icon, |el, name| el.child(icon(name)))
.child(self.label) .child(self.label)
@@ -202,7 +203,7 @@ impl RenderOnce for Button {
if link { style.underline() } else { style } if link { style.underline() } else { style }
}) })
.active(|style| style.bg(tone.pressed)) .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()) .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.when_some(self.on_click, |el, handler| el.on_click(handler)) .when_some(self.on_click, |el, handler| el.on_click(handler))
}) })
+4 -3
View File
@@ -4,7 +4,7 @@ use gpui::{
use super::button::{ButtonVariant, ClickHandler, label_size, tone}; use super::button::{ButtonVariant, ClickHandler, label_size, tone};
use crate::{ use crate::{
primitives::{Icon, IconName}, primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, ControlSize, Radius}, theme::{ActiveTheme, ControlSize, Radius},
}; };
@@ -71,7 +71,8 @@ impl RenderOnce for IconButton {
.size(side) .size(side)
.rounded(theme.radius(Radius::Md)) .rounded(theme.radius(Radius::Md))
.bg(tone.bg) .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)) .child(Icon::new(self.icon).size(icon_size).color(tone.fg))
.map(|el| { .map(|el| {
if self.disabled { if self.disabled {
@@ -81,7 +82,7 @@ impl RenderOnce for IconButton {
.tab_index(0) .tab_index(0)
.hover(|style| style.bg(tone.hover)) .hover(|style| style.bg(tone.hover))
.active(|style| style.bg(tone.pressed)) .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()) .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.when_some(self.on_click, |el, handler| el.on_click(handler)) .when_some(self.on_click, |el, handler| el.on_click(handler))
}) })
+12 -9
View File
@@ -7,18 +7,21 @@ pub mod theme;
pub use assets::Assets; 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) { pub fn init(cx: &mut App) {
let probe = IconName::Check.path(); match cx.asset_source().load(IconName::Check.path()) {
let wired = cx.asset_source().load(probe).ok().flatten().is_some(); Ok(Some(_)) => {}
assert!( Ok(None) => panic!("ely: pass `ely_gpui_component::Assets` to `Application::with_assets`"),
wired, Err(error) => panic!("ely: asset source failed: {error:#}"),
"ely: pass `ely_gpui_component::Assets` to `Application::with_assets`" }
);
assets::load_fonts(cx).expect("ely: embedded fonts failed to register"); assets::load_fonts(cx).expect("ely: embedded fonts failed to register");
theme::Theme::init(cx); theme::Theme::init(cx);
cx.bind_keys([
KeyBinding::new("tab", FocusNext, None),
KeyBinding::new("shift-tab", FocusPrev, None),
]);
} }
+4 -1
View File
@@ -1,6 +1,6 @@
use std::time::Duration; use std::time::Duration;
use gpui::App; use gpui::{App, Pixels, px};
use crate::theme::ActiveTheme; 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 SLOW: Duration = Duration::from_millis(320);
pub const THEME: Duration = Duration::from_millis(280); 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 DAMPING: f32 = 0.75;
const OMEGA: f32 = 6.91 / DAMPING; const OMEGA: f32 = 6.91 / DAMPING;
+83
View File
@@ -0,0 +1,83 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, AnyElement, App, ElementId, InteractiveElement, IntoElement,
MouseButton, ParentElement, Point, RenderOnce, Styled, Window, anchored, deferred, div,
prelude::*,
};
use smallvec::SmallVec;
use crate::{motion, theme::ActiveTheme};
type Dismiss = Rc<dyn Fn(&mut Window, &mut App)>;
/// Full-window scrim. Children sit centered above it.
#[derive(IntoElement)]
pub struct Backdrop {
id: ElementId,
on_dismiss: Option<Dismiss>,
children: SmallVec<[AnyElement; 1]>,
}
impl Backdrop {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
on_dismiss: None,
children: SmallVec::new(),
}
}
/// Runs on a click outside the children.
pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_dismiss = Some(Rc::new(handler));
self
}
}
impl ParentElement for Backdrop {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for Backdrop {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let viewport = window.viewport_size();
let fade = motion::duration(motion::BASE, cx);
let scrim = div()
.id(self.id.clone())
.absolute()
.top_0()
.left_0()
.size_full()
.bg(cx.theme().colors.backdrop)
.occlude()
.when_some(self.on_dismiss, |scrim, dismiss| {
scrim.on_mouse_down(MouseButton::Left, move |_, window, cx| dismiss(window, cx))
})
.with_animation(
self.id,
Animation::new(fade).with_easing(motion::ease_out_cubic),
|scrim, t| scrim.opacity(t),
);
deferred(
anchored().position(Point::default()).child(
div()
.relative()
.w(viewport.width)
.h(viewport.height)
.flex()
.items_center()
.justify_center()
.child(scrim)
.children(
self.children
.into_iter()
.map(|child| div().relative().occlude().child(child)),
),
),
)
}
}
+58
View File
@@ -0,0 +1,58 @@
use gpui::{App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div};
use crate::theme::{ActiveTheme, TextSize};
/// Hairline rule, across or down.
#[derive(IntoElement)]
pub struct Divider {
vertical: bool,
label: Option<SharedString>,
}
impl Divider {
pub fn horizontal() -> Self {
Self {
vertical: false,
label: None,
}
}
pub fn vertical() -> Self {
Self {
vertical: true,
label: None,
}
}
/// Text centered on a horizontal rule.
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
}
impl RenderOnce for Divider {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let line = || div().border_color(theme.colors.border);
if self.vertical {
return line().h_full().border_l_1();
}
match self.label {
None => line().w_full().border_t_1(),
Some(label) => div()
.flex()
.items_center()
.gap_3()
.w_full()
.child(line().flex_1().border_t_1())
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_subtle)
.child(label),
)
.child(line().flex_1().border_t_1()),
}
}
}
+96
View File
@@ -0,0 +1,96 @@
use gpui::{
AnyElement, App, Div, FocusHandle, InteractiveElement, IntoElement, ParentElement, RenderOnce,
StyleRefinement, Styled, Window, actions, div,
};
use crate::theme::ActiveTheme;
actions!(ely, [FocusNext, FocusPrev]);
/// Focus-colored border while focused. Give the element a 1px border.
pub trait FocusRing: InteractiveElement + Sized {
fn focus_ring(self, cx: &App) -> Self {
let ring = cx.theme().colors.focus;
self.focus(move |style| style.border_color(ring))
}
}
impl<E: InteractiveElement> FocusRing for E {}
/// Owns Tab and Shift-Tab for its subtree. `trap` keeps focus inside.
#[derive(IntoElement)]
pub struct FocusScope {
base: Div,
handle: FocusHandle,
trap: bool,
}
impl FocusScope {
pub fn new(handle: &FocusHandle) -> Self {
Self {
base: div().track_focus(handle),
handle: handle.clone(),
trap: false,
}
}
pub fn trap(mut self) -> Self {
self.trap = true;
self
}
}
impl Styled for FocusScope {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl ParentElement for FocusScope {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.base.extend(elements);
}
}
impl RenderOnce for FocusScope {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let (next, prev) = (self.handle.clone(), self.handle);
let trap = self.trap;
self.base
.on_action(move |_: &FocusNext, window, cx| step(&next, trap, true, window, cx))
.on_action(move |_: &FocusPrev, window, cx| step(&prev, trap, false, window, cx))
}
}
fn step(scope: &FocusHandle, trap: bool, forward: bool, window: &mut Window, cx: &mut App) {
let advance = |window: &mut Window| {
if forward {
window.focus_next()
} else {
window.focus_prev()
}
};
if !trap {
advance(window);
return;
}
let origin = window.focused(cx);
let mut first = None;
loop {
advance(window);
let focused = window.focused(cx);
if scope.contains_focused(window, cx) && focused.as_ref() != Some(scope) {
return;
}
if focused.is_none() || focused == origin || (first.is_some() && focused == first) {
break;
}
if first.is_none() {
first = focused;
}
}
log::warn!("focus scope: trap holds no tab stop; focus stays");
if let Some(origin) = origin {
window.focus(&origin);
}
}
+8
View File
@@ -18,6 +18,13 @@ macro_rules! icons {
$(IconName::$variant => concat!("icons/", $file, ".svg"),)* $(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", Hourglass => "hourglass",
House => "house", House => "house",
Image => "image", Image => "image",
ImageOff => "image-off",
Inbox => "inbox", Inbox => "inbox",
Info => "info", Info => "info",
Italic => "italic", Italic => "italic",
+105
View File
@@ -0,0 +1,105 @@
use std::time::Duration;
use gpui::{
Animation, AnimationExt, App, Div, ElementId, ImageSource, InteractiveElement, IntoElement,
ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, Window, div, img,
pulsating_between,
};
use crate::{
primitives::{Icon, IconName},
theme::{ActiveTheme, IconSize},
};
const PULSE: Duration = Duration::from_millis(1600);
/// Themed `img()`; a `Cover` crop keeps square corners.
#[derive(IntoElement)]
pub struct Image {
id: ElementId,
base: Div,
source: Option<ImageSource>,
fit: ObjectFit,
}
impl Image {
/// The id keeps gpui's loading state across frames.
pub fn new(id: impl Into<ElementId>, source: impl Into<ImageSource>) -> Self {
Self {
id: id.into(),
base: div(),
source: Some(source.into()),
fit: ObjectFit::Cover,
}
}
/// Nothing to load yet.
pub fn placeholder(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
base: div(),
source: None,
fit: ObjectFit::Cover,
}
}
pub fn fit(mut self, fit: ObjectFit) -> Self {
self.fit = fit;
self
}
}
impl Styled for Image {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl RenderOnce for Image {
fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let (sunken, subtle) = (theme.colors.sunken, theme.colors.fg_subtle);
let still = theme.reduced_motion;
let radii = self.base.style().corner_radii.clone();
let pane = {
let radii = radii.clone();
move || {
let mut pane = div().size_full().bg(sunken);
pane.style().corner_radii = radii.clone();
pane
}
};
let loading_pane = pane.clone();
let mark = move |name| {
pane()
.flex()
.items_center()
.justify_center()
.child(Icon::new(name).size(IconSize::Lg).color(subtle))
};
let Some(source) = self.source else {
return self.base.overflow_hidden().child(mark(IconName::Image));
};
let mut picture = img(source)
.id(self.id)
.size_full()
.object_fit(self.fit)
.with_loading(move || {
if still {
return loading_pane().into_any_element();
}
loading_pane()
.with_animation(
"image-loading",
Animation::new(PULSE)
.repeat()
.with_easing(pulsating_between(0.55, 1.0)),
|pane, t| pane.opacity(t),
)
.into_any_element()
})
.with_fallback(move || mark(IconName::ImageOff).into_any_element());
picture.style().corner_radii = radii;
self.base.overflow_hidden().child(picture)
}
}
+120
View File
@@ -0,0 +1,120 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, Bounds, Div, ElementId, IntoElement, ParentElement, Pixels, RenderOnce,
StyleRefinement, Styled, Window, canvas, div,
};
type BoundsHandler = Rc<dyn Fn(Bounds<Pixels>, &mut Window, &mut App)>;
type VisibilityHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
/// A div that reports its bounds whenever they change.
#[derive(IntoElement)]
pub struct Measure {
id: ElementId,
base: Div,
on_measure: BoundsHandler,
}
impl Measure {
pub fn new(
id: impl Into<ElementId>,
on_measure: impl Fn(Bounds<Pixels>, &mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
base: div(),
on_measure: Rc::new(on_measure),
}
}
}
impl Styled for Measure {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl ParentElement for Measure {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.base.extend(elements);
}
}
impl RenderOnce for Measure {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let last = window.use_keyed_state(self.id, cx, |_, _| None::<Bounds<Pixels>>);
let on_measure = self.on_measure;
self.base.relative().child(
canvas(
move |bounds, window, cx| {
if *last.read(cx) != Some(bounds) {
last.update(cx, |seen, _| *seen = Some(bounds));
on_measure(bounds, window, cx);
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
}
}
/// A div that reports when it enters or leaves the visible area.
#[derive(IntoElement)]
pub struct IntersectionObserver {
id: ElementId,
base: Div,
on_change: VisibilityHandler,
}
impl IntersectionObserver {
pub fn new(
id: impl Into<ElementId>,
on_change: impl Fn(bool, &mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
base: div(),
on_change: Rc::new(on_change),
}
}
}
impl Styled for IntersectionObserver {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl ParentElement for IntersectionObserver {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.base.extend(elements);
}
}
impl RenderOnce for IntersectionObserver {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let last = window.use_keyed_state(self.id, cx, |_, _| None::<bool>);
let on_change = self.on_change;
self.base.relative().child(
canvas(
move |bounds, window, cx| {
let visible = window.content_mask().bounds.intersects(&bounds);
if *last.read(cx) != Some(visible) {
last.update(cx, |seen, _| *seen = Some(visible));
on_change(visible, window, cx);
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
}
}
+14
View File
@@ -1,3 +1,17 @@
mod backdrop;
mod divider;
mod focus;
mod icon; mod 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 icon::{Icon, IconName};
pub use image::Image;
pub use measure::{IntersectionObserver, Measure};
pub use pressable::Pressable;
pub use tooltip::{Tooltip, TooltipTrigger};
+111
View File
@@ -0,0 +1,111 @@
use std::{rc::Rc, time::Duration};
use gpui::{
AnyElement, App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, Task, Window, div,
};
type Handler = Rc<dyn Fn(&mut Window, &mut App)>;
type Content = Box<dyn Fn(bool, &mut Window, &mut App) -> AnyElement>;
const LONG_PRESS: Duration = Duration::from_millis(500);
#[derive(Default)]
struct PressState {
pressed: bool,
long_fired: bool,
_timer: Option<Task<()>>,
}
/// Pressed state and long press for any content.
#[derive(IntoElement)]
pub struct Pressable {
id: ElementId,
content: Content,
on_press: Option<Handler>,
on_long_press: Option<Handler>,
}
impl Pressable {
/// `content` receives whether the pointer is down.
pub fn new(
id: impl Into<ElementId>,
content: impl Fn(bool, &mut Window, &mut App) -> AnyElement + 'static,
) -> Self {
Self {
id: id.into(),
content: Box::new(content),
on_press: None,
on_long_press: None,
}
}
pub fn on_press(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_press = Some(Rc::new(handler));
self
}
pub fn on_long_press(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_long_press = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Pressable {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| PressState::default());
let pressed = state.read(cx).pressed;
let content = (self.content)(pressed, window, cx);
let (down, up, out) = (state.clone(), state.clone(), state);
let on_long_press = self.on_long_press;
let on_press = self.on_press;
div()
.id(self.id)
.child(content)
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
let timer = on_long_press.clone().map(|handler| {
let weak = down.downgrade();
window.spawn(cx, async move |cx| {
cx.background_executor().timer(LONG_PRESS).await;
let fired = cx.update(|window, cx| {
weak.update(cx, |press, cx| {
press.long_fired = press.pressed;
cx.notify();
press.long_fired
})
.map(|fire| fire.then(|| handler(window, cx)))
});
if let Err(error) = fired.and_then(|inner| inner) {
log::error!("pressable: long press lost its window: {error:#}");
}
})
});
down.update(cx, |press, cx| {
*press = PressState {
pressed: true,
long_fired: false,
_timer: timer,
};
cx.notify();
});
})
.on_mouse_up(MouseButton::Left, move |_, window, cx| {
let released = up.update(cx, |press, cx| {
let short = press.pressed && !press.long_fired;
*press = PressState::default();
cx.notify();
short
});
if let Some(handler) = on_press.as_ref().filter(|_| released) {
handler(window, cx);
}
})
.on_mouse_up_out(MouseButton::Left, move |_, _, cx| {
out.update(cx, |press, cx| {
*press = PressState::default();
cx.notify();
})
})
}
}
+192
View File
@@ -0,0 +1,192 @@
use std::{rc::Rc, time::Duration};
use gpui::{
Animation, AnimationExt, AnyElement, AnyView, App, AppContext, Context, Div, ElementId,
InteractiveElement, IntoElement, ParentElement, Pixels, Point, Render, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Task, Window, anchored, deferred, div, point,
prelude::*,
};
use crate::{
motion,
theme::{ActiveTheme, Elevation, Radius, TextSize},
};
type Content = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
const DELAY: Duration = Duration::from_millis(400);
fn surface(content: AnyElement, cx: &App) -> impl IntoElement + use<> {
let theme = cx.theme();
let rise = motion::NUDGE;
let body = div()
.max_w(theme.tooltip_max_width())
.px_2()
.py_1()
.rounded(theme.radius(Radius::Md))
.bg(theme.colors.tooltip_bg)
.text_color(theme.colors.tooltip_fg)
.text_size(theme.text_size(TextSize::Sm))
.shadow(theme.elevation(Elevation::Floating))
.child(content);
body.with_animation(
"tooltip-in",
Animation::new(motion::duration(motion::FAST, cx)).with_easing(motion::ease_out_cubic),
move |body, t| body.opacity(t).mt(rise * (1.0 - t)),
)
}
/// Tooltip body for gpui's `.tooltip()`.
pub struct Tooltip {
content: Content,
}
impl Tooltip {
pub fn text(title: impl Into<SharedString>) -> impl Fn(&mut Window, &mut App) -> AnyView {
let title = title.into();
Self::rich(move |_, _| title.clone().into_any_element())
}
/// Title plus a quiet trailing hint, such as a shortcut.
pub fn with_meta(
title: impl Into<SharedString>,
meta: impl Into<SharedString>,
) -> impl Fn(&mut Window, &mut App) -> AnyView {
let (title, meta) = (title.into(), meta.into());
Self::rich(move |_, cx| meta_row(title.clone(), meta.clone(), cx).into_any_element())
}
pub fn rich(
content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
) -> impl Fn(&mut Window, &mut App) -> AnyView {
let content: Content = Rc::new(content);
move |_, cx| {
let content = content.clone();
cx.new(|_| Tooltip { content }).into()
}
}
}
impl Render for Tooltip {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let content = (self.content)(window, cx);
div()
.pt(cx.theme().cursor_offset())
.child(surface(content, cx))
}
}
fn meta_row(title: SharedString, meta: SharedString, cx: &App) -> Div {
div().flex().items_center().gap_2().child(title).child(
div()
.text_color(cx.theme().colors.tooltip_fg.opacity(0.6))
.child(meta),
)
}
#[derive(Default)]
struct TriggerState {
visible: bool,
mouse: Point<Pixels>,
_pending: Option<Task<()>>,
}
/// Tooltip with its own delay, and optional cursor tracking.
#[derive(IntoElement)]
pub struct TooltipTrigger {
id: ElementId,
child: AnyElement,
content: Content,
delay: Duration,
follow: bool,
}
impl TooltipTrigger {
pub fn new(
id: impl Into<ElementId>,
child: impl IntoElement,
content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
) -> Self {
Self {
id: id.into(),
child: child.into_any_element(),
content: Rc::new(content),
delay: DELAY,
follow: false,
}
}
pub fn delay(mut self, delay: Duration) -> Self {
self.delay = delay;
self
}
pub fn follow_mouse(mut self) -> Self {
self.follow = true;
self
}
}
impl RenderOnce for TooltipTrigger {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| TriggerState::default());
let (visible, mouse) = {
let read = state.read(cx);
(read.visible, read.mouse)
};
let (hover, track, press) = (state.clone(), state.clone(), state);
let (delay, follow) = (self.delay, self.follow);
let content = self.content;
div()
.id(self.id)
.child(self.child)
.on_hover(move |hovered, window, cx| {
if !*hovered {
hover.update(cx, hide);
return;
}
let weak = hover.downgrade();
let task = window.spawn(cx, async move |cx| {
cx.background_executor().timer(delay).await;
let shown = cx.update(|window, cx| {
weak.update(cx, |trigger, cx| {
trigger.visible = true;
trigger.mouse = window.mouse_position();
trigger._pending = None;
cx.notify();
})
});
if let Err(error) = shown.and_then(|inner| inner) {
log::error!("tooltip: trigger vanished before showing: {error:#}");
}
});
hover.update(cx, |trigger, _| trigger._pending = Some(task));
})
.on_mouse_move(move |event, _, cx| {
track.update(cx, |trigger, cx| {
trigger.mouse = event.position;
if follow && trigger.visible {
cx.notify();
}
})
})
.on_any_mouse_down(move |_, _, cx| press.update(cx, hide))
.when(visible, |trigger| {
let body = content(window, cx);
let below = cx.theme().cursor_offset().to_pixels(window.rem_size());
trigger.child(deferred(
anchored()
.position(mouse + point(Pixels::ZERO, below))
.snap_to_window()
.child(surface(body, cx)),
))
})
}
}
fn hide(trigger: &mut TriggerState, cx: &mut Context<TriggerState>) {
trigger.visible = false;
trigger._pending = None;
cx.notify();
}
+6
View File
@@ -96,6 +96,8 @@ mixable!(
info_subtle: Hsla, info_subtle: Hsla,
backdrop: Hsla, backdrop: Hsla,
shadow: Hsla, shadow: Hsla,
tooltip_bg: Hsla,
tooltip_fg: Hsla,
chart: [Hsla; 8], chart: [Hsla; 8],
ansi: [Hsla; 16], ansi: [Hsla; 16],
syntax: Syntax, syntax: Syntax,
@@ -141,6 +143,8 @@ impl Palette {
info_subtle: c(0xebf4ff), info_subtle: c(0xebf4ff),
backdrop: rgba(0x18161352).into(), backdrop: rgba(0x18161352).into(),
shadow: c(0x181613), shadow: c(0x181613),
tooltip_bg: c(0x181613),
tooltip_fg: c(0xfcfaf7),
chart: c8([ chart: c8([
0x5182c1, 0x009589, 0xaa732b, 0xb8636b, 0x8572bb, 0x539156, 0xb66946, 0x138db1, 0x5182c1, 0x009589, 0xaa732b, 0xb8636b, 0x8572bb, 0x539156, 0xb66946, 0x138db1,
]), ]),
@@ -203,6 +207,8 @@ impl Palette {
info_subtle: c(0x19273a), info_subtle: c(0x19273a),
backdrop: rgba(0x00000080).into(), backdrop: rgba(0x00000080).into(),
shadow: c(0x000000), shadow: c(0x000000),
tooltip_bg: c(0x373533),
tooltip_fg: c(0xf3f1f0),
chart: c8([ chart: c8([
0x79a7e2, 0x4eb9ad, 0xcd995c, 0xdc8a90, 0xa998dd, 0x7cb57d, 0xd9906f, 0x56b2d4, 0x79a7e2, 0x4eb9ad, 0xcd995c, 0xdc8a90, 0xa998dd, 0x7cb57d, 0xd9906f, 0x56b2d4,
]), ]),
+9 -9
View File
@@ -101,6 +101,15 @@ impl Theme {
px_to_rems(base * self.font_scale) 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 { pub fn text_size(&self, size: TextSize) -> Rems {
let base = match size { let base = match size {
TextSize::Xs => 11.0, TextSize::Xs => 11.0,
@@ -140,15 +149,6 @@ impl Theme {
.map(|&(y, blur, alpha)| shadow(self.colors.shadow.alpha(alpha), y, blur)) .map(|&(y, blur, alpha)| shadow(self.colors.shadow.alpha(alpha), y, blur))
.collect() .collect()
} }
pub fn focus_ring(&self) -> Vec<BoxShadow> {
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 { fn shadow(color: Hsla, y: f32, blur: f32) -> BoxShadow {
+25 -25
View File
@@ -2,33 +2,33 @@
## T01 Primitives — `src/primitives` ## T01 Primitives — `src/primitives`
- [ ] Box / View — native `div()` - [x] Box / View — native `div()`
- [ ] Text — native: strings and `StyledText` are elements - [x] Text — native: strings and `StyledText` are elements
- [x] Icon (T00) - [x] Icon (T00)
- [x] IconSet / IconRegistry (T00) - [x] IconSet / IconRegistry (T00)
- [ ] Svg — native `svg()` - [x] Svg — native `svg()`
- [ ] Image — loading, failed, placeholder over `img()` - [x] Image — loading, failed, placeholder over `img()`
- [ ] Canvas — native `canvas()` - [x] Canvas — native `canvas()`
- [ ] Divider / Separator - [x] Divider / Separator
- [ ] Spacer — native `div().flex_1()` - [x] Spacer — native `div().flex_1()`
- [ ] Portal — native `deferred(anchored())` - [x] Portal — native `deferred(anchored())`
- [ ] Overlay / Backdrop - [x] Overlay / Backdrop
- [ ] Slot — native `impl IntoElement` params and `ParentElement` - [x] Slot — native `impl IntoElement` params and `ParentElement`
- [ ] Show / When — native `.when()` / `.when_some()` - [x] Show / When — native `.when()` / `.when_some()`
- [ ] For / Each — native `.children(iter)` - [x] For / Each — native `.children(iter)`
- [ ] Fragment — native: children take iterators - [x] Fragment — native: children take iterators
- [ ] VisuallyHidden — blocked: gpui 0.2.2 has no accessibility tree - [x] VisuallyHidden — blocked: gpui 0.2.2 has no accessibility tree
- [ ] FocusRing - [x] FocusRing
- [ ] FocusScope / FocusTrap - [x] FocusScope / FocusTrap
- [ ] ClickOutside — native `on_mouse_down_out` - [x] ClickOutside — native `on_mouse_down_out`
- [ ] HoverArea — native `on_hover` / `.hover()` - [x] HoverArea — native `on_hover` / `.hover()`
- [ ] Pressable — press state, long press - [x] Pressable — press state, long press
- [ ] KeyboardHandler — native `on_key_down` / `on_action` - [x] KeyboardHandler — native `on_key_down` / `on_action`
- [ ] Measure - [x] Measure
- [ ] ResizeObserver → `primitives::Measure` - [x] ResizeObserver → `primitives::Measure`
- [ ] IntersectionObserver - [x] IntersectionObserver
- [ ] Clipboard — native `write_to_clipboard` / `read_from_clipboard` - [x] Clipboard — native `write_to_clipboard` / `read_from_clipboard`
- [ ] Tooltip Trigger — owns `Tooltip`: delay, follow mouse, rich content - [x] Tooltip Trigger — owns `Tooltip`: delay, follow mouse, rich content
## T02 Typography — `src/typography` ## T02 Typography — `src/typography`
+1 -1
View File
@@ -289,4 +289,4 @@
- [ ] DesignTokenViewer - [ ] DesignTokenViewer
- [ ] IconBrowser - [ ] IconBrowser
- [ ] A11yChecker — contrast audit - [ ] A11yChecker — contrast audit
- [ ] ScreenshotTest Harness — self-capture - [ ] ScreenshotTest Harness — self-capture; scripted input since T01