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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- `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 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`).
|
- 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`).
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- `img()` keeps loading state only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
- [x] T08a Menus · Menu & hosts
|
- [x] T08a Menus · Menu & hosts
|
||||||
- [x] T08b Menus · Bar, search & pie
|
- [x] T08b Menus · Bar, search & pie
|
||||||
- [x] T09a Overlays · Popovers & dialogs
|
- [x] T09a Overlays · Popovers & dialogs
|
||||||
- [ ] T09b Overlays · Guides & floats
|
- [x] T09b Overlays · Guides & floats
|
||||||
- [ ] T10 Feedback
|
- [ ] T10 Feedback
|
||||||
- [ ] T11a Loading — `tasks/ch11-20.md`
|
- [ ] T11a Loading — `tasks/ch11-20.md`
|
||||||
- [ ] T11b Motion
|
- [ ] 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 | 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 | 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 | |
|
| 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::{
|
use ely_gpui_component::{
|
||||||
buttons::{Button, ButtonVariant},
|
buttons::{Button, ButtonVariant},
|
||||||
forms::{Input, Switch, TextInput},
|
forms::{Input, TextInput},
|
||||||
overlays::{AlertDialog, ConfirmDialog, Dialog, HoverCard, Popover, PromptDialog},
|
overlays::{AlertDialog, ConfirmDialog, Dialog, PromptDialog},
|
||||||
primitives::{IconName, Severity},
|
primitives::Severity,
|
||||||
theme::{ActiveTheme, TextSize},
|
theme::ActiveTheme,
|
||||||
typography::Caption,
|
typography::Caption,
|
||||||
};
|
};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Entity, FontWeight, IntoElement, ParentElement, SharedString, Styled, Window,
|
AnyElement, App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div,
|
||||||
div, prelude::*,
|
prelude::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::Page;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
probe::probe,
|
probe::probe,
|
||||||
script::Step,
|
|
||||||
ui::{keep, row, section, set, specimen},
|
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.
|
/// Which dialog is open, if any.
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
enum Open {
|
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 open = keep("overlay-open", || None::<Open>, window, cx);
|
||||||
let said = keep("overlay-said", || None::<SharedString>, window, cx);
|
let said = keep("overlay-said", || None::<SharedString>, window, cx);
|
||||||
let (name, email, rename) = {
|
let (name, email, rename) = {
|
||||||
@@ -360,22 +174,13 @@ fn dialogs(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
|||||||
.children(overlay)
|
.children(overlay)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pointers(cx: &App) -> impl IntoElement + use<> {
|
pub fn pointers(cx: &App) -> impl IntoElement + use<> {
|
||||||
section(
|
section(
|
||||||
"Tooltip / Sheet / Drawer",
|
"Tooltip / Sheet / Drawer / DropdownPanel / InlinePopup / ToastViewport",
|
||||||
"They live with the pieces they are made of.",
|
"They live with the pieces they are made of.",
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
.child(Caption::new(
|
.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 dialog;
|
||||||
mod dialogs;
|
mod dialogs;
|
||||||
|
mod floats;
|
||||||
mod hover;
|
mod hover;
|
||||||
|
mod lightbox;
|
||||||
mod popover;
|
mod popover;
|
||||||
|
mod spotlight;
|
||||||
#[cfg(all(test, feature = "test-support"))]
|
#[cfg(all(test, feature = "test-support"))]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
pub use dialog::{Close, Dialog};
|
pub use dialog::{Close, Dialog};
|
||||||
pub use dialogs::{AlertDialog, ConfirmDialog, PromptDialog};
|
pub use dialogs::{AlertDialog, ConfirmDialog, PromptDialog};
|
||||||
|
pub use floats::{FloatingToolbar, Peek};
|
||||||
pub use hover::HoverCard;
|
pub use hover::HoverCard;
|
||||||
|
pub use lightbox::{Lightbox, Slide};
|
||||||
pub use popover::Popover;
|
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,
|
VisualTestContext, Window, div, point, px,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod guides;
|
||||||
|
|
||||||
use super::{AlertDialog, ConfirmDialog, Dialog, HoverCard, Popover, PromptDialog};
|
use super::{AlertDialog, ConfirmDialog, Dialog, HoverCard, Popover, PromptDialog};
|
||||||
use crate::{
|
use crate::{
|
||||||
buttons::Button,
|
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.run_until_parked();
|
||||||
cx.update(|window, _| window.refresh());
|
cx.update(|window, _| window.refresh());
|
||||||
cx.run_until_parked();
|
cx.run_until_parked();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn press(key: &str, cx: &mut VisualTestContext) {
|
pub(super) fn press(key: &str, cx: &mut VisualTestContext) {
|
||||||
cx.simulate_keystrokes(key);
|
cx.simulate_keystrokes(key);
|
||||||
settle(cx);
|
settle(cx);
|
||||||
cx.simulate_event(KeyUpEvent {
|
cx.simulate_event(KeyUpEvent {
|
||||||
@@ -145,7 +147,7 @@ fn press(key: &str, cx: &mut VisualTestContext) {
|
|||||||
settle(cx);
|
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));
|
let at = point(px(x), px(y));
|
||||||
cx.simulate_mouse_move(at, None, Modifiers::none());
|
cx.simulate_mouse_move(at, None, Modifiers::none());
|
||||||
cx.simulate_click(at, Modifiers::none());
|
cx.simulate_click(at, Modifiers::none());
|
||||||
+18
-5
@@ -1,6 +1,6 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Div, ElementId, Entity, FocusHandle, InteractiveElement, IntoElement,
|
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;
|
use crate::theme::ActiveTheme;
|
||||||
@@ -100,18 +100,27 @@ pub(crate) struct Takeover {
|
|||||||
pub focus: FocusHandle,
|
pub focus: FocusHandle,
|
||||||
previous: Option<FocusHandle>,
|
previous: Option<FocusHandle>,
|
||||||
taken: bool,
|
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(
|
pub(crate) fn take_focus(
|
||||||
key: impl Into<gpui::ElementId>,
|
key: impl Into<gpui::ElementId>,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> Entity<Takeover> {
|
) -> 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(),
|
focus: cx.focus_handle(),
|
||||||
previous: None,
|
previous: None,
|
||||||
taken: false,
|
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 {
|
if !state.read(cx).taken {
|
||||||
let previous = window.focused(cx);
|
let previous = window.focused(cx);
|
||||||
@@ -125,8 +134,12 @@ pub(crate) fn take_focus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns focus to whatever held it before the overlay opened.
|
/// Returns focus to whatever held it before the overlay opened.
|
||||||
pub(crate) fn give_back(state: &Entity<Takeover>, window: &mut Window, cx: &App) {
|
pub(crate) fn give_back(state: &Entity<Takeover>, window: &mut Window, cx: &mut App) {
|
||||||
match state.read(cx).previous.clone() {
|
let previous = state.update(cx, |takeover, _| {
|
||||||
|
takeover.returned = true;
|
||||||
|
takeover.previous.clone()
|
||||||
|
});
|
||||||
|
match previous {
|
||||||
Some(previous) => {
|
Some(previous) => {
|
||||||
window.focus(&previous);
|
window.focus(&previous);
|
||||||
log::info!("focus: handed back after an overlay");
|
log::info!("focus: handed back after an overlay");
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use crate::{
|
|||||||
|
|
||||||
const PULSE: Duration = Duration::from_millis(1600);
|
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)]
|
#[derive(IntoElement)]
|
||||||
pub struct Image {
|
pub struct Image {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
@@ -82,6 +82,8 @@ impl RenderOnce for Image {
|
|||||||
};
|
};
|
||||||
let mut picture = img(source)
|
let mut picture = img(source)
|
||||||
.id(self.id)
|
.id(self.id)
|
||||||
|
.absolute()
|
||||||
|
.inset_0()
|
||||||
.size_full()
|
.size_full()
|
||||||
.object_fit(self.fit)
|
.object_fit(self.fit)
|
||||||
.with_loading(move || {
|
.with_loading(move || {
|
||||||
@@ -100,6 +102,6 @@ impl RenderOnce for Image {
|
|||||||
})
|
})
|
||||||
.with_fallback(move || mark(IconName::ImageOff).into_any_element());
|
.with_fallback(move || mark(IconName::ImageOff).into_any_element());
|
||||||
picture.style().corner_radii = radii;
|
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,
|
danger_subtle: Hsla,
|
||||||
info_subtle: Hsla,
|
info_subtle: Hsla,
|
||||||
backdrop: Hsla,
|
backdrop: Hsla,
|
||||||
|
media_backdrop: Hsla,
|
||||||
shadow: Hsla,
|
shadow: Hsla,
|
||||||
tooltip_bg: Hsla,
|
tooltip_bg: Hsla,
|
||||||
tooltip_fg: Hsla,
|
tooltip_fg: Hsla,
|
||||||
@@ -144,6 +145,7 @@ impl Palette {
|
|||||||
danger_subtle: c(0xffefec),
|
danger_subtle: c(0xffefec),
|
||||||
info_subtle: c(0xebf4ff),
|
info_subtle: c(0xebf4ff),
|
||||||
backdrop: rgba(0x18161352).into(),
|
backdrop: rgba(0x18161352).into(),
|
||||||
|
media_backdrop: rgba(0x0e0d0bf8).into(),
|
||||||
shadow: c(0x181613),
|
shadow: c(0x181613),
|
||||||
tooltip_bg: c(0x181613),
|
tooltip_bg: c(0x181613),
|
||||||
tooltip_fg: c(0xfcfaf7),
|
tooltip_fg: c(0xfcfaf7),
|
||||||
@@ -209,6 +211,7 @@ impl Palette {
|
|||||||
danger_subtle: c(0x3a1d1b),
|
danger_subtle: c(0x3a1d1b),
|
||||||
info_subtle: c(0x19273a),
|
info_subtle: c(0x19273a),
|
||||||
backdrop: rgba(0x00000080).into(),
|
backdrop: rgba(0x00000080).into(),
|
||||||
|
media_backdrop: rgba(0x000000f8).into(),
|
||||||
shadow: c(0x000000),
|
shadow: c(0x000000),
|
||||||
tooltip_bg: c(0x373533),
|
tooltip_bg: c(0x373533),
|
||||||
tooltip_fg: c(0xf3f1f0),
|
tooltip_fg: c(0xf3f1f0),
|
||||||
|
|||||||
@@ -233,6 +233,11 @@ impl Theme {
|
|||||||
(px_to_rems(84.0), px_to_rems(40.0))
|
(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.
|
/// Width of a dialog's card.
|
||||||
pub fn dialog_width(&self) -> Rems {
|
pub fn dialog_width(&self) -> Rems {
|
||||||
px_to_rems(440.0)
|
px_to_rems(440.0)
|
||||||
|
|||||||
+9
-9
@@ -305,7 +305,7 @@
|
|||||||
- [x] QuickSwitcher → `navigation::QuickSwitcher`
|
- [x] QuickSwitcher → `navigation::QuickSwitcher`
|
||||||
- [x] SearchPalette → `navigation::SearchPalette`
|
- [x] SearchPalette → `navigation::SearchPalette`
|
||||||
- [x] SpotlightSearch → `navigation::SearchPalette`; T04 QuickLauncher opens it always on top
|
- [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`
|
## T08a Menus · Menu & hosts — `src/menus`
|
||||||
|
|
||||||
@@ -341,17 +341,17 @@
|
|||||||
|
|
||||||
## T09b Overlays · Guides & floats
|
## T09b Overlays · Guides & floats
|
||||||
|
|
||||||
- [ ] Lightbox
|
- [x] Lightbox → `overlays::Lightbox`, `overlays::Slide`
|
||||||
- [ ] Spotlight / Coachmark — also T07c Tour / Onboarding and T37 WelcomeTour
|
- [x] Spotlight / Coachmark → `overlays::Spotlight`; also T07c Tour / Onboarding and T37 WelcomeTour as `overlays::Tour`
|
||||||
- [ ] FloatingToolbar
|
- [x] FloatingToolbar → `overlays::FloatingToolbar`
|
||||||
- [ ] Callout / Peek
|
- [x] Callout / Peek → `overlays::Peek`
|
||||||
- [ ] DropdownPanel → `overlays::Popover`
|
- [x] DropdownPanel → `overlays::Popover`
|
||||||
- [ ] InlinePopup → `overlays::Popover`
|
- [x] InlinePopup → `overlays::Popover` with the Link variant
|
||||||
- [ ] ToastViewport
|
- [x] ToastViewport → lands with Feedback (T10): it holds Toasts
|
||||||
|
|
||||||
## T10 Feedback — `src/feedback`
|
## T10 Feedback — `src/feedback`
|
||||||
|
|
||||||
- [ ] Toast — success, error, warning, info, action, undo
|
- [ ] Toast — success, error, warning, info, action, undo; also T09b ToastViewport
|
||||||
- [ ] Notification
|
- [ ] Notification
|
||||||
- [ ] NotificationCenter
|
- [ ] NotificationCenter
|
||||||
- [ ] Alert
|
- [ ] Alert
|
||||||
|
|||||||
+1
-1
@@ -167,7 +167,7 @@
|
|||||||
## T37 Onboarding & Help — `src/onboarding`
|
## T37 Onboarding & Help — `src/onboarding`
|
||||||
|
|
||||||
- [ ] OnboardingWizard
|
- [ ] OnboardingWizard
|
||||||
- [ ] WelcomeTour → `overlays::Tour` (T09)
|
- [ ] WelcomeTour → `overlays::Tour` (T09b)
|
||||||
- [ ] Coachmark / Hotspot — Coachmark → `overlays::Spotlight`; Hotspot here
|
- [ ] Coachmark / Hotspot — Coachmark → `overlays::Spotlight`; Hotspot here
|
||||||
- [ ] FeatureHighlight / NewBadge
|
- [ ] FeatureHighlight / NewBadge
|
||||||
- [ ] Checklist (first-run tasks)
|
- [ ] Checklist (first-run tasks)
|
||||||
|
|||||||
Reference in new issue
Block a user