T09b overlays: lightbox, spotlight and tour, floating toolbar, peek; images fill their box; overlays take focus back when its element leaves the tree

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 15:19:57 -04:00
1 parent ca73d24c01
commit ef74bac823
18 files changed
+1754 -228

No files matched your search

+4 -2
View File
@@ -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
+4 -1
View File
@@ -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) |
+152
View File
@@ -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,
))
}
@@ -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::<Open>, window, cx);
let said = keep("overlay-said", || None::<SharedString>, 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()
}
+222
View File
@@ -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::<usize>, 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<Pixels>; 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::<usize>, 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<TextInput> = 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::<SharedString>, 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;")),
)
}
+88
View File
@@ -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()
}
+169
View File
@@ -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<TextInput>,
tools: SmallVec<[AnyElement; 4]>,
}
impl FloatingToolbar {
pub fn new(id: impl Into<ElementId>, field: &Entity<TextInput>) -> Self {
Self {
id: id.into(),
field: field.clone(),
tools: SmallVec::new(),
}
}
}
impl ParentElement for FloatingToolbar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
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<ElementId>,
title: impl Into<SharedString>,
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<Item = AnyElement>) {
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)),
)
}
}
+280
View File
@@ -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<ImageSource>, caption: impl Into<SharedString>) -> Self {
Self {
source: source.into(),
caption: caption.into(),
}
}
}
type OnStep = Rc<dyn Fn(usize, &mut Window, &mut App)>;
/// 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<Div> {
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<Slide>,
at: usize,
on_step: Option<OnStep>,
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<ElementId>,
slides: impl IntoIterator<Item = Slide>,
at: usize,
on_close: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
let slides: Vec<Slide> = 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::<Pixels>::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<usize>, 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)
}
}
+6
View File
@@ -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};
+439
View File
@@ -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<Pixels>,
title: SharedString,
body: SharedString,
}
impl TourStep {
/// `target` is window coordinates, as `primitives::Measure` reports them.
pub fn new(
target: Bounds<Pixels>,
title: impl Into<SharedString>,
body: impl Into<SharedString>,
) -> 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<RefCell<Run>>,
_escape: Subscription,
}
/// Where the lit box glides from, and the card's measured height.
#[derive(Default)]
struct Lit {
from: Bounds<Pixels>,
to: Bounds<Pixels>,
card: Pixels,
}
fn lerp(from: Bounds<Pixels>, to: Bounds<Pixels>, t: f32) -> Bounds<Pixels> {
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<Pixels>,
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<ElementId>,
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<SharedString>) -> 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<dyn Fn(usize, &mut Window, &mut App)>;
/// 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<TourStep>,
at: usize,
on_step: Option<OnStep>,
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<ElementId>,
steps: impl IntoIterator<Item = TourStep>,
at: usize,
on_close: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
let steps: Vec<TourStep> = 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,
)
}
}
+335
View File
@@ -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<TextInput>,
open: Option<Open>,
at: usize,
log: Vec<String>,
lower: bool,
}
fn target() -> Bounds<gpui::Pixels> {
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<Self>) -> impl IntoElement {
let note = |what: &'static str, cx: &mut Context<Self>| {
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<Guides>, &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<Guides>, 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<Guides>, cx: &mut VisualTestContext) -> (Option<Open>, usize, Vec<String>) {
view.read_with(cx, |guides, _| (guides.open, guides.at, guides.log.clone()))
}
fn before_focused(view: &Entity<Guides>, 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);
}
@@ -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());
+18 -5
View File
@@ -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<FocusHandle>,
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<gpui::ElementId>,
window: &mut Window,
cx: &mut App,
) -> Entity<Takeover> {
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<Takeover>, window: &mut Window, cx: &App) {
match state.read(cx).previous.clone() {
pub(crate) fn give_back(state: &Entity<Takeover>, 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");
+4 -2
View File
@@ -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)
}
}
+3
View File
@@ -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),
+5
View File
@@ -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)
+9 -9
View File
@@ -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
+1 -1
View File
@@ -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)