From ef74bac823cb0ff8c1649e76de8affb3994f8680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 15:19:57 -0400 Subject: [PATCH] T09b overlays: lightbox, spotlight and tour, floating toolbar, peek; images fill their box; overlays take focus back when its element leaves the tree --- AGENTS.md | 6 +- TASKS.md | 5 +- examples/gallery/pages/overlays/anchored.rs | 152 ++++++ .../{overlays.rs => overlays/dialogs.rs} | 215 +-------- examples/gallery/pages/overlays/guides.rs | 222 +++++++++ examples/gallery/pages/overlays/mod.rs | 88 ++++ src/overlays/floats.rs | 169 +++++++ src/overlays/lightbox.rs | 280 +++++++++++ src/overlays/mod.rs | 6 + src/overlays/spotlight.rs | 439 ++++++++++++++++++ src/overlays/tests/guides.rs | 335 +++++++++++++ src/overlays/{tests.rs => tests/mod.rs} | 8 +- src/primitives/focus.rs | 23 +- src/primitives/image.rs | 6 +- src/theme/palette.rs | 3 + src/theme/tokens.rs | 5 + tasks/ch01-10.md | 18 +- tasks/ch31-43.md | 2 +- 18 files changed, 1754 insertions(+), 228 deletions(-) create mode 100644 examples/gallery/pages/overlays/anchored.rs rename examples/gallery/pages/{overlays.rs => overlays/dialogs.rs} (50%) create mode 100644 examples/gallery/pages/overlays/guides.rs create mode 100644 examples/gallery/pages/overlays/mod.rs create mode 100644 src/overlays/floats.rs create mode 100644 src/overlays/lightbox.rs create mode 100644 src/overlays/spotlight.rs create mode 100644 src/overlays/tests/guides.rs rename src/overlays/{tests.rs => tests/mod.rs} (98%) diff --git a/AGENTS.md b/AGENTS.md index fb78a57..16ee15f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A component that takes a starting value from its owner keeps it in `layout::seeded`: a new value from the owner replaces local drags. - The capture harness finds gpui windows by handle through `raw-window-handle`. AppKit popups, which gpui does not own, come from the window list by process and level. - Posted events cannot move a macOS window, so window drags are not scripted. `drag_region` follows Zed's title bar. -- An overlay takes focus with `primitives::take_focus` and returns it with `give_back` on every close path. +- An overlay takes focus with `primitives::take_focus` and returns it with `give_back` on every close path. While open, it takes focus back whenever the focused element leaves the tree (gpui's `on_focus_lost`). - A state change made outside render calls `cx.notify()`, or nothing redraws. - The macOS share picker draws its content in another process; a capture shows only its frame. - NSColorSampler runs in another process too. The harness cannot pick or cancel with it; `read_srgb` is tested on a constructed `NSColor` instead. @@ -66,7 +66,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - `primitives::Severity` names info, success, warning and danger with their colors and icons, for alerts and feedback alike. - A marker that slides between items (segment thumb, tab line) measures them with `motion::slide` and eases with `glide`. - A picker hands focus to its popup on open and back to its trigger on pick or Escape; it closes once focus leaves both (`forms::date::picker`). -- Motion for a value change keys its animation on `motion::changes`, so it replays per change and stays still on first paint. +- Motion for a value change keys its animation on `motion::changes`, so it replays per change and stays still on first paint. Key only the part that moves: a keyed ancestor gives every descendant a new id, and focused buttons lose focus. +- A spotlight or tour takes its target's box from `primitives::Measure`; the lit box glides between targets. - Thumbs stay inside their component's box: the track is padded by half a thumb (`Slider`, `ColorPicker`, `GradientEditor`). - gpui's `Hsla::opacity` scales alpha; `alpha` sets it. - A rounded box does not clip its children. Each layer inside takes the radius itself; `checker` takes one. @@ -80,6 +81,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Masks reshape edits inside `TextInput::set_fit`, which sees the replaced range and the typed text. A diff after the fact cannot tell typed characters from kept ones. - Ids inside a reusable component carry its owner's id or `EntityId`, animation ids too. Twin ids share focus, click and animation state. - Set an explicit line height on any box that clips text. gpui's default leading is taller than a tight box, and the clip eats descenders. +- gpui's `img` takes its picture's pixel size for any `Auto` side, and its aspect ratio beats percent heights in flow. `Image` pins it absolute at full size, so an `Image` needs a sized box. - `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/TASKS.md b/TASKS.md index 233839d..579836f 100644 --- a/TASKS.md +++ b/TASKS.md @@ -35,7 +35,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T08a Menus · Menu & hosts - [x] T08b Menus · Bar, search & pie - [x] T09a Overlays · Popovers & dialogs -- [ ] T09b Overlays · Guides & floats +- [x] T09b Overlays · Guides & floats - [ ] T10 Feedback - [ ] T11a Loading — `tasks/ch11-20.md` - [ ] T11b Motion @@ -156,3 +156,6 @@ Per-component lines live in `tasks/`. Tags there: | T09a | 1 | FAIL | dialog buttons closed through the owner and skipped give_back, popover and hover panels let presses through, and the popover guessed its height | | T09a | 2 | FAIL | the prompt's Enter release ran from anywhere in the dialog, so Enter on Cancel submitted and on Submit sent twice | | T09a | 3 | PASS | | +| T09b | 1 | FAIL | the lightbox photo was cropped, the toolbar kept last frame's anchor, Escape missed a spotlight whose target held focus, a disabled Next dropped focus, and a dead arrow's press closed the lightbox | +| T09b | 2 | FAIL | Enter on a focused Back that returned the tour to its first step removed the button and lost focus, so the arrows stopped | +| T09b | 3 | FAIL | Left from a focused Back also removed it and lost focus; take_focus now takes focus back whenever the focused element leaves the tree (fixed after the cap) | diff --git a/examples/gallery/pages/overlays/anchored.rs b/examples/gallery/pages/overlays/anchored.rs new file mode 100644 index 0000000..74bbed9 --- /dev/null +++ b/examples/gallery/pages/overlays/anchored.rs @@ -0,0 +1,152 @@ +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + forms::Switch, + overlays::{HoverCard, Popover}, + primitives::IconName, + theme::{ActiveTheme, TextSize}, +}; +use gpui::{AnyElement, App, FontWeight, IntoElement, ParentElement, Styled, Window, div}; + +use crate::{ + probe::probe, + ui::{keep, row, section, set, specimen}, +}; + +pub fn popovers(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let public = keep("share-public", || true, window, cx); + let on = *public.read(cx); + let theme = cx.theme(); + let muted = theme.colors.fg_muted; + let panel = move |_: &mut Window, cx: &mut App| { + let theme = cx.theme(); + div() + .flex() + .flex_col() + .gap_3() + .w_72() + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .child("Share this page"), + ) + .child( + div() + .text_color(theme.colors.fg_muted) + .child("Anyone with the link can view it."), + ) + .child( + Switch::new("share-switch", on) + .label("Public link") + .on_change(move |next, _, cx| set(&public, next, cx)), + ) + .child( + Button::new("share-copy", "Copy link") + .icon(IconName::Link) + .full_width(), + ) + }; + section( + "Popover / DropdownPanel / InlinePopup", + "A panel of any content under its button. A press outside or Escape closes it, and Tab stays inside. The Link variant sets the trigger inline.", + cx, + ) + .child( + row() + .child(probe( + "share", + Popover::new("share", "Share", panel).icon(IconName::Share2), + )) + .child( + div() + .flex() + .items_center() + .gap_1() + .text_color(muted) + .child("Read the") + .child( + Popover::new("terms", "terms", |_, cx| { + div() + .w_72() + .text_color(cx.theme().colors.fg_muted) + .child("Plain words: you own your work, and we keep it private.") + }) + .variant(ButtonVariant::Link), + ) + .child("before you share."), + ), + ) +} + +fn profile(cx: &App) -> AnyElement { + let theme = cx.theme(); + let colors = &theme.colors; + div() + .flex() + .flex_col() + .gap_3() + .w_64() + .child( + div() + .flex() + .items_center() + .gap_3() + .child( + div() + .flex() + .items_center() + .justify_center() + .size_10() + .rounded_full() + .bg(colors.hover) + .font_weight(FontWeight::SEMIBOLD) + .child("AL"), + ) + .child( + div() + .flex() + .flex_col() + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .child("Ada Lovelace"), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child("Design · London"), + ), + ), + ) + .child( + div() + .text_color(colors.fg_muted) + .child("Writes the first programs for machines not yet built."), + ) + .child(Button::new("ada-follow", "Follow").primary()) + .into_any_element() +} + +pub fn hover_card(cx: &App) -> impl IntoElement + use<> { + let theme = cx.theme(); + section( + "HoverCard", + "Rest the pointer on a name and a card opens. It stays while the pointer is on either, and never takes focus.", + cx, + ) + .child(specimen( + "rest the pointer on the name", + probe( + "ada", + HoverCard::new( + "ada", + div() + .text_color(theme.colors.link) + .font_weight(FontWeight::MEDIUM) + .child("@ada"), + |_, cx| profile(cx), + ), + ), + cx, + )) +} diff --git a/examples/gallery/pages/overlays.rs b/examples/gallery/pages/overlays/dialogs.rs similarity index 50% rename from examples/gallery/pages/overlays.rs rename to examples/gallery/pages/overlays/dialogs.rs index 8a7f48f..4ffef03 100644 --- a/examples/gallery/pages/overlays.rs +++ b/examples/gallery/pages/overlays/dialogs.rs @@ -1,207 +1,21 @@ use ely_gpui_component::{ buttons::{Button, ButtonVariant}, - forms::{Input, Switch, TextInput}, - overlays::{AlertDialog, ConfirmDialog, Dialog, HoverCard, Popover, PromptDialog}, - primitives::{IconName, Severity}, - theme::{ActiveTheme, TextSize}, + forms::{Input, TextInput}, + overlays::{AlertDialog, ConfirmDialog, Dialog, PromptDialog}, + primitives::Severity, + theme::ActiveTheme, typography::Caption, }; use gpui::{ - AnyElement, App, Entity, FontWeight, IntoElement, ParentElement, SharedString, Styled, Window, - div, prelude::*, + AnyElement, App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, + prelude::*, }; -use super::Page; use crate::{ probe::probe, - script::Step, ui::{keep, row, section, set, specimen}, }; -pub const PAGE: Page = Page { - number: 9, - slug: "overlays", - title: "Overlays", - summary: "Panels over the page: anchored to a control, over a scrim, or over everything.", - render, - script: SCRIPT, -}; - -const SCRIPT: &[Step] = &[ - Step::Rest, - Step::Click("share"), - Step::Wait(400), - Step::Shot("popover"), - Step::Key("escape"), - Step::Hover("ada"), - Step::Wait(900), - Step::Shot("hovercard"), - Step::Hover("share"), - Step::Wait(400), - Step::Click("edit-profile"), - Step::Wait(500), - Step::Shot("dialog"), - Step::Key("escape"), - Step::Click("alert"), - Step::Wait(500), - Step::Shot("alert"), - Step::Key("escape"), - Step::Click("confirm"), - Step::Wait(500), - Step::Shot("confirm"), - Step::Key("escape"), - Step::Click("prompt"), - Step::Wait(400), - Step::Type("drafts/2027"), - Step::Wait(300), - Step::Shot("prompt"), - Step::Key("escape"), - Step::Click("fullscreen"), - Step::Wait(600), - Step::Shot("fullscreen"), - Step::Key("escape"), - Step::Rest, -]; - -fn popovers(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { - let public = keep("share-public", || true, window, cx); - let on = *public.read(cx); - let theme = cx.theme(); - let muted = theme.colors.fg_muted; - let panel = move |_: &mut Window, cx: &mut App| { - let theme = cx.theme(); - div() - .flex() - .flex_col() - .gap_3() - .w_72() - .child( - div() - .font_weight(FontWeight::SEMIBOLD) - .child("Share this page"), - ) - .child( - div() - .text_color(theme.colors.fg_muted) - .child("Anyone with the link can view it."), - ) - .child( - Switch::new("share-switch", on) - .label("Public link") - .on_change(move |next, _, cx| set(&public, next, cx)), - ) - .child( - Button::new("share-copy", "Copy link") - .icon(IconName::Link) - .full_width(), - ) - }; - section( - "Popover / DropdownPanel / InlinePopup", - "A panel of any content under its button. A press outside or Escape closes it, and Tab stays inside. The Link variant sets the trigger inline.", - cx, - ) - .child( - row() - .child(probe( - "share", - Popover::new("share", "Share", panel).icon(IconName::Share2), - )) - .child( - div() - .flex() - .items_center() - .gap_1() - .text_color(muted) - .child("Read the") - .child( - Popover::new("terms", "terms", |_, cx| { - div() - .w_72() - .text_color(cx.theme().colors.fg_muted) - .child("Plain words: you own your work, and we keep it private.") - }) - .variant(ButtonVariant::Link), - ) - .child("before you share."), - ), - ) -} - -fn profile(cx: &App) -> AnyElement { - let theme = cx.theme(); - let colors = &theme.colors; - div() - .flex() - .flex_col() - .gap_3() - .w_64() - .child( - div() - .flex() - .items_center() - .gap_3() - .child( - div() - .flex() - .items_center() - .justify_center() - .size_10() - .rounded_full() - .bg(colors.hover) - .font_weight(FontWeight::SEMIBOLD) - .child("AL"), - ) - .child( - div() - .flex() - .flex_col() - .child( - div() - .font_weight(FontWeight::SEMIBOLD) - .child("Ada Lovelace"), - ) - .child( - div() - .text_size(theme.text_size(TextSize::Xs)) - .text_color(colors.fg_subtle) - .child("Design · London"), - ), - ), - ) - .child( - div() - .text_color(colors.fg_muted) - .child("Writes the first programs for machines not yet built."), - ) - .child(Button::new("ada-follow", "Follow").primary()) - .into_any_element() -} - -fn hover_card(cx: &App) -> impl IntoElement + use<> { - let theme = cx.theme(); - section( - "HoverCard", - "Rest the pointer on a name and a card opens. It stays while the pointer is on either, and never takes focus.", - cx, - ) - .child(specimen( - "rest the pointer on the name", - probe( - "ada", - HoverCard::new( - "ada", - div() - .text_color(theme.colors.link) - .font_weight(FontWeight::MEDIUM) - .child("@ada"), - |_, cx| profile(cx), - ), - ), - cx, - )) -} - /// Which dialog is open, if any. #[derive(Clone, Copy, PartialEq)] enum Open { @@ -225,7 +39,7 @@ fn opener( ) } -fn dialogs(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { +pub fn dialogs(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { let open = keep("overlay-open", || None::, window, cx); let said = keep("overlay-said", || None::, window, cx); let (name, email, rename) = { @@ -360,22 +174,13 @@ fn dialogs(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { .children(overlay) } -fn pointers(cx: &App) -> impl IntoElement + use<> { +pub fn pointers(cx: &App) -> impl IntoElement + use<> { section( - "Tooltip / Sheet / Drawer", + "Tooltip / Sheet / Drawer / DropdownPanel / InlinePopup / ToastViewport", "They live with the pieces they are made of.", cx, ) .child(Caption::new( - "Tooltip is primitives::Tooltip, on Primitives, chapter 1. Sheet and Drawer are layout::Sheet and layout::Drawer, on Layout, chapter 3.", + "Tooltip is primitives::Tooltip, on Primitives, chapter 1. Sheet and Drawer are layout::Sheet and layout::Drawer, on Layout, chapter 3. DropdownPanel and InlinePopup are the Popover above. ToastViewport holds toasts, so it lands with Feedback, chapter 10.", )) } - -fn render(window: &mut Window, cx: &mut App) -> AnyElement { - div() - .child(popovers(window, cx)) - .child(hover_card(cx)) - .child(dialogs(window, cx)) - .child(pointers(cx)) - .into_any_element() -} diff --git a/examples/gallery/pages/overlays/guides.rs b/examples/gallery/pages/overlays/guides.rs new file mode 100644 index 0000000..17ec1f0 --- /dev/null +++ b/examples/gallery/pages/overlays/guides.rs @@ -0,0 +1,222 @@ +use std::path::Path; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, IconButton}, + forms::{Input, TextInput}, + overlays::{FloatingToolbar, Lightbox, Peek, Slide, Spotlight, Tour, TourStep}, + primitives::{IconName, Image, Measure}, + theme::ActiveTheme, +}; +use gpui::{ + App, Bounds, Entity, InteractiveElement, IntoElement, ObjectFit, ParentElement, Pixels, + SharedString, StatefulInteractiveElement, Styled, Window, div, +}; + +use crate::{ + probe::probe, + ui::{keep, row, section, set, specimen}, +}; + +const PHOTOS: [(&str, &str); 2] = [ + ( + concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/dunes.jpg" + ), + "Dunes at first light", + ), + ( + concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/atrium.jpg" + ), + "An atrium, noon", + ), +]; + +pub fn lightbox(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let shown = keep("lightbox-at", || None::, window, cx); + let thumbs = PHOTOS.iter().enumerate().map(|(ix, (path, _))| { + let open = shown.clone(); + probe( + ["lightbox-0", "lightbox-1"][ix], + div() + .id(("thumb", ix)) + .w_48() + .h_32() + .cursor_pointer() + .child( + Image::new(("thumb-image", ix), Path::new(*path)) + .fit(ObjectFit::Cover) + .size_full(), + ) + .on_click(move |_, _, cx| set(&open, Some(ix), cx)), + ) + }); + let overlay = (*shown.read(cx)).map(|at| { + let (step, close) = (shown.clone(), shown.clone()); + Lightbox::new( + "lightbox", + PHOTOS.map(|(path, caption)| Slide::new(Path::new(path), caption)), + at, + move |_, cx| set(&close, None, cx), + ) + .on_step(move |to, _, cx| set(&step, Some(to), cx)) + }); + section( + "Lightbox", + "A picture over a dark layer, one at a time. Arrows step, Escape or a press on the dark closes it, and each picture fades in.", + cx, + ) + .child(row().children(thumbs)) + .children(overlay) +} + +/// Where each tour target sits, measured as it renders. +type Boxes = Entity<[Bounds; 3]>; + +fn target(boxes: &Boxes, ix: usize, child: impl IntoElement) -> impl IntoElement { + let boxes = boxes.clone(); + Measure::new(("tour-target", ix), move |bounds, _, cx| { + let mut next = *boxes.read(cx); + next[ix] = bounds; + set(&boxes, next, cx); + }) + .child(child) +} + +pub fn tour(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let boxes: Boxes = keep("tour-boxes", || [Bounds::default(); 3], window, cx); + let at = keep("tour-at", || None::, window, cx); + let coach = keep("coach-open", || false, window, cx); + let spots = *boxes.read(cx); + let steps = [ + ("Search", "Find any page, person or file from here."), + ("Invite", "Bring your team in. They see what you share."), + ("Settings", "Theme, density and keys live here."), + ]; + let overlay = (*at.read(cx)).map(|step| { + let (next, close) = (at.clone(), at.clone()); + Tour::new( + "tour", + steps + .iter() + .zip(spots) + .map(|((title, body), spot)| TourStep::new(spot, *title, *body)), + step, + move |_, cx| set(&close, None, cx), + ) + .on_step(move |to, _, cx| set(&next, Some(to), cx)) + .into_any_element() + }); + let coachmark = (*coach.read(cx)).then(|| { + let close = coach.clone(); + Spotlight::new( + "coach", + TourStep::new( + spots[1], + "New: invites", + "Invite by name or email; links expire in a week.", + ), + move |_, cx| set(&close, false, cx), + ) + .into_any_element() + }); + let (start, show) = (at.clone(), coach.clone()); + section( + "Spotlight / Coachmark / Tour", + "The window dims around one control and a card explains it. A tour walks the stops; the lit box glides from one to the next.", + cx, + ) + .child( + row() + .child(target(&boxes, 0, Button::new("tour-search", "Search").icon(IconName::Search))) + .child(target(&boxes, 1, Button::new("tour-invite", "Invite").icon(IconName::Users))) + .child(target(&boxes, 2, IconButton::new("tour-settings", IconName::Settings))) + .child(div().w_8()) + .child(probe( + "tour", + Button::new("tour-start", "Take the tour") + .primary() + .on_click(move |_, _, cx| set(&start, Some(0), cx)), + )) + .child(probe( + "coach", + Button::new("coach-start", "Show a coachmark") + .variant(ButtonVariant::Ghost) + .on_click(move |_, _, cx| set(&show, true, cx)), + )), + ) + .children(overlay) + .children(coachmark) +} + +pub fn toolbar(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let field: Entity = window.use_keyed_state("toolbar-field", cx, |window, cx| { + let mut input = TextInput::new(window, cx); + input.set_text("Select some of these words to see the tools.", cx); + input + }); + let used = keep("toolbar-used", || None::, window, cx); + let caption = used + .read(cx) + .clone() + .map_or("select text in the field".to_string(), |tool| { + format!("applied {tool}") + }); + let tool = |name: &'static str, icon: IconName| { + let used = used.clone(); + IconButton::new(SharedString::from(format!("tool-{name}")), icon) + .tooltip(name) + .on_click(move |_, _, cx| set(&used, Some(name.into()), cx)) + }; + section( + "FloatingToolbar", + "Tools float over a selection while the field has focus, and leave focus where it is.", + cx, + ) + .child(specimen( + caption, + probe( + "toolbar", + div().w_96().child(Input::new(&field)).child( + FloatingToolbar::new("toolbar", &field) + .child(tool("bold", IconName::Bold)) + .child(tool("italic", IconName::Italic)) + .child(tool("link", IconName::Link)), + ), + ), + cx, + )) +} + +pub fn peek(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let open = keep("peek-open", || false, window, cx); + let shown = *open.read(cx); + let (toggle, close) = (open.clone(), open.clone()); + let theme = cx.theme(); + section( + "Callout / Peek", + "A panel opens in the flow under its line, the way an editor peeks at a definition, and pushes what follows down.", + cx, + ) + .child( + div() + .w_128() + .font_family(theme.mono_family.clone()) + .text_color(theme.colors.fg_muted) + .child( + div().flex().child("let colors = cx.").child(probe( + "peek", + Button::new("peek-theme", "theme()") + .variant(ButtonVariant::Link) + .on_click(move |_, _, cx| set(&toggle, !shown, cx)), + )), + ) + .child( + Peek::new("peek-theme-def", "src/theme/mod.rs", shown, move |_, cx| set(&close, false, cx)) + .child("pub trait ActiveTheme { fn theme(&self) -> &Theme; }"), + ) + .child(div().child("let accent = colors.accent;")), + ) +} diff --git a/examples/gallery/pages/overlays/mod.rs b/examples/gallery/pages/overlays/mod.rs new file mode 100644 index 0000000..ac4c032 --- /dev/null +++ b/examples/gallery/pages/overlays/mod.rs @@ -0,0 +1,88 @@ +mod anchored; +mod dialogs; +mod guides; + +use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; + +use super::Page; +use crate::script::Step; + +pub const PAGE: Page = Page { + number: 9, + slug: "overlays", + title: "Overlays", + summary: "Panels over the page: anchored to a control, over a scrim, or over everything.", + render, + script: SCRIPT, +}; + +const SCRIPT: &[Step] = &[ + Step::Rest, + Step::Click("share"), + Step::Wait(400), + Step::Shot("popover"), + Step::Key("escape"), + Step::Hover("ada"), + Step::Wait(900), + Step::Shot("hovercard"), + Step::Hover("share"), + Step::Wait(400), + Step::Click("edit-profile"), + Step::Wait(500), + Step::Shot("dialog"), + Step::Key("escape"), + Step::Click("alert"), + Step::Wait(500), + Step::Shot("alert"), + Step::Key("escape"), + Step::Click("confirm"), + Step::Wait(500), + Step::Shot("confirm"), + Step::Key("escape"), + Step::Click("prompt"), + Step::Wait(400), + Step::Type("drafts/2027"), + Step::Wait(300), + Step::Shot("prompt"), + Step::Key("escape"), + Step::Click("fullscreen"), + Step::Wait(600), + Step::Shot("fullscreen"), + Step::Key("escape"), + Step::Click("lightbox-0"), + Step::Wait(700), + Step::Shot("lightbox"), + Step::Key("right"), + Step::Wait(500), + Step::Shot("lightbox-next"), + Step::Key("escape"), + Step::Click("tour"), + Step::Wait(700), + Step::Shot("tour"), + Step::Key("right"), + Step::Wait(700), + Step::Shot("tour-next"), + Step::Key("escape"), + Step::Click("toolbar"), + Step::Key("secondary-a"), + Step::Wait(400), + Step::Shot("toolbar"), + Step::Click("peek"), + Step::Wait(500), + Step::Shot("peek"), + Step::Click("peek"), + Step::Rest, +]; + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(anchored::popovers(window, cx)) + .child(anchored::hover_card(cx)) + .child(dialogs::dialogs(window, cx)) + .child(guides::lightbox(window, cx)) + .child(guides::tour(window, cx)) + .child(guides::toolbar(window, cx)) + .child(guides::peek(window, cx)) + .child(dialogs::pointers(cx)) + .into_any_element() +} diff --git a/src/overlays/floats.rs b/src/overlays/floats.rs new file mode 100644 index 0000000..cf2481c --- /dev/null +++ b/src/overlays/floats.rs @@ -0,0 +1,169 @@ +use std::rc::Rc; + +use gpui::{ + AnimationExt, AnyElement, App, Corner, ElementId, Entity, FontWeight, InteractiveElement, + IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, anchored, canvas, + deferred, div, point, prelude::*, +}; +use smallvec::SmallVec; + +use crate::{ + buttons::{ButtonVariant, IconButton}, + forms::{Run, TextInput, surface}, + layout::Collapsible, + motion, + primitives::IconName, + theme::{ActiveTheme, ControlSize, TextSize}, +}; + +/// Tools that float over a text field's selection while it has one and holds focus. Its buttons leave focus in the field. +#[derive(IntoElement)] +pub struct FloatingToolbar { + id: ElementId, + field: Entity, + tools: SmallVec<[AnyElement; 4]>, +} + +impl FloatingToolbar { + pub fn new(id: impl Into, field: &Entity) -> Self { + Self { + id: id.into(), + field: field.clone(), + tools: SmallVec::new(), + } + } +} + +impl ParentElement for FloatingToolbar { + fn extend(&mut self, elements: impl IntoIterator) { + self.tools.extend(elements); + } +} + +impl RenderOnce for FloatingToolbar { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let seen = window.use_keyed_state((self.id.clone(), "seen"), cx, |_, _| None); + let input = self.field.read(cx); + let selection = input.selection(); + let shown = !selection.is_empty() && input.focus().is_focused(window); + let start = input.bounds_for(selection.start); + let anchor = start.filter(|_| shown); + let (field, from) = (self.field.clone(), selection.start); + div() + .id(self.id.clone()) + .child(canvas( + |_, _, _| {}, + move |_, _, _, cx| { + let now = field.read(cx).bounds_for(from); + if now != start { + log::debug!("floating toolbar: the selection moved to {now:?}"); + seen.update(cx, |seen, cx| { + *seen = now; + cx.notify(); + }); + } + }, + )) + .when_some(anchor, |host, anchor| { + let lift = cx.theme().float_gap().to_pixels(window.rem_size()); + host.child( + deferred( + anchored() + .position(anchor.origin - point(gpui::Pixels::ZERO, lift)) + .anchor(Corner::BottomLeft) + .snap_to_window() + .child( + surface((self.id.clone(), "bar"), cx) + .occlude() + .flex() + .items_center() + .gap_0p5() + .p_1() + .children(self.tools) + .with_animation( + (self.id.clone(), "in"), + gpui::Animation::new(motion::duration(motion::FAST, cx)) + .with_easing(motion::ease_out_cubic), + |bar, t| bar.opacity(t).mb(motion::NUDGE * (1.0 - t)), + ), + ), + ) + .with_priority(1), + ) + }) + } +} + +/// A panel that opens in the flow under a line, the way an editor peeks at a definition: a title bar with a close button over the owner's content. +#[derive(IntoElement)] +pub struct Peek { + id: ElementId, + title: SharedString, + open: bool, + body: SmallVec<[AnyElement; 2]>, + on_close: Run, +} + +impl Peek { + pub fn new( + id: impl Into, + title: impl Into, + open: bool, + on_close: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + title: title.into(), + open, + body: SmallVec::new(), + on_close: Rc::new(on_close), + } + } +} + +impl ParentElement for Peek { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +impl RenderOnce for Peek { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = &theme.colors; + let (id, close) = (self.id.clone(), self.on_close); + Collapsible::new(self.id.clone(), self.open).child( + div() + .my_2() + .border_l_2() + .border_color(colors.accent) + .bg(colors.surface) + .child( + div() + .flex() + .items_center() + .justify_between() + .gap_2() + .h(theme.control_height(ControlSize::Lg)) + .pl_3() + .pr_1() + .border_b_1() + .border_color(colors.border) + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::MEDIUM) + .child(self.title) + .child( + IconButton::new((self.id.clone(), "close"), IconName::X) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip("Close") + .on_click(move |_, window, cx| { + log::info!("peek {id:?}: closed"); + close(window, cx) + }), + ), + ) + .child(div().p_3().children(self.body)), + ) + } +} diff --git a/src/overlays/lightbox.rs b/src/overlays/lightbox.rs new file mode 100644 index 0000000..9293b96 --- /dev/null +++ b/src/overlays/lightbox.rs @@ -0,0 +1,280 @@ +use std::rc::Rc; + +use gpui::{ + Animation, AnimationExt, App, Div, ElementId, ImageSource, InteractiveElement, IntoElement, + MouseButton, ObjectFit, ParentElement, Pixels, Point, RenderOnce, SharedString, Size, Stateful, + StatefulInteractiveElement, Styled, Window, anchored, canvas, deferred, div, prelude::*, +}; + +use crate::{ + forms::Run, + motion, + primitives::{FocusRing, FocusScope, Icon, IconName, Image, give_back, take_focus}, + theme::{ActiveTheme, ControlSize, IconSize, TextSize}, +}; + +/// One picture in a lightbox, and the line under it. +#[derive(Clone)] +pub struct Slide { + source: ImageSource, + caption: SharedString, +} + +impl Slide { + pub fn new(source: impl Into, caption: impl Into) -> Self { + Self { + source: source.into(), + caption: caption.into(), + } + } +} + +type OnStep = Rc; + +/// A round button drawn for the dark layer: light icon, a faint fill on hover, dim when it has nowhere to go. +fn media_button( + id: (ElementId, &'static str), + icon: IconName, + enabled: bool, + run: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> Stateful
{ + let theme = cx.theme(); + let light = theme.colors.on_media; + div() + .id(id) + .flex() + .flex_none() + .items_center() + .justify_center() + .size(theme.control_height(ControlSize::Lg)) + .rounded_full() + .border_1() + .border_color(gpui::transparent_black()) + .child(Icon::new(icon).size(IconSize::Md).color(if enabled { + light + } else { + light.opacity(0.3) + })) + .on_mouse_down(MouseButton::Left, |_, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + }) + .when(enabled, |button| { + button + .cursor_pointer() + .tab_index(0) + .focus_ring(cx) + .hover(|style| style.bg(light.opacity(0.12))) + .on_click(move |_, window, cx| run(window, cx)) + }) +} + +/// Pictures over a dark layer, one at a time. Left and Right or the side buttons step through them; Escape, the close button or a press on the dark closes it. +#[derive(IntoElement)] +pub struct Lightbox { + id: ElementId, + slides: Vec, + at: usize, + on_step: Option, + on_close: Run, +} + +impl Lightbox { + /// Render it while open; the owner keeps `at` and moves it in `on_step`. + pub fn new( + id: impl Into, + slides: impl IntoIterator, + at: usize, + on_close: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + let slides: Vec = slides.into_iter().collect(); + assert!( + at < slides.len(), + "slide {at} is past the last of {}", + slides.len() + ); + Self { + id: id.into(), + slides, + at, + on_step: None, + on_close: Rc::new(on_close), + } + } + + pub fn on_step(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_step = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for Lightbox { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let (at, count) = (self.at, self.slides.len()); + let turn = motion::changes((self.id.clone(), "at"), at, window, cx); + let takeover = take_focus((self.id.clone(), "takeover"), window, cx); + let focus = takeover.read(cx).focus.clone(); + let area = window.use_keyed_state((self.id.clone(), "area"), cx, |_, _| { + Size::::default() + }); + let room = *area.read(cx); + let close: Run = { + let (id, on_close) = (self.id.clone(), self.on_close); + Rc::new(move |window, cx| { + log::info!("lightbox {id:?}: closed"); + give_back(&takeover, window, cx); + on_close(window, cx) + }) + }; + let step: OnStep = { + let (id, on_step) = (self.id.clone(), self.on_step); + Rc::new(move |to, window, cx| { + log::info!("lightbox {id:?}: slide {to}"); + if let Some(on_step) = &on_step { + on_step(to, window, cx); + } + }) + }; + let (back, next) = (at.checked_sub(1), (at + 1 < count).then_some(at + 1)); + let side = |name: &'static str, icon: IconName, to: Option, cx: &App| { + let step = step.clone(); + media_button( + (self.id.clone(), name), + icon, + to.is_some(), + move |window, cx| { + if let Some(to) = to { + step(to, window, cx); + } + }, + cx, + ) + }; + let theme = cx.theme(); + let colors = &theme.colors; + let slide = self.slides[at].clone(); + let picture = div() + .size_full() + .child( + Image::new((self.id.clone(), format!("slide-{at}")), slide.source) + .fit(ObjectFit::Contain) + .w(room.width) + .h(room.height), + ) + .with_animation( + (self.id.clone(), format!("turn-{turn}")), + Animation::new(motion::duration(motion::BASE, cx)) + .with_easing(motion::ease_out_cubic), + move |picture, t| { + if turn == 0 { + picture + } else { + picture.opacity(t) + } + }, + ); + let (keys, escape, press, button) = (step.clone(), close.clone(), close.clone(), close); + let viewport = window.viewport_size(); + let stage = div() + .id((self.id.clone(), "stage")) + .w(viewport.width) + .h(viewport.height) + .bg(colors.media_backdrop) + .text_color(colors.on_media) + .occlude() + .on_mouse_down(MouseButton::Left, move |_, window, cx| press(window, cx)) + .on_key_down(move |event, window, cx| { + match (event.keystroke.key.as_str(), back, next) { + ("left", Some(to), _) | ("right", _, Some(to)) => keys(to, window, cx), + ("escape", _, _) => escape(window, cx), + _ => return, + } + cx.stop_propagation(); + }) + .child( + FocusScope::new(&focus) + .trap() + .size_full() + .flex() + .flex_col() + .child(div().flex().justify_end().p_3().child(media_button( + (self.id.clone(), "close"), + IconName::X, + true, + move |window, cx| button(window, cx), + cx, + ))) + .child( + div() + .relative() + .flex_1() + .min_h_0() + .child( + div() + .absolute() + .top_0() + .bottom_0() + .left_16() + .right_16() + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation() + }) + .child(picture) + .child( + canvas( + move |bounds, _, cx| { + if area.read(cx) != &bounds.size { + area.update(cx, |area, cx| { + *area = bounds.size; + cx.notify(); + }); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ), + ) + .child( + div() + .absolute() + .top_0() + .bottom_0() + .left_3() + .flex() + .items_center() + .child(side("back", IconName::ChevronLeft, back, cx)), + ) + .child( + div() + .absolute() + .top_0() + .bottom_0() + .right_3() + .flex() + .items_center() + .child(side("next", IconName::ChevronRight, next, cx)), + ), + ) + .child( + div() + .flex() + .justify_center() + .gap_3() + .p_5() + .text_size(theme.text_size(TextSize::Sm)) + .child(slide.caption) + .child( + div() + .text_color(colors.on_media.opacity(0.6)) + .child(format!("{} / {count}", at + 1)), + ), + ), + ); + deferred(anchored().position(Point::default()).child(stage)).with_priority(1) + } +} diff --git a/src/overlays/mod.rs b/src/overlays/mod.rs index 77649cf..f6ed0b5 100644 --- a/src/overlays/mod.rs +++ b/src/overlays/mod.rs @@ -1,11 +1,17 @@ mod dialog; mod dialogs; +mod floats; mod hover; +mod lightbox; mod popover; +mod spotlight; #[cfg(all(test, feature = "test-support"))] mod tests; pub use dialog::{Close, Dialog}; pub use dialogs::{AlertDialog, ConfirmDialog, PromptDialog}; +pub use floats::{FloatingToolbar, Peek}; pub use hover::HoverCard; +pub use lightbox::{Lightbox, Slide}; pub use popover::Popover; +pub use spotlight::{Spotlight, Tour, TourStep}; diff --git a/src/overlays/spotlight.rs b/src/overlays/spotlight.rs new file mode 100644 index 0000000..5358832 --- /dev/null +++ b/src/overlays/spotlight.rs @@ -0,0 +1,439 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + Animation, AnimationExt, AnyElement, App, Bounds, ElementId, FontWeight, InteractiveElement, + IntoElement, ParentElement, Pixels, Point, RenderOnce, SharedString, Styled, Subscription, + Window, anchored, canvas, deferred, div, point, prelude::*, size, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + forms::{Run, float_height, surface}, + motion, + primitives::{FocusScope, give_back, take_focus}, + theme::{ActiveTheme, Radius, TextSize}, +}; + +/// One stop of a tour: the box to light, a title and a line. +#[derive(Clone, Debug)] +pub struct TourStep { + target: Bounds, + title: SharedString, + body: SharedString, +} + +impl TourStep { + /// `target` is window coordinates, as `primitives::Measure` reports them. + pub fn new( + target: Bounds, + title: impl Into, + body: impl Into, + ) -> Self { + Self { + target, + title: title.into(), + body: body.into(), + } + } +} + +/// Escape from anywhere in the window, the lit target included, closes through the latest close. +struct Watch { + close: Rc>, + _escape: Subscription, +} + +/// Where the lit box glides from, and the card's measured height. +#[derive(Default)] +struct Lit { + from: Bounds, + to: Bounds, + card: Pixels, +} + +fn lerp(from: Bounds, to: Bounds, t: f32) -> Bounds { + let mix = |a: Pixels, b: Pixels| a + (b - a) * t; + Bounds::new( + point( + mix(from.origin.x, to.origin.x), + mix(from.origin.y, to.origin.y), + ), + size( + mix(from.size.width, to.size.width), + mix(from.size.height, to.size.height), + ), + ) +} + +/// Dims the window around the lit box, rings it, and floats `card` beside it. The box glides when the target moves. +fn light( + id: &ElementId, + target: Bounds, + card: AnyElement, + close: Run, + keys: impl Fn(&str, &mut Window, &mut App) -> bool + 'static, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let gap = cx.theme().float_gap().to_pixels(window.rem_size()); + let target = target.dilate(gap); + let state = window.use_keyed_state((id.clone(), "lit"), cx, |_, _| Lit { + from: target, + to: target, + card: Pixels::ZERO, + }); + if state.read(cx).to != target { + state.update(cx, |lit, _| { + lit.from = lit.to; + lit.to = target; + }); + } + let (from, card_height) = (state.read(cx).from, state.read(cx).card); + let turn = motion::changes((id.clone(), "target"), target, window, cx); + let theme = cx.theme(); + let viewport = window.viewport_size(); + let colors = theme.colors.clone(); + let radius = theme.radius(Radius::Md); + let frame = div().size_full().with_animation( + (id.clone(), format!("glide-{turn}")), + Animation::new(motion::duration(motion::SLOW, cx)).with_easing(motion::ease_in_out_cubic), + move |frame, t| { + let lit = if turn == 0 { + target + } else { + lerp(from, target, t) + }; + let dim = |left: Pixels, top: Pixels, width: Pixels, height: Pixels| { + div() + .absolute() + .left(left) + .top(top) + .w(width.max(Pixels::ZERO)) + .h(height.max(Pixels::ZERO)) + .bg(colors.backdrop) + .occlude() + }; + frame + .child(dim(Pixels::ZERO, Pixels::ZERO, viewport.width, lit.top())) + .child(dim( + Pixels::ZERO, + lit.bottom(), + viewport.width, + viewport.height - lit.bottom(), + )) + .child(dim(Pixels::ZERO, lit.top(), lit.left(), lit.size.height)) + .child(dim( + lit.right(), + lit.top(), + viewport.width - lit.right(), + lit.size.height, + )) + .child( + div() + .absolute() + .left(lit.left()) + .top(lit.top()) + .w(lit.size.width) + .h(lit.size.height) + .rounded(radius) + .border_2() + .border_color(colors.accent), + ) + }, + ); + let measure = state.clone(); + let watch = window.use_keyed_state((id.clone(), "escape"), cx, |window, cx| { + let latest = Rc::new(RefCell::new(close.clone())); + let (home, current) = (window.window_handle(), latest.clone()); + let _escape = cx.intercept_keystrokes(move |event, window, cx| { + let stroke = &event.keystroke; + if window.window_handle() == home + && stroke.key == "escape" + && !stroke.modifiers.modified() + { + cx.stop_propagation(); + let close = current.borrow().clone(); + close(window, cx); + } + }); + Watch { + close: latest, + _escape, + } + }); + *watch.read(cx).close.borrow_mut() = close; + let card = div() + .id((id.clone(), "card")) + .relative() + .occlude() + .on_key_down(move |event, window, cx| { + if keys(event.keystroke.key.as_str(), window, cx) { + cx.stop_propagation(); + } + }) + .child(card) + .child( + canvas( + move |bounds, _, cx| { + if measure.read(cx).card != bounds.size.height { + measure.update(cx, |lit, cx| { + lit.card = bounds.size.height; + cx.notify(); + }); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ); + div() + .child( + deferred( + anchored() + .position(Point::default()) + .child(div().w(viewport.width).h(viewport.height).child(frame)), + ) + .with_priority(1), + ) + .child(float_height(target, card_height, card, window, cx)) + .into_any_element() +} + +/// The card's words: a title, then a line. +fn words(title: SharedString, body: SharedString, cx: &App) -> impl IntoElement { + let theme = cx.theme(); + div() + .flex() + .flex_col() + .gap_1() + .child(div().font_weight(FontWeight::SEMIBOLD).child(title)) + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(body), + ) +} + +/// Dims the window around one box and sets a card beside it: a title, a line and one button. Escape or the button closes it. +#[derive(IntoElement)] +pub struct Spotlight { + id: ElementId, + step: TourStep, + label: SharedString, + on_close: Run, +} + +impl Spotlight { + pub fn new( + id: impl Into, + step: TourStep, + on_close: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + step, + label: "Got it".into(), + on_close: Rc::new(on_close), + } + } + + /// The button's label, Got it unless set. + pub fn label(mut self, label: impl Into) -> Self { + self.label = label.into(); + self + } +} + +impl RenderOnce for Spotlight { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let takeover = take_focus((self.id.clone(), "takeover"), window, cx); + let focus = takeover.read(cx).focus.clone(); + let close: Run = { + let (id, on_close) = (self.id.clone(), self.on_close); + Rc::new(move |window, cx| { + log::info!("spotlight {id:?}: closed"); + give_back(&takeover, window, cx); + on_close(window, cx) + }) + }; + let button = close.clone(); + let card = FocusScope::new(&focus).trap().child( + surface((self.id.clone(), "surface"), cx) + .p_4() + .flex() + .flex_col() + .gap_3() + .max_w(cx.theme().dialog_width()) + .child(words(self.step.title, self.step.body, cx)) + .child( + div().flex().justify_end().child( + Button::new((self.id.clone(), "done"), self.label) + .primary() + .on_click(move |_, window, cx| button(window, cx)), + ), + ), + ); + light( + &self.id, + self.step.target, + card.into_any_element(), + close, + |_, _, _| false, + window, + cx, + ) + } +} + +type OnStep = Rc; + +/// Spotlights one step after another: Back, Next and Done, the count, and Skip. Left and Right step too. The lit box glides between targets. +#[derive(IntoElement)] +pub struct Tour { + id: ElementId, + steps: Vec, + at: usize, + on_step: Option, + on_close: Run, +} + +impl Tour { + /// Render it while open; the owner keeps `at` and moves it in `on_step`. Done and Skip both close. + pub fn new( + id: impl Into, + steps: impl IntoIterator, + at: usize, + on_close: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + let steps: Vec = steps.into_iter().collect(); + assert!( + at < steps.len(), + "step {at} is past the last of {}", + steps.len() + ); + Self { + id: id.into(), + steps, + at, + on_step: None, + on_close: Rc::new(on_close), + } + } + + pub fn on_step(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_step = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for Tour { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let (at, count) = (self.at, self.steps.len()); + let takeover = take_focus((self.id.clone(), "takeover"), window, cx); + let focus = takeover.read(cx).focus.clone(); + let close: Run = { + let (id, on_close) = (self.id.clone(), self.on_close); + Rc::new(move |window, cx| { + log::info!("tour {id:?}: closed at step {at}"); + give_back(&takeover, window, cx); + on_close(window, cx) + }) + }; + let step: OnStep = { + let (id, on_step) = (self.id.clone(), self.on_step); + Rc::new(move |to, window, cx| { + log::info!("tour {id:?}: step {to}"); + if let Some(on_step) = &on_step { + on_step(to, window, cx); + } + }) + }; + let (back, next) = (at.checked_sub(1), (at + 1 < count).then_some(at + 1)); + let current = self.steps[at].clone(); + let turn = motion::changes((self.id.clone(), "at"), at, window, cx); + let theme = cx.theme(); + let (skip, done, back_step, next_step, keyed) = ( + close.clone(), + close.clone(), + step.clone(), + step.clone(), + step, + ); + let footer = div() + .flex() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_subtle) + .child(format!("{} of {count}", at + 1)), + ) + .child( + Button::new((self.id.clone(), "skip"), "Skip") + .variant(ButtonVariant::Ghost) + .on_click(move |_, window, cx| skip(window, cx)), + ) + .when_some(back, |row, to| { + row.child( + Button::new((self.id.clone(), "back"), "Back") + .on_click(move |_, window, cx| back_step(to, window, cx)), + ) + }) + .child( + Button::new( + (self.id.clone(), "primary"), + if next.is_some() { "Next" } else { "Done" }, + ) + .primary() + .on_click(move |_, window, cx| match next { + Some(to) => next_step(to, window, cx), + None => done(window, cx), + }), + ); + let card = FocusScope::new(&focus).trap().child( + surface((self.id.clone(), "surface"), cx) + .p_4() + .flex() + .flex_col() + .gap_4() + .max_w(theme.dialog_width()) + .child( + div() + .child(words(current.title, current.body, cx)) + .with_animation( + (self.id.clone(), format!("words-{turn}")), + Animation::new(motion::duration(motion::BASE, cx)) + .with_easing(motion::ease_out_cubic), + move |words, t| if turn == 0 { words } else { words.opacity(t) }, + ), + ) + .child(footer), + ); + let keys = move |key: &str, window: &mut Window, cx: &mut App| { + let to = match key { + "left" => back, + "right" => next, + _ => return false, + }; + if let Some(to) = to { + keyed(to, window, cx); + } + true + }; + light( + &self.id, + current.target, + card.into_any_element(), + close, + keys, + window, + cx, + ) + } +} diff --git a/src/overlays/tests/guides.rs b/src/overlays/tests/guides.rs new file mode 100644 index 0000000..d31b08f --- /dev/null +++ b/src/overlays/tests/guides.rs @@ -0,0 +1,335 @@ +use std::path::Path; + +use gpui::{ + AppContext as _, Bounds, Context, Entity, FocusHandle, InteractiveElement, IntoElement, + KeyBinding, ParentElement, Render, Styled, TestAppContext, VisualTestContext, Window, div, + point, px, size, +}; + +use super::{click, press, settle}; +use crate::{ + buttons::{Button, IconButton}, + forms::{Input, TextInput}, + overlays::{FloatingToolbar, Lightbox, Peek, Slide, Spotlight, Tour, TourStep}, + primitives::{FocusNext, FocusScope, IconName}, + theme::Theme, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Open { + Lightbox, + Spotlight, + SpotField, + Tour, + Peek, +} + +/// Opens one guide at a time over a page with a target button, recording what happens. +struct Guides { + root: FocusHandle, + before: FocusHandle, + field: Entity, + open: Option, + at: usize, + log: Vec, + lower: bool, +} + +fn target() -> Bounds { + Bounds::new(point(px(0.0), px(0.0)), size(px(90.0), px(28.0))) +} + +impl Render for Guides { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let note = |what: &'static str, cx: &mut Context| { + let view = cx.entity(); + move |_: &mut Window, cx: &mut gpui::App| { + view.update(cx, |guides, _| guides.log.push(what.into())) + } + }; + let (shut, stepped) = (cx.entity(), cx.entity()); + let shut = move |_: &mut Window, cx: &mut gpui::App| { + shut.update(cx, |guides, cx| { + guides.open = None; + cx.notify(); + }) + }; + let step = move |to: usize, _: &mut Window, cx: &mut gpui::App| { + stepped.update(cx, |guides, cx| { + guides.at = to; + cx.notify(); + }) + }; + let slides = ["a", "b", "c"].map(|name| Slide::new(Path::new("/missing/ely.png"), name)); + let steps = [ + TourStep::new(target(), "Here", "The first stop"), + TourStep::new( + Bounds::new(point(px(200.0), px(300.0)), size(px(90.0), px(28.0))), + "There", + "The second stop", + ), + ]; + let (pressed, bold) = (note("target", cx), note("bold", cx)); + let overlay = self.open.map(|open| match open { + Open::Lightbox => Lightbox::new("light", slides, self.at, shut) + .on_step(step) + .into_any_element(), + Open::Spotlight => Spotlight::new("spot", steps[0].clone(), shut).into_any_element(), + Open::SpotField => Spotlight::new( + "spot", + TourStep::new( + Bounds::new(point(px(0.0), px(188.0)), size(px(300.0), px(28.0))), + "Type here", + "The field takes focus", + ), + shut, + ) + .into_any_element(), + Open::Tour => Tour::new("tour", steps, self.at, shut) + .on_step(step) + .into_any_element(), + Open::Peek => Peek::new("peek", "Definition", true, shut) + .child("fn peek()") + .into_any_element(), + }); + FocusScope::new(&self.root) + .size_full() + .child( + Button::new("target", "Target").on_click(move |_, window, cx| pressed(window, cx)), + ) + .child(div().id("before").track_focus(&self.before)) + .child(div().h(px(if self.lower { 260.0 } else { 160.0 }))) + .child( + div().w(px(300.0)).child(Input::new(&self.field)).child( + FloatingToolbar::new("tools", &self.field).child( + IconButton::new("bold", IconName::Bold) + .on_click(move |_, window, cx| bold(window, cx)), + ), + ), + ) + .children(overlay) + } +} + +fn guides(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + cx.update(|cx| { + Theme::init(cx); + crate::forms::bind_keys(cx); + cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]); + }); + let (view, cx) = cx.add_window_view(|window, cx| Guides { + root: cx.focus_handle(), + before: cx.focus_handle(), + field: cx.new(|cx| { + let mut input = TextInput::new(window, cx); + input.set_text("hello world", cx); + input + }), + open: None, + at: 0, + log: Vec::new(), + lower: false, + }); + settle(cx); + (view, cx) +} + +fn open(view: &Entity, which: Open, cx: &mut VisualTestContext) { + let before = view.read_with(cx, |guides, _| guides.before.clone()); + cx.update(|window, _| window.focus(&before)); + view.update(cx, |guides, cx| { + guides.open = Some(which); + guides.at = 0; + cx.notify(); + }); + settle(cx); +} + +fn state(view: &Entity, cx: &mut VisualTestContext) -> (Option, usize, Vec) { + view.read_with(cx, |guides, _| (guides.open, guides.at, guides.log.clone())) +} + +fn before_focused(view: &Entity, cx: &mut VisualTestContext) -> bool { + let before = view.read_with(cx, |guides, _| guides.before.clone()); + cx.update(|window, _| before.is_focused(window)) +} + +#[gpui::test] +fn the_lightbox_steps_with_arrows_and_escape_hands_focus_back(cx: &mut TestAppContext) { + let (view, cx) = guides(cx); + open(&view, Open::Lightbox, cx); + for key in ["right", "right", "right", "left"] { + press(key, cx); + } + assert_eq!(state(&view, cx).1, 1); + press("escape", cx); + assert!(state(&view, cx).0.is_none()); + assert!(before_focused(&view, cx)); +} + +#[gpui::test] +fn the_dark_closes_the_lightbox_and_the_picture_does_not(cx: &mut TestAppContext) { + let (view, cx) = guides(cx); + open(&view, Open::Lightbox, cx); + let middle = cx.update(|window, _| window.viewport_size()); + click( + f32::from(middle.width) / 2.0, + f32::from(middle.height) / 2.0, + cx, + ); + assert_eq!(state(&view, cx).0, Some(Open::Lightbox)); + click(4.0, f32::from(middle.height) - 4.0, cx); + assert!(state(&view, cx).0.is_none()); +} + +#[gpui::test] +fn the_tour_walks_by_arrows_and_buttons_and_done_closes(cx: &mut TestAppContext) { + let (view, cx) = guides(cx); + open(&view, Open::Tour, cx); + press("right", cx); + assert_eq!(state(&view, cx).1, 1); + press("left", cx); + assert_eq!(state(&view, cx).1, 0); + press("tab", cx); + press("tab", cx); + press("enter", cx); + assert_eq!(state(&view, cx).1, 1, "Next moved on"); + press("enter", cx); + assert!( + state(&view, cx).0.is_none(), + "focus stayed on the button, now Done" + ); + assert!(before_focused(&view, cx)); +} + +#[gpui::test] +fn the_spotlight_passes_presses_through_its_hole_only(cx: &mut TestAppContext) { + let (view, cx) = guides(cx); + open(&view, Open::Spotlight, cx); + click(40.0, 14.0, cx); + assert_eq!( + state(&view, cx).2, + ["target"], + "the lit target took the press" + ); + click(40.0, 200.0, cx); + assert_eq!(state(&view, cx).0, Some(Open::Spotlight), "the dim held"); + press("escape", cx); + assert!(state(&view, cx).0.is_none()); +} + +#[gpui::test] +fn the_toolbar_floats_over_a_selection_only(cx: &mut TestAppContext) { + let (view, cx) = guides(cx); + let field = view.read_with(cx, |guides, _| guides.field.clone()); + let focus = field.read_with(cx, |input, _| input.focus().clone()); + cx.update(|window, _| window.focus(&focus)); + settle(cx); + let start = field + .read_with(cx, |input, _| input.bounds_for(0)) + .expect("the field laid out"); + let bold = ( + f32::from(start.left()) + 16.0, + f32::from(start.top()) - 22.0, + ); + click(bold.0, bold.1, cx); + assert!(state(&view, cx).2.is_empty(), "no selection, no toolbar"); + cx.update(|window, _| window.focus(&focus)); + press("secondary-a", cx); + click(bold.0, bold.1, cx); + assert_eq!(state(&view, cx).2, ["bold"]); +} + +#[gpui::test] +fn the_peek_closes_from_its_button(cx: &mut TestAppContext) { + let (view, cx) = guides(cx); + open(&view, Open::Peek, cx); + cx.update(|window, _| { + window.blur(); + for _ in 0..3 { + window.focus_next(); + } + }); + press("enter", cx); + assert!(state(&view, cx).0.is_none()); +} + +#[gpui::test] +fn escape_closes_a_spotlight_while_its_target_holds_focus(cx: &mut TestAppContext) { + let (view, cx) = guides(cx); + open(&view, Open::SpotField, cx); + click(40.0, 200.0, cx); + press("escape", cx); + assert!(state(&view, cx).0.is_none()); +} + +#[gpui::test] +fn the_lightbox_keeps_focus_when_its_button_disables(cx: &mut TestAppContext) { + let (view, cx) = guides(cx); + open(&view, Open::Lightbox, cx); + press("tab", cx); + press("tab", cx); + press("enter", cx); + press("enter", cx); + assert_eq!(state(&view, cx).1, 2, "Next walked to the end"); + press("escape", cx); + assert!(state(&view, cx).0.is_none()); +} + +#[gpui::test] +fn a_press_on_a_dead_arrow_keeps_the_lightbox(cx: &mut TestAppContext) { + let (view, cx) = guides(cx); + open(&view, Open::Lightbox, cx); + let screen = cx.update(|window, _| window.viewport_size()); + click(28.0, f32::from(screen.height) / 2.0, cx); + assert_eq!(state(&view, cx).0, Some(Open::Lightbox)); +} + +#[gpui::test] +fn the_toolbar_follows_its_field(cx: &mut TestAppContext) { + let (view, cx) = guides(cx); + let field = view.read_with(cx, |guides, _| guides.field.clone()); + let focus = field.read_with(cx, |input, _| input.focus().clone()); + cx.update(|window, _| window.focus(&focus)); + press("secondary-a", cx); + view.update(cx, |guides, cx| { + guides.lower = true; + cx.notify(); + }); + cx.run_until_parked(); + let start = field + .read_with(cx, |input, _| input.bounds_for(0)) + .expect("the field laid out"); + click( + f32::from(start.left()) + 16.0, + f32::from(start.top()) - 22.0, + cx, + ); + assert_eq!(state(&view, cx).2, ["bold"]); +} + +#[gpui::test] +fn back_to_the_first_step_keeps_the_tour_keys(cx: &mut TestAppContext) { + let (view, cx) = guides(cx); + open(&view, Open::Tour, cx); + press("right", cx); + press("tab", cx); + press("tab", cx); + press("enter", cx); + assert_eq!(state(&view, cx).1, 0, "Back went to the first step"); + press("right", cx); + assert_eq!(state(&view, cx).1, 1); +} + +#[gpui::test] +fn left_from_back_keeps_the_tour_keys(cx: &mut TestAppContext) { + let (view, cx) = guides(cx); + open(&view, Open::Tour, cx); + press("right", cx); + press("tab", cx); + press("tab", cx); + press("left", cx); + assert_eq!(state(&view, cx).1, 0); + press("right", cx); + assert_eq!(state(&view, cx).1, 1); +} diff --git a/src/overlays/tests.rs b/src/overlays/tests/mod.rs similarity index 98% rename from src/overlays/tests.rs rename to src/overlays/tests/mod.rs index 4ee91c9..d2ddd6f 100644 --- a/src/overlays/tests.rs +++ b/src/overlays/tests/mod.rs @@ -6,6 +6,8 @@ use gpui::{ VisualTestContext, Window, div, point, px, }; +mod guides; + use super::{AlertDialog, ConfirmDialog, Dialog, HoverCard, Popover, PromptDialog}; use crate::{ buttons::Button, @@ -130,13 +132,13 @@ impl Render for Stage { } } -fn settle(cx: &mut VisualTestContext) { +pub(super) fn settle(cx: &mut VisualTestContext) { cx.run_until_parked(); cx.update(|window, _| window.refresh()); cx.run_until_parked(); } -fn press(key: &str, cx: &mut VisualTestContext) { +pub(super) fn press(key: &str, cx: &mut VisualTestContext) { cx.simulate_keystrokes(key); settle(cx); cx.simulate_event(KeyUpEvent { @@ -145,7 +147,7 @@ fn press(key: &str, cx: &mut VisualTestContext) { settle(cx); } -fn click(x: f32, y: f32, cx: &mut VisualTestContext) { +pub(super) fn click(x: f32, y: f32, cx: &mut VisualTestContext) { let at = point(px(x), px(y)); cx.simulate_mouse_move(at, None, Modifiers::none()); cx.simulate_click(at, Modifiers::none()); diff --git a/src/primitives/focus.rs b/src/primitives/focus.rs index 2dde958..cc9bf59 100644 --- a/src/primitives/focus.rs +++ b/src/primitives/focus.rs @@ -1,6 +1,6 @@ use gpui::{ AnyElement, App, Div, ElementId, Entity, FocusHandle, InteractiveElement, IntoElement, - ParentElement, RenderOnce, StyleRefinement, Styled, Window, actions, div, + ParentElement, RenderOnce, StyleRefinement, Styled, Subscription, Window, actions, div, }; use crate::theme::ActiveTheme; @@ -100,18 +100,27 @@ pub(crate) struct Takeover { pub focus: FocusHandle, previous: Option, taken: bool, + returned: bool, + _lost: Subscription, } -/// Keyed focus for an overlay; focused on its first render. +/// Keyed focus for an overlay; focused on its first render, and again whenever the focused element leaves the tree. pub(crate) fn take_focus( key: impl Into, window: &mut Window, cx: &mut App, ) -> Entity { - let state = window.use_keyed_state(key, cx, |_, cx| Takeover { + let state = window.use_keyed_state(key, cx, |window, cx| Takeover { focus: cx.focus_handle(), previous: None, taken: false, + returned: false, + _lost: cx.on_focus_lost(window, |takeover: &mut Takeover, window, _| { + if !takeover.returned { + log::info!("focus: its element left the tree; the overlay takes it back"); + window.focus(&takeover.focus); + } + }), }); if !state.read(cx).taken { let previous = window.focused(cx); @@ -125,8 +134,12 @@ pub(crate) fn take_focus( } /// Returns focus to whatever held it before the overlay opened. -pub(crate) fn give_back(state: &Entity, window: &mut Window, cx: &App) { - match state.read(cx).previous.clone() { +pub(crate) fn give_back(state: &Entity, window: &mut Window, cx: &mut App) { + let previous = state.update(cx, |takeover, _| { + takeover.returned = true; + takeover.previous.clone() + }); + match previous { Some(previous) => { window.focus(&previous); log::info!("focus: handed back after an overlay"); diff --git a/src/primitives/image.rs b/src/primitives/image.rs index 9ec76a2..db9d481 100644 --- a/src/primitives/image.rs +++ b/src/primitives/image.rs @@ -13,7 +13,7 @@ use crate::{ const PULSE: Duration = Duration::from_millis(1600); -/// Themed `img()`; a `Cover` crop keeps square corners. +/// Themed `img()` that fills the box it is given; a `Cover` crop keeps square corners. #[derive(IntoElement)] pub struct Image { id: ElementId, @@ -82,6 +82,8 @@ impl RenderOnce for Image { }; let mut picture = img(source) .id(self.id) + .absolute() + .inset_0() .size_full() .object_fit(self.fit) .with_loading(move || { @@ -100,6 +102,6 @@ impl RenderOnce for Image { }) .with_fallback(move || mark(IconName::ImageOff).into_any_element()); picture.style().corner_radii = radii; - self.base.overflow_hidden().child(picture) + self.base.relative().overflow_hidden().child(picture) } } diff --git a/src/theme/palette.rs b/src/theme/palette.rs index ff2a2a5..d5d7294 100644 --- a/src/theme/palette.rs +++ b/src/theme/palette.rs @@ -96,6 +96,7 @@ mixable!( danger_subtle: Hsla, info_subtle: Hsla, backdrop: Hsla, + media_backdrop: Hsla, shadow: Hsla, tooltip_bg: Hsla, tooltip_fg: Hsla, @@ -144,6 +145,7 @@ impl Palette { danger_subtle: c(0xffefec), info_subtle: c(0xebf4ff), backdrop: rgba(0x18161352).into(), + media_backdrop: rgba(0x0e0d0bf8).into(), shadow: c(0x181613), tooltip_bg: c(0x181613), tooltip_fg: c(0xfcfaf7), @@ -209,6 +211,7 @@ impl Palette { danger_subtle: c(0x3a1d1b), info_subtle: c(0x19273a), backdrop: rgba(0x00000080).into(), + media_backdrop: rgba(0x000000f8).into(), shadow: c(0x000000), tooltip_bg: c(0x373533), tooltip_fg: c(0xf3f1f0), diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index 1de60a7..0bacf0e 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -233,6 +233,11 @@ impl Theme { (px_to_rems(84.0), px_to_rems(40.0)) } + /// Room between a box and what floats by it: a spotlight's ring, a selection's toolbar. + pub fn float_gap(&self) -> Rems { + px_to_rems(6.0) + } + /// Width of a dialog's card. pub fn dialog_width(&self) -> Rems { px_to_rems(440.0) diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md index 03468a3..b18a5de 100644 --- a/tasks/ch01-10.md +++ b/tasks/ch01-10.md @@ -305,7 +305,7 @@ - [x] QuickSwitcher → `navigation::QuickSwitcher` - [x] SearchPalette → `navigation::SearchPalette` - [x] SpotlightSearch → `navigation::SearchPalette`; T04 QuickLauncher opens it always on top -- [x] Tour / Onboarding → lands with Overlays (T09): its steps are Coachmarks +- [x] Tour / Onboarding → `overlays::Tour` (T09b): its steps are Spotlights ## T08a Menus · Menu & hosts — `src/menus` @@ -341,17 +341,17 @@ ## T09b Overlays · Guides & floats -- [ ] Lightbox -- [ ] Spotlight / Coachmark — also T07c Tour / Onboarding and T37 WelcomeTour -- [ ] FloatingToolbar -- [ ] Callout / Peek -- [ ] DropdownPanel → `overlays::Popover` -- [ ] InlinePopup → `overlays::Popover` -- [ ] ToastViewport +- [x] Lightbox → `overlays::Lightbox`, `overlays::Slide` +- [x] Spotlight / Coachmark → `overlays::Spotlight`; also T07c Tour / Onboarding and T37 WelcomeTour as `overlays::Tour` +- [x] FloatingToolbar → `overlays::FloatingToolbar` +- [x] Callout / Peek → `overlays::Peek` +- [x] DropdownPanel → `overlays::Popover` +- [x] InlinePopup → `overlays::Popover` with the Link variant +- [x] ToastViewport → lands with Feedback (T10): it holds Toasts ## T10 Feedback — `src/feedback` -- [ ] Toast — success, error, warning, info, action, undo +- [ ] Toast — success, error, warning, info, action, undo; also T09b ToastViewport - [ ] Notification - [ ] NotificationCenter - [ ] Alert diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index e0d056b..e865cdf 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -167,7 +167,7 @@ ## T37 Onboarding & Help — `src/onboarding` - [ ] OnboardingWizard -- [ ] WelcomeTour → `overlays::Tour` (T09) +- [ ] WelcomeTour → `overlays::Tour` (T09b) - [ ] Coachmark / Hotspot — Coachmark → `overlays::Spotlight`; Hotspot here - [ ] FeatureHighlight / NewBadge - [ ] Checklist (first-run tasks)