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:
18 files changed
+1754
-228
No files matched your search
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;")),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user