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