From ca73d24c01bdd08674957ab6ce9961fc1816c351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 14:07:30 -0400 Subject: [PATCH] T09a overlays: popover, hover card, dialog with fullscreen, alert, confirm and prompt dialogs; shared severity; tooltip, sheet and drawer point to their homes --- AGENTS.md | 2 + TASKS.md | 6 +- examples/gallery/pages/mod.rs | 2 + examples/gallery/pages/overlays.rs | 381 +++++++++++++++++++++++++++++ src/forms/mod.rs | 2 +- src/forms/options.rs | 33 ++- src/lib.rs | 1 + src/overlays/dialog.rs | 271 ++++++++++++++++++++ src/overlays/dialogs.rs | 326 ++++++++++++++++++++++++ src/overlays/hover.rs | 135 ++++++++++ src/overlays/mod.rs | 11 + src/overlays/popover.rs | 170 +++++++++++++ src/overlays/tests.rs | 351 ++++++++++++++++++++++++++ src/primitives/mod.rs | 2 + src/primitives/severity.rs | 43 ++++ src/theme/tokens.rs | 5 + tasks/ch01-10.md | 25 +- 17 files changed, 1750 insertions(+), 16 deletions(-) create mode 100644 examples/gallery/pages/overlays.rs create mode 100644 src/overlays/dialog.rs create mode 100644 src/overlays/dialogs.rs create mode 100644 src/overlays/hover.rs create mode 100644 src/overlays/mod.rs create mode 100644 src/overlays/popover.rs create mode 100644 src/overlays/tests.rs create mode 100644 src/primitives/severity.rs diff --git a/AGENTS.md b/AGENTS.md index 7ba0701..fb78a57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - gpui focuses the nearest focusable ancestor on mouse down, and apps wrap everything in a `FocusScope`. A press that must leave focus alone calls `prevent_default`, as buttons and context regions do. - gpui draws a window's first frame inside `open_window`. State the first frame reads, such as `cx.set_menus`, is set before it opens. - gpui clicks a focused element when Enter or Space is released. An overlay that hands focus back picks on release too; a pick on press returns focus first, and the release clicks the opener again. +- A dialog goes through `overlays::Dialog`: scrim, focus trap, Escape and focus return live there. AlertDialog, ConfirmDialog and PromptDialog build on it; a fullscreen dialog fills the window below its title bar. +- `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. diff --git a/TASKS.md b/TASKS.md index d824b79..233839d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -34,7 +34,8 @@ Per-component lines live in `tasks/`. Tags there: - [x] T07c Navigation · Palettes & tour - [x] T08a Menus · Menu & hosts - [x] T08b Menus · Bar, search & pie -- [ ] T09 Overlays +- [x] T09a Overlays · Popovers & dialogs +- [ ] T09b Overlays · Guides & floats - [ ] T10 Feedback - [ ] T11a Loading — `tasks/ch11-20.md` - [ ] T11b Motion @@ -152,3 +153,6 @@ Per-component lines live in `tasks/`. Tags there: | T08b | 1 | FAIL | a shrinking pie kept a mark past its end, a hub press lost focus, pie confirm keys bubbled and modified releases picked, an open dropdown stayed put when its host moved, clearing the filter kept the old mark, and the ring sat half a slice off its hub | | T08b | 2 | FAIL | the pie's opening right click let a root FocusScope take focus before take_focus recorded it | | T08b | 3 | PASS | | +| 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 | | diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index 9857f86..e7b92f0 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -3,6 +3,7 @@ mod forms; mod layout; mod menus; mod navigation; +mod overlays; mod primitives; mod shell; mod theme; @@ -31,6 +32,7 @@ pub const ALL: &[Page] = &[ forms::PAGE, navigation::PAGE, menus::PAGE, + overlays::PAGE, theme::PAGE, ]; diff --git a/examples/gallery/pages/overlays.rs b/examples/gallery/pages/overlays.rs new file mode 100644 index 0000000..8a7f48f --- /dev/null +++ b/examples/gallery/pages/overlays.rs @@ -0,0 +1,381 @@ +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + forms::{Input, Switch, TextInput}, + overlays::{AlertDialog, ConfirmDialog, Dialog, HoverCard, Popover, PromptDialog}, + primitives::{IconName, Severity}, + theme::{ActiveTheme, TextSize}, + typography::Caption, +}; +use gpui::{ + AnyElement, App, Entity, FontWeight, 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 { + Profile, + Alert, + Confirm, + Prompt, + Fullscreen, +} + +fn opener( + state: &Entity>, + id: &'static str, + label: &'static str, + open: Open, +) -> impl IntoElement { + let state = state.clone(); + probe( + id, + Button::new(id, label).on_click(move |_, _, cx| set(&state, Some(open), cx)), + ) +} + +fn dialogs(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let open = keep("overlay-open", || None::, window, cx); + let said = keep("overlay-said", || None::, window, cx); + let (name, email, rename) = { + let make = |window: &mut Window, cx: &mut App, text: &str| { + let text = text.to_string(); + cx.new(|cx| { + let mut input = TextInput::new(window, cx); + input.set_text(text, cx); + input + }) + }; + let fields = window.use_keyed_state("overlay-fields", cx, |window, cx| { + ( + make(window, cx, "Ada Lovelace"), + make(window, cx, "ada@ely.dev"), + make(window, cx, "Roadmap"), + ) + }); + fields.read(cx).clone() + }; + let shut = { + let open = open.clone(); + move |_: &mut Window, cx: &mut App| set(&open, None, cx) + }; + let caption = said + .read(cx) + .clone() + .map_or("each opens over a scrim; Escape closes it".into(), |said| { + said.to_string() + }); + let overlay: Option = (*open.read(cx)).map(|which| { + let (saved, confirmed, renamed) = (said.clone(), said.clone(), said.clone()); + match which { + Open::Profile => Dialog::new("profile", "Edit profile", shut.clone()) + .detail("How others see you across the workspace.") + .child( + div() + .flex() + .flex_col() + .gap_3() + .child(Input::new(&name)) + .child(Input::new(&email)), + ) + .action(|close| { + Button::new("profile-cancel", "Cancel") + .variant(ButtonVariant::Ghost) + .on_click(move |_, window, cx| close(window, cx)) + }) + .action(move |close| { + Button::new("profile-save", "Save") + .primary() + .on_click(move |_, window, cx| { + set(&saved, Some("profile saved".into()), cx); + close(window, cx); + }) + }) + .into_any_element(), + Open::Alert => AlertDialog::new( + "expired", + Severity::Warning, + "Session expired", + "Sign in again to keep your changes. Nothing you wrote is lost.", + shut.clone(), + ) + .label("Sign in") + .into_any_element(), + Open::Confirm => ConfirmDialog::new( + "delete", + "Delete this project?", + "Its pages, files and history go with it. This cannot be undone.", + shut.clone(), + ) + .confirm("Delete project") + .destructive() + .on_confirm(move |_, cx| set(&confirmed, Some("project deleted".into()), cx)) + .into_any_element(), + Open::Prompt => PromptDialog::new("rename", "Rename", &rename, shut.clone()) + .label("New name") + .submit("Rename") + .check(|text| match text.trim() { + "" => Err("A name cannot be empty.".into()), + text if text.contains('/') => Err("Names cannot hold a slash.".into()), + _ => Ok(()), + }) + .on_submit(move |text, _, cx| { + set(&renamed, Some(format!("renamed to {text}").into()), cx) + }) + .into_any_element(), + Open::Fullscreen => Dialog::new("notes", "Release notes", shut.clone()) + .detail("Version 0.9 · September 2026") + .fullscreen() + .child( + div() + .flex() + .flex_col() + .gap_3() + .max_w( + cx.theme() + .container_width(ely_gpui_component::theme::ContainerSize::Sm), + ) + .child(Caption::new("Menus nest, filter and follow their buttons.")) + .child(Caption::new("Palettes pick on release, as buttons do.")) + .child(Caption::new("Overlays hand focus back on every close.")), + ) + .into_any_element(), + } + }); + section( + "Dialog / Modal / AlertDialog / ConfirmDialog / PromptDialog / FullscreenDialog", + "A card over a scrim; Tab stays inside and focus returns on close. An alert holds until acknowledged, a confirm asks first, a prompt checks its one line, and a fullscreen dialog fills the window.", + cx, + ) + .child(specimen( + caption, + row() + .child(opener(&open, "edit-profile", "Edit profile", Open::Profile)) + .child(opener(&open, "alert", "Session expired", Open::Alert)) + .child(opener(&open, "confirm", "Delete project", Open::Confirm)) + .child({ + let (open, rename) = (open.clone(), rename.clone()); + probe( + "prompt", + Button::new("prompt", "Rename").on_click(move |_, _, cx| { + rename.update(cx, |input, cx| input.set_text("Roadmap", cx)); + set(&open, Some(Open::Prompt), cx); + }), + ) + }) + .child(opener(&open, "fullscreen", "Release notes", Open::Fullscreen)), + cx, + )) + .children(overlay) +} + +fn pointers(cx: &App) -> impl IntoElement + use<> { + section( + "Tooltip / Sheet / Drawer", + "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.", + )) +} + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(popovers(window, cx)) + .child(hover_card(cx)) + .child(dialogs(window, cx)) + .child(pointers(cx)) + .into_any_element() +} diff --git a/src/forms/mod.rs b/src/forms/mod.rs index c5bb18d..6333785 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -70,7 +70,7 @@ pub use mention::{MentionInput, mention_highlights}; pub use multi::MultiSelect; pub use number::{NumberInput, ScrubInput}; pub use options::Choice; -pub(crate) use options::{OnFlag, OnValue, Pick, Run, float, step, surface}; +pub(crate) use options::{OnFlag, OnValue, Pick, Run, float, float_height, step, surface}; pub use pairs::{FieldArray, KeyValueInput, ListInput}; pub use path::PathInput; pub use pattern::{RegexInput, regex_highlights}; diff --git a/src/forms/options.rs b/src/forms/options.rs index 9a495c4..50799bb 100644 --- a/src/forms/options.rs +++ b/src/forms/options.rs @@ -151,8 +151,24 @@ pub(crate) fn float( let rem = window.rem_size(); let row = theme.control_height(ControlSize::Md).to_pixels(rem); let height = (row * (rows + 1) as f32).min(theme.list_max_height().to_pixels(rem)); - let below = window.viewport_size().height - anchor.bottom(); - let up = height > below && anchor.top() > below; + float_height(anchor, height, content, window, cx) +} + +/// Whether `height` goes over `anchor`: it does not fit below, and above has more room. +pub(crate) fn opens_up(anchor: Bounds, height: Pixels, viewport: Pixels) -> bool { + let below = viewport - anchor.bottom(); + height > below && anchor.top() > below +} + +/// Floats `content` of a known `height` under `anchor`, or over it when only above has room. +pub(crate) fn float_height( + anchor: Bounds, + height: Pixels, + content: impl IntoElement, + window: &Window, + cx: &App, +) -> AnyElement { + let up = opens_up(anchor, height, window.viewport_size().height); let enter = Animation::new(motion::duration(motion::FAST, cx)).with_easing(motion::ease_out_cubic); let placed = if up { @@ -237,7 +253,18 @@ impl Popup<'_> { #[cfg(test)] mod tests { - use super::{Choice, step}; + use gpui::{Bounds, point, px, size}; + + use super::{Choice, opens_up, step}; + + #[test] + fn a_panel_opens_up_only_when_it_does_not_fit_below_and_above_has_more() { + let low = Bounds::new(point(px(0.0), px(600.0)), size(px(80.0), px(28.0))); + assert!(opens_up(low, px(260.0), px(790.0))); + assert!(!opens_up(low, px(120.0), px(790.0))); + let high = Bounds::new(point(px(0.0), px(100.0)), size(px(80.0), px(28.0))); + assert!(!opens_up(high, px(700.0), px(790.0))); + } #[test] fn stepping_wraps_and_skips_disabled_rows() { diff --git a/src/lib.rs b/src/lib.rs index 29d7cea..0199962 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod layout; pub mod menus; pub mod motion; pub mod navigation; +pub mod overlays; pub mod primitives; pub mod shell; pub mod theme; diff --git a/src/overlays/dialog.rs b/src/overlays/dialog.rs new file mode 100644 index 0000000..5273fb3 --- /dev/null +++ b/src/overlays/dialog.rs @@ -0,0 +1,271 @@ +use std::rc::Rc; + +use gpui::{ + Animation, AnimationExt, AnyElement, App, ElementId, FocusHandle, FontWeight, + InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, Styled, + Window, anchored, deferred, div, point, prelude::*, +}; +use smallvec::SmallVec; + +use crate::{ + buttons::{ButtonVariant, IconButton}, + forms::{Enter, Run}, + motion, + primitives::{Backdrop, FocusScope, IconName, give_back, take_focus}, + theme::{ActiveTheme, Elevation, Radius, TextSize}, +}; + +/// A dialog's way out: it hands focus back, then runs the owner's close. +pub type Close = Rc; + +type Action = Box AnyElement>; +type OnEnter = Box Run>; + +/// A card over a scrim: a title, a line of detail, the owner's content and a row of actions. Escape closes it, Tab stays inside, and focus returns on every close. +#[derive(IntoElement)] +pub struct Dialog { + id: ElementId, + title: SharedString, + detail: Option, + body: SmallVec<[AnyElement; 2]>, + actions: SmallVec<[Action; 2]>, + enter: Option, + scrim_closes: bool, + fullscreen: bool, + first: Option, + on_close: Run, +} + +impl Dialog { + /// Render it while open; `on_close` runs on Escape, a press on the scrim, or the close button. + pub fn new( + id: impl Into, + title: impl Into, + on_close: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + title: title.into(), + detail: None, + body: SmallVec::new(), + actions: SmallVec::new(), + enter: None, + scrim_closes: true, + fullscreen: false, + first: None, + on_close: Rc::new(on_close), + } + } + + pub fn detail(mut self, text: impl Into) -> Self { + self.detail = Some(text.into()); + self + } + + /// A button in the row at the bottom, right-aligned in the order given. `build` gets the dialog's close; a button that closes calls it. + pub fn action(mut self, build: impl FnOnce(Close) -> E + 'static) -> Self { + self.actions + .push(Box::new(move |close| build(close).into_any_element())); + self + } + + /// Runs on an unmodified Enter release while the `focus_first` field holds focus; `build` gets the dialog's close. + pub(crate) fn on_enter(mut self, build: impl FnOnce(Close) -> Run + 'static) -> Self { + self.enter = Some(Box::new(build)); + self + } + + /// Fills the window below its title bar, under a bar with the title and a close button. + pub fn fullscreen(mut self) -> Self { + self.fullscreen = true; + self + } + + /// Only its actions and Escape close it; a press on the scrim does not. + pub(crate) fn held(mut self) -> Self { + self.scrim_closes = false; + self + } + + /// Where focus goes when it opens. + pub(crate) fn focus_first(mut self, handle: FocusHandle) -> Self { + self.first = Some(handle); + self + } +} + +impl ParentElement for Dialog { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +impl RenderOnce for Dialog { + 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(); + if let Some(first) = &self.first + && focus.is_focused(window) + { + window.focus(first); + } + let close: Run = { + let (id, on_close) = (self.id.clone(), self.on_close); + Rc::new(move |window, cx| { + log::info!("dialog {id:?}: closed"); + give_back(&takeover, window, cx); + on_close(window, cx) + }) + }; + let theme = cx.theme(); + let colors = &theme.colors; + let heading = div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_size(theme.text_size(TextSize::Lg)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(self.title), + ) + .when_some(self.detail, |heading, detail| { + heading.child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child(detail), + ) + }); + let buttons: Vec = self + .actions + .into_iter() + .map(|build| build(close.clone())) + .collect(); + let actions = + (!buttons.is_empty()).then(|| div().flex().justify_end().gap_2().children(buttons)); + let enter = self.enter.map(|build| { + let field = self + .first + .clone() + .expect("a dialog that takes Enter names its field with focus_first"); + (field, build(close.clone())) + }); + let (escape, dismiss, button) = (close.clone(), close.clone(), close); + let fullscreen = self.fullscreen; + let card = if fullscreen { + div() + .id(self.id.clone()) + .flex() + .flex_col() + .size_full() + .bg(colors.bg) + .child( + div() + .flex() + .items_center() + .justify_between() + .gap_4() + .px_6() + .py_4() + .border_b_1() + .border_color(colors.border) + .child(heading) + .child( + IconButton::new((self.id.clone(), "close"), IconName::X) + .variant(ButtonVariant::Ghost) + .tooltip("Close") + .on_click(move |_, window, cx| button(window, cx)), + ), + ) + .child(div().flex_1().min_h_0().p_6().children(self.body)) + .children(actions.map(|row| { + div() + .px_6() + .py_4() + .border_t_1() + .border_color(colors.border) + .child(row) + })) + } else { + div() + .id(self.id.clone()) + .flex() + .flex_col() + .gap_5() + .w(theme.dialog_width()) + .p_6() + .rounded(theme.radius(Radius::Xl)) + .bg(colors.overlay) + .border_1() + .border_color(colors.border) + .shadow(theme.elevation(Elevation::Modal)) + .child(heading) + .children(self.body) + .children(actions) + }; + let rise = if fullscreen { + motion::NUDGE * 4.0 + } else { + motion::NUDGE + }; + let card = card.with_animation( + (self.id.clone(), "in"), + Animation::new(motion::duration(motion::BASE, cx)).with_easing(motion::ease_out_cubic), + move |card, t| card.opacity(t).mt(rise * (1.0 - t)), + ); + let layer = div() + .when(fullscreen, |layer| layer.size_full()) + .on_key_down(move |event, window, cx| { + if event.keystroke.key == "escape" { + cx.stop_propagation(); + escape(window, cx); + } + }) + .when_some(enter, |layer, (field, enter)| { + layer + .capture_action(|_: &Enter, _, cx| cx.stop_propagation()) + .on_key_up(move |event, window, cx| { + let stroke = &event.keystroke; + if stroke.key == "enter" + && !stroke.modifiers.modified() + && field.is_focused(window) + { + cx.stop_propagation(); + enter(window, cx); + } + }) + }) + .child( + FocusScope::new(&focus) + .trap() + .when(fullscreen, |scope| scope.size_full()) + .child(card), + ); + if fullscreen { + let viewport = window.viewport_size(); + let top = cx.theme().titlebar_height().to_pixels(window.rem_size()); + return deferred( + anchored().position(point(Pixels::ZERO, top)).child( + div() + .w(viewport.width) + .h(viewport.height - top) + .occlude() + .child(layer), + ), + ) + .with_priority(1) + .into_any_element(); + } + let scrim_closes = self.scrim_closes; + Backdrop::new((self.id, "scrim")) + .on_dismiss(move |window, cx| { + if scrim_closes { + dismiss(window, cx); + } + }) + .child(layer) + .into_any_element() + } +} diff --git a/src/overlays/dialogs.rs b/src/overlays/dialogs.rs new file mode 100644 index 0000000..02a1d6c --- /dev/null +++ b/src/overlays/dialogs.rs @@ -0,0 +1,326 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, + div, prelude::*, +}; + +use super::{Close, Dialog}; +use crate::{ + buttons::{Button, ButtonVariant}, + forms::{Input, Run, TextInput}, + primitives::{Icon, Severity}, + theme::{ActiveTheme, ControlSize, IconSize, TextSize}, +}; + +/// The row a message sits in: its severity's icon on a quiet disc, then the text. +fn said(severity: Option, message: SharedString, cx: &App) -> impl IntoElement { + let theme = cx.theme(); + let colors = &theme.colors; + div() + .flex() + .items_start() + .gap_3() + .when_some(severity, |row, severity| { + row.child( + div() + .flex() + .flex_none() + .items_center() + .justify_center() + .size(theme.control_height(ControlSize::Lg)) + .rounded_full() + .bg(severity.subtle(colors)) + .child( + Icon::new(severity.icon()) + .size(IconSize::Md) + .color(severity.color(colors)), + ), + ) + }) + .child( + div() + .pt_1p5() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child(message), + ) +} + +/// A message to acknowledge: its severity's icon, the text and one button. The scrim does not close it. +#[derive(IntoElement)] +pub struct AlertDialog { + id: ElementId, + severity: Severity, + title: SharedString, + message: SharedString, + label: SharedString, + on_close: Run, +} + +impl AlertDialog { + pub fn new( + id: impl Into, + severity: Severity, + title: impl Into, + message: impl Into, + on_close: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + severity, + title: title.into(), + message: message.into(), + label: "OK".into(), + on_close: Rc::new(on_close), + } + } + + /// The button's label, OK unless set. + pub fn label(mut self, label: impl Into) -> Self { + self.label = label.into(); + self + } +} + +impl RenderOnce for AlertDialog { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (ok, label) = ((self.id.clone(), "ok"), self.label); + let on_close = self.on_close; + Dialog::new(self.id, self.title, move |window, cx| on_close(window, cx)) + .held() + .child(said(Some(self.severity), self.message, cx)) + .action(move |close| { + Button::new(ok, label) + .primary() + .on_click(move |_, window, cx| close(window, cx)) + }) + } +} + +/// Asks before an action: Cancel, and a button that confirms, red when the action destroys. +#[derive(IntoElement)] +pub struct ConfirmDialog { + id: ElementId, + title: SharedString, + message: SharedString, + confirm: SharedString, + destructive: bool, + on_confirm: Option, + on_close: Run, +} + +impl ConfirmDialog { + /// `on_close` runs on every way out, the confirm button included. + pub fn new( + id: impl Into, + title: impl Into, + message: impl Into, + on_close: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + title: title.into(), + message: message.into(), + confirm: "Confirm".into(), + destructive: false, + on_confirm: None, + on_close: Rc::new(on_close), + } + } + + /// The confirming button's label. + pub fn confirm(mut self, label: impl Into) -> Self { + self.confirm = label.into(); + self + } + + pub fn destructive(mut self) -> Self { + self.destructive = true; + self + } + + pub fn on_confirm(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_confirm = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ConfirmDialog { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (id, on_confirm, label) = (self.id.clone(), self.on_confirm, self.confirm); + let variant = if self.destructive { + ButtonVariant::Danger + } else { + ButtonVariant::Primary + }; + let (cancel_id, confirm_id) = ((self.id.clone(), "cancel"), (self.id.clone(), "confirm")); + let on_close = self.on_close; + Dialog::new(self.id, self.title, move |window, cx| on_close(window, cx)) + .child(said(None, self.message, cx)) + .action(move |close| { + Button::new(cancel_id, "Cancel") + .variant(ButtonVariant::Ghost) + .on_click(move |_, window, cx| close(window, cx)) + }) + .action(move |close| { + Button::new(confirm_id, label) + .variant(variant) + .on_click(move |_, window, cx| { + log::info!("confirm dialog {id:?}: confirmed"); + close(window, cx); + if let Some(on_confirm) = &on_confirm { + on_confirm(window, cx); + } + }) + }) + } +} + +type Check = Rc Result<(), SharedString>>; +type Submit = Rc; +type Send = Rc; + +/// Asks for one line, its text selected so typing replaces it. Enter or the submit button sends it once the check passes; the check's reason shows under the field. +#[derive(IntoElement)] +pub struct PromptDialog { + id: ElementId, + title: SharedString, + field: Entity, + label: Option, + submit: SharedString, + check: Option, + on_submit: Option, + on_close: Run, +} + +impl PromptDialog { + pub fn new( + id: impl Into, + title: impl Into, + field: &Entity, + on_close: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + title: title.into(), + field: field.clone(), + label: None, + submit: "OK".into(), + check: None, + on_submit: None, + on_close: Rc::new(on_close), + } + } + + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + /// The submit button's label. + pub fn submit(mut self, label: impl Into) -> Self { + self.submit = label.into(); + self + } + + /// Says why the text cannot go yet. + pub fn check(mut self, check: impl Fn(&str) -> Result<(), SharedString> + 'static) -> Self { + self.check = Some(Rc::new(check)); + self + } + + pub fn on_submit(mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self { + self.on_submit = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for PromptDialog { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let primed = window.use_keyed_state((self.id.clone(), "primed"), cx, |_, _| false); + if !*primed.read(cx) { + primed.update(cx, |primed, _| *primed = true); + self.field.update(cx, |input, cx| { + let end = input.text().len(); + input.select(0..end, cx); + }); + } + let text = self.field.read(cx).text().to_string(); + let verdict = match &self.check { + Some(check) => check(&text), + None => Ok(()), + }; + let ready = verdict.is_ok(); + let send: Send = { + let (id, field, check, on_submit) = ( + self.id.clone(), + self.field.clone(), + self.check.clone(), + self.on_submit.clone(), + ); + Rc::new(move |close, window, cx| { + let text = field.read(cx).text().to_string(); + if let Some(check) = &check + && let Err(reason) = check(&text) + { + log::info!("prompt dialog {id:?}: held back, {reason}"); + return; + } + log::info!("prompt dialog {id:?}: sent"); + close(window, cx); + if let Some(on_submit) = &on_submit { + on_submit(&text, window, cx); + } + }) + }; + let theme = cx.theme(); + let colors = &theme.colors; + let (button, lifted) = (send.clone(), send); + let field = div() + .flex() + .flex_col() + .gap_1p5() + .when_some(self.label, |field, label| { + field.child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child(label), + ) + }) + .child(Input::new(&self.field).invalid(!ready && !text.is_empty())) + .when_some( + verdict.err().filter(|_| !text.is_empty()), + |field, reason| { + field.child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.danger) + .child(reason), + ) + }, + ); + let (cancel_id, submit_id, label) = ( + (self.id.clone(), "cancel"), + (self.id.clone(), "submit"), + self.submit, + ); + let on_close = self.on_close; + Dialog::new(self.id, self.title, move |window, cx| on_close(window, cx)) + .focus_first(self.field.read(cx).focus().clone()) + .on_enter(move |close| Rc::new(move |window, cx| lifted(&close, window, cx))) + .child(field) + .action(move |close| { + Button::new(cancel_id, "Cancel") + .variant(ButtonVariant::Ghost) + .on_click(move |_, window, cx| close(window, cx)) + }) + .action(move |close| { + Button::new(submit_id, label) + .primary() + .disabled(!ready) + .on_click(move |_, window, cx| button(&close, window, cx)) + }) + } +} diff --git a/src/overlays/hover.rs b/src/overlays/hover.rs new file mode 100644 index 0000000..9ae5ef2 --- /dev/null +++ b/src/overlays/hover.rs @@ -0,0 +1,135 @@ +use std::{rc::Rc, time::Duration}; + +use gpui::{ + AnyElement, App, Bounds, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, + Pixels, RenderOnce, StatefulInteractiveElement, Styled, Task, Window, canvas, div, +}; + +use crate::forms::{float_height, surface}; + +/// How long the pointer rests on the trigger before the card opens. +const OPEN: Duration = Duration::from_millis(500); +/// How long the card waits after the pointer leaves, so it can cross the gap. +const LINGER: Duration = Duration::from_millis(200); + +type Content = Rc AnyElement>; + +/// Whether the card shows, what the pointer is over, the pending change and the trigger's box. +#[derive(Default)] +struct Hover { + shown: bool, + on_trigger: bool, + on_card: bool, + host: Bounds, + height: Pixels, + _pending: Option>, +} + +/// After `after`, shows or hides the card as the pointer then warrants. +fn settle_after(state: &Entity, after: Duration, window: &mut Window, cx: &mut App) { + let weak = state.downgrade(); + let task = window.spawn(cx, async move |cx| { + cx.background_executor().timer(after).await; + let done = cx.update(|_, cx| { + weak.update(cx, |hover, cx| { + let shown = hover.on_trigger || hover.on_card; + if hover.shown != shown { + log::info!("hover card: {}", if shown { "shown" } else { "hidden" }); + hover.shown = shown; + cx.notify(); + } + hover._pending = None; + }) + }); + if let Err(error) = done.and_then(|inner| inner) { + log::error!("hover card: trigger vanished: {error:#}"); + } + }); + state.update(cx, |hover, _| hover._pending = Some(task)); +} + +/// A card that opens once the pointer rests on its trigger, and stays while the pointer is on either. It never takes focus. +#[derive(IntoElement)] +pub struct HoverCard { + id: ElementId, + trigger: AnyElement, + content: Content, +} + +impl HoverCard { + pub fn new( + id: impl Into, + trigger: impl IntoElement, + content: impl Fn(&mut Window, &mut App) -> E + 'static, + ) -> Self { + Self { + id: id.into(), + trigger: trigger.into_any_element(), + content: Rc::new(move |window, cx| content(window, cx).into_any_element()), + } + } +} + +impl RenderOnce for HoverCard { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = window.use_keyed_state((self.id.clone(), "hover"), cx, |_, _| Hover::default()); + let (enter, measure) = (state.clone(), state.clone()); + let host = div() + .id(self.id.clone()) + .relative() + .child(self.trigger) + .on_hover(move |hovered, window, cx| { + enter.update(cx, |hover, _| hover.on_trigger = *hovered); + settle_after(&enter, if *hovered { OPEN } else { LINGER }, window, cx); + }) + .child( + canvas( + move |bounds, _, cx| { + if measure.read(cx).host != bounds { + measure.update(cx, |hover, cx| { + hover.host = bounds; + cx.notify(); + }); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ); + if !state.read(cx).shown { + return host; + } + let (over, tall) = (state.clone(), state.clone()); + let card = surface((self.id.clone(), "card"), cx) + .relative() + .occlude() + .p_4() + .on_hover(move |hovered, window, cx| { + over.update(cx, |hover, _| hover.on_card = *hovered); + settle_after(&over, LINGER, window, cx); + }) + .child((self.content)(window, cx)) + .child( + canvas( + move |bounds, _, cx| { + if tall.read(cx).height != bounds.size.height { + tall.update(cx, |hover, cx| { + hover.height = bounds.size.height; + cx.notify(); + }); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ); + let (anchor, height) = (state.read(cx).host, state.read(cx).height); + host.child(float_height(anchor, height, card, window, cx)) + } +} diff --git a/src/overlays/mod.rs b/src/overlays/mod.rs new file mode 100644 index 0000000..77649cf --- /dev/null +++ b/src/overlays/mod.rs @@ -0,0 +1,11 @@ +mod dialog; +mod dialogs; +mod hover; +mod popover; +#[cfg(all(test, feature = "test-support"))] +mod tests; + +pub use dialog::{Close, Dialog}; +pub use dialogs::{AlertDialog, ConfirmDialog, PromptDialog}; +pub use hover::HoverCard; +pub use popover::Popover; diff --git a/src/overlays/popover.rs b/src/overlays/popover.rs new file mode 100644 index 0000000..bf07346 --- /dev/null +++ b/src/overlays/popover.rs @@ -0,0 +1,170 @@ +use gpui::{ + AnyElement, App, Bounds, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, + Pixels, RenderOnce, SharedString, Styled, Window, canvas, div, prelude::*, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + forms::{float_height, surface}, + primitives::{FocusScope, IconName, Takeover, give_back, take_focus}, +}; + +type Content = Box AnyElement>; + +/// Whether the panel is open, where the trigger sits, and the focus to hand back. +#[derive(Default)] +struct Pop { + open: bool, + host: Bounds, + height: Pixels, + takeover: Option>, +} + +fn close(state: &Entity, window: &mut Window, cx: &mut App) { + log::info!("popover: closed"); + let takeover = state.update(cx, |pop, cx| { + pop.open = false; + cx.notify(); + pop.takeover.take() + }); + if let Some(takeover) = takeover { + give_back(&takeover, window, cx); + } +} + +/// A button that opens a panel of any content under it, or over it when only above has room. A press outside or Escape closes it; Tab stays inside. +#[derive(IntoElement)] +pub struct Popover { + id: ElementId, + label: SharedString, + icon: Option, + variant: ButtonVariant, + content: Content, +} + +impl Popover { + /// `content` is built only while the panel is open. + pub fn new( + id: impl Into, + label: impl Into, + content: impl FnOnce(&mut Window, &mut App) -> E + 'static, + ) -> Self { + Self { + id: id.into(), + label: label.into(), + icon: None, + variant: ButtonVariant::Secondary, + content: Box::new(move |window, cx| content(window, cx).into_any_element()), + } + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(icon); + self + } + + /// `Link` sets the trigger inline in running text. + pub fn variant(mut self, variant: ButtonVariant) -> Self { + self.variant = variant; + self + } +} + +impl RenderOnce for Popover { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = window.use_keyed_state((self.id.clone(), "popover"), cx, |_, _| Pop::default()); + let open = state.read(cx).open; + let toggle = state.clone(); + let trigger = Button::new((self.id.clone(), "trigger"), self.label) + .variant(self.variant) + .when_some(self.icon, |button, icon| button.icon(icon)) + .on_click(move |_, window, cx| { + if open { + close(&toggle, window, cx); + } else { + log::info!("popover: open"); + toggle.update(cx, |pop, cx| { + pop.open = true; + cx.notify(); + }); + } + }); + let measure = state.clone(); + let host = div() + .id(self.id.clone()) + .relative() + .flex_none() + .child(trigger) + .child( + canvas( + move |bounds, _, cx| { + if measure.read(cx).host != bounds { + measure.update(cx, |pop, cx| { + pop.host = bounds; + cx.notify(); + }); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ); + if !open { + return host; + } + let takeover = take_focus((self.id.clone(), "takeover"), window, cx); + let focus = takeover.read(cx).focus.clone(); + if state.read(cx).takeover.is_none() { + state.update(cx, |pop, _| pop.takeover = Some(takeover)); + } + if !focus.contains_focused(window, cx) { + log::info!("popover {:?}: focus left", self.id); + close(&state, window, cx); + return host; + } + let (escape, out, tall) = (state.clone(), state.clone(), state.clone()); + let (anchor, height) = (state.read(cx).host, state.read(cx).height); + let body = (self.content)(window, cx); + let panel = div() + .id((self.id.clone(), "panel")) + .relative() + .occlude() + .on_key_down(move |event, window, cx| { + if event.keystroke.key == "escape" { + cx.stop_propagation(); + close(&escape, window, cx); + } + }) + .on_mouse_down_out(move |event, window, cx| { + if !out.read(cx).host.contains(&event.position) { + close(&out, window, cx); + } + }) + .child( + FocusScope::new(&focus) + .trap() + .child(surface((self.id.clone(), "surface"), cx).p_3().child(body)), + ) + .child( + canvas( + move |bounds, _, cx| { + if tall.read(cx).height != bounds.size.height { + tall.update(cx, |pop, cx| { + pop.height = bounds.size.height; + cx.notify(); + }); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ); + host.child(float_height(anchor, height, panel, window, cx)) + } +} diff --git a/src/overlays/tests.rs b/src/overlays/tests.rs new file mode 100644 index 0000000..4ee91c9 --- /dev/null +++ b/src/overlays/tests.rs @@ -0,0 +1,351 @@ +use std::time::Duration; + +use gpui::{ + AppContext as _, Context, Entity, FocusHandle, InteractiveElement, IntoElement, KeyBinding, + KeyUpEvent, Keystroke, Modifiers, ParentElement, Render, Styled, TestAppContext, + VisualTestContext, Window, div, point, px, +}; + +use super::{AlertDialog, ConfirmDialog, Dialog, HoverCard, Popover, PromptDialog}; +use crate::{ + buttons::Button, + forms::TextInput, + primitives::{FocusNext, FocusScope, Severity}, + theme::Theme, +}; + +#[derive(Clone, Copy, PartialEq)] +enum Open { + Plain, + Alert, + Confirm, + Prompt, +} + +/// A popover, a hover card and one dialog at a time, recording what they do. +struct Stage { + root: FocusHandle, + before: FocusHandle, + field: Entity, + open: Option, + log: Vec, +} + +impl Stage { + fn note( + &self, + cx: &mut Context, + what: &'static str, + ) -> impl Fn(&mut Window, &mut gpui::App) + Clone + 'static { + let view = cx.entity(); + move |_, cx| view.update(cx, |stage, _| stage.log.push(what.into())) + } + + fn shut(&self, cx: &mut Context) -> impl Fn(&mut Window, &mut gpui::App) + 'static { + let view = cx.entity(); + move |_, cx| { + view.update(cx, |stage, cx| { + stage.open = None; + cx.notify(); + }) + } + } +} + +impl Render for Stage { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (copy, mail, follow) = ( + self.note(cx, "copy"), + self.note(cx, "mail"), + self.note(cx, "follow"), + ); + let popover = Popover::new("share", "Share", move |_, _| { + div() + .flex() + .flex_col() + .gap_2() + .child(div().h(px(60.0))) + .child( + div() + .flex() + .gap_2() + .child( + Button::new("copy", "Copy link") + .on_click(move |_, window, cx| copy(window, cx)), + ) + .child( + Button::new("mail", "Email") + .on_click(move |_, window, cx| mail(window, cx)), + ), + ) + }); + let beneath = self.note(cx, "beneath"); + let card = HoverCard::new( + "ada", + div().id("ada-name").w(px(120.0)).h(px(24.0)).child("Ada"), + move |_, _| { + let follow = follow.clone(); + Button::new("follow", "Follow").on_click(move |_, window, cx| follow(window, cx)) + }, + ); + let (shut, confirmed) = (self.shut(cx), self.note(cx, "confirmed")); + let sent = cx.entity(); + let overlay = self.open.map(|open| match open { + Open::Plain => Dialog::new("plain", "Plain", shut) + .detail("A line of detail") + .into_any_element(), + Open::Alert => AlertDialog::new("alert", Severity::Warning, "Alert", "Look", shut) + .into_any_element(), + Open::Confirm => ConfirmDialog::new("confirm", "Delete", "Gone for good", shut) + .confirm("Delete") + .destructive() + .on_confirm(move |window, cx| confirmed(window, cx)) + .into_any_element(), + Open::Prompt => PromptDialog::new("prompt", "Rename", &self.field, shut) + .check(|text| match text { + "" => Err("Needs a name".into()), + text if text.contains('/') => Err("No slashes".into()), + _ => Ok(()), + }) + .on_submit(move |text, _, cx| { + let text = format!("sent {text}"); + sent.update(cx, |stage, _| stage.log.push(text)); + }) + .into_any_element(), + }); + FocusScope::new(&self.root) + .size_full() + .child(popover) + .child(div().h(px(52.0))) + .child( + Button::new("beneath", "Beneath") + .on_click(move |_, window, cx| beneath(window, cx)), + ) + .child(div().h(px(80.0))) + .child(Button::new("elsewhere", "Elsewhere")) + .child(div().h(px(12.0))) + .child(card) + .child(div().id("before").track_focus(&self.before)) + .children(overlay) + } +} + +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) { + cx.simulate_keystrokes(key); + settle(cx); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).expect("a key"), + }); + settle(cx); +} + +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()); + settle(cx); +} + +fn stage(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + cx.update(|cx| { + Theme::init(cx); + crate::forms::bind_keys(cx); + cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]); + }); + let (view, cx) = cx.add_window_view(|window, cx| Stage { + root: cx.focus_handle(), + before: cx.focus_handle(), + field: cx.new(|cx| TextInput::new(window, cx)), + open: None, + log: Vec::new(), + }); + settle(cx); + (view, cx) +} + +fn log(view: &Entity, cx: &mut VisualTestContext) -> Vec { + view.read_with(cx, |stage, _| stage.log.clone()) +} + +fn show(view: &Entity, open: Open, cx: &mut VisualTestContext) { + let before = view.read_with(cx, |stage, _| stage.before.clone()); + cx.update(|window, _| window.focus(&before)); + view.update(cx, |stage, cx| { + stage.open = Some(open); + cx.notify(); + }); + settle(cx); +} + +#[gpui::test] +fn tab_walks_inside_an_open_popover(cx: &mut TestAppContext) { + let (view, cx) = stage(cx); + cx.update(|window, _| window.focus_next()); + press("enter", cx); + for _ in 0..3 { + press("tab", cx); + } + press("enter", cx); + assert_eq!(log(&view, cx), ["copy"]); +} + +#[gpui::test] +fn escape_closes_the_popover_and_hands_focus_back(cx: &mut TestAppContext) { + let (view, cx) = stage(cx); + cx.update(|window, _| window.focus_next()); + press("enter", cx); + press("escape", cx); + press("enter", cx); + press("tab", cx); + press("enter", cx); + assert_eq!(log(&view, cx), ["copy"], "focus came back to the trigger"); +} + +#[gpui::test] +fn a_press_on_a_button_outside_closes_the_popover(cx: &mut TestAppContext) { + let (view, cx) = stage(cx); + click(12.0, 12.0, cx); + click(20.0, 200.0, cx); + click(40.0, 127.0, cx); + assert!(log(&view, cx).is_empty()); +} + +#[gpui::test] +fn the_card_opens_after_a_rest_and_holds_while_on_it(cx: &mut TestAppContext) { + let (view, cx) = stage(cx); + cx.simulate_mouse_move(point(px(20.0), px(240.0)), None, Modifiers::none()); + settle(cx); + cx.executor().advance_clock(Duration::from_millis(600)); + settle(cx); + click(40.0, 290.0, cx); + assert_eq!(log(&view, cx), ["follow"]); +} + +#[gpui::test] +fn escape_closes_a_dialog_and_hands_focus_back(cx: &mut TestAppContext) { + let (view, cx) = stage(cx); + show(&view, Open::Plain, cx); + press("escape", cx); + assert!(view.read_with(cx, |stage, _| stage.open.is_none())); + let before = view.read_with(cx, |stage, _| stage.before.clone()); + assert!(cx.update(|window, _| before.is_focused(window))); +} + +#[gpui::test] +fn the_scrim_closes_a_dialog_but_not_an_alert(cx: &mut TestAppContext) { + let (view, cx) = stage(cx); + show(&view, Open::Alert, cx); + click(8.0, 8.0, cx); + assert!(view.read_with(cx, |stage, _| stage.open == Some(Open::Alert))); + view.update(cx, |stage, cx| { + stage.open = None; + cx.notify(); + }); + settle(cx); + show(&view, Open::Plain, cx); + click(8.0, 8.0, cx); + assert!(view.read_with(cx, |stage, _| stage.open.is_none())); +} + +#[gpui::test] +fn confirm_runs_from_the_keyboard(cx: &mut TestAppContext) { + let (view, cx) = stage(cx); + show(&view, Open::Confirm, cx); + press("tab", cx); + press("tab", cx); + press("enter", cx); + assert_eq!(log(&view, cx), ["confirmed"]); + assert!(view.read_with(cx, |stage, _| stage.open.is_none())); +} + +#[gpui::test] +fn the_prompt_sends_only_what_passes(cx: &mut TestAppContext) { + let (view, cx) = stage(cx); + show(&view, Open::Prompt, cx); + cx.simulate_input("a/b"); + settle(cx); + press("enter", cx); + assert!(log(&view, cx).is_empty(), "a slash went through"); + press("secondary-a", cx); + cx.simulate_input("notes"); + settle(cx); + press("enter", cx); + assert_eq!(log(&view, cx), ["sent notes"]); +} + +#[gpui::test] +fn the_prompt_opens_with_its_text_selected(cx: &mut TestAppContext) { + let (view, cx) = stage(cx); + let field = view.read_with(cx, |stage, _| stage.field.clone()); + field.update(cx, |input, cx| input.set_text("Draft", cx)); + show(&view, Open::Prompt, cx); + cx.simulate_input("notes"); + settle(cx); + press("enter", cx); + assert_eq!(log(&view, cx), ["sent notes"]); +} + +fn before_focused(view: &Entity, cx: &mut VisualTestContext) -> bool { + let before = view.read_with(cx, |stage, _| stage.before.clone()); + cx.update(|window, _| before.is_focused(window)) +} + +#[gpui::test] +fn every_dialog_button_hands_focus_back(cx: &mut TestAppContext) { + let (view, cx) = stage(cx); + show(&view, Open::Alert, cx); + press("tab", cx); + press("enter", cx); + assert!(before_focused(&view, cx), "alert button"); + show(&view, Open::Confirm, cx); + press("tab", cx); + press("enter", cx); + assert!(before_focused(&view, cx), "confirm cancel"); + show(&view, Open::Confirm, cx); + press("tab", cx); + press("tab", cx); + press("enter", cx); + assert!(before_focused(&view, cx), "confirm confirm"); + show(&view, Open::Prompt, cx); + cx.simulate_input("notes"); + settle(cx); + press("enter", cx); + assert!(before_focused(&view, cx), "prompt submit"); +} + +#[gpui::test] +fn a_press_on_the_panel_stays_off_what_lies_beneath(cx: &mut TestAppContext) { + let (view, cx) = stage(cx); + click(12.0, 12.0, cx); + click(30.0, 94.0, cx); + assert!( + log(&view, cx).is_empty(), + "the press went through the panel" + ); + click(40.0, 127.0, cx); + assert_eq!(log(&view, cx), ["copy"], "the panel stayed open"); +} + +#[gpui::test] +fn enter_on_a_prompt_button_runs_that_button_alone(cx: &mut TestAppContext) { + let (view, cx) = stage(cx); + show(&view, Open::Prompt, cx); + cx.simulate_input("notes"); + settle(cx); + press("tab", cx); + press("enter", cx); + assert!(log(&view, cx).is_empty(), "cancel submitted"); + show(&view, Open::Prompt, cx); + cx.simulate_input("notes"); + settle(cx); + press("tab", cx); + press("tab", cx); + press("enter", cx); + assert_eq!(log(&view, cx), ["sent notes"]); +} diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index f535c12..3ef01c0 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -6,6 +6,7 @@ mod icon; mod image; mod measure; mod pressable; +mod severity; mod tooltip; pub use backdrop::{Backdrop, Place}; @@ -17,4 +18,5 @@ pub use icon::{Icon, IconName}; pub use image::Image; pub use measure::{IntersectionObserver, Measure}; pub use pressable::Pressable; +pub use severity::Severity; pub use tooltip::{Tooltip, TooltipTrigger}; diff --git a/src/primitives/severity.rs b/src/primitives/severity.rs new file mode 100644 index 0000000..9ad7ab5 --- /dev/null +++ b/src/primitives/severity.rs @@ -0,0 +1,43 @@ +use gpui::Hsla; + +use super::IconName; +use crate::theme::Palette; + +/// How much a message matters, with the color and icon that say so. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Severity { + Info, + Success, + Warning, + Danger, +} + +impl Severity { + pub fn color(self, colors: &Palette) -> Hsla { + match self { + Self::Info => colors.info, + Self::Success => colors.success, + Self::Warning => colors.warning, + Self::Danger => colors.danger, + } + } + + /// The quiet fill behind its icon or a banner. + pub fn subtle(self, colors: &Palette) -> Hsla { + match self { + Self::Info => colors.info_subtle, + Self::Success => colors.success_subtle, + Self::Warning => colors.warning_subtle, + Self::Danger => colors.danger_subtle, + } + } + + pub fn icon(self) -> IconName { + match self { + Self::Info => IconName::Info, + Self::Success => IconName::CircleCheck, + Self::Warning => IconName::TriangleAlert, + Self::Danger => IconName::CircleAlert, + } + } +} diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index 394ce20..1de60a7 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -233,6 +233,11 @@ impl Theme { (px_to_rems(84.0), px_to_rems(40.0)) } + /// Width of a dialog's card. + pub fn dialog_width(&self) -> Rems { + px_to_rems(440.0) + } + /// Narrowest a menu's panel gets. pub fn menu_width(&self) -> Rems { px_to_rems(200.0) diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md index 5fe0f9e..03468a3 100644 --- a/tasks/ch01-10.md +++ b/tasks/ch01-10.md @@ -326,18 +326,21 @@ - [x] SearchableMenu → `menus::SearchableMenu` - [x] PieMenu / RadialMenu → `menus::PieMenu`, `menus::PieItem` -## T09 Overlays — `src/overlays` +## T09a Overlays · Popovers & dialogs — `src/overlays` + +- [x] Tooltip → `primitives::Tooltip` +- [x] Popover → `overlays::Popover` +- [x] HoverCard → `overlays::HoverCard` +- [x] Dialog / Modal → `overlays::Dialog` +- [x] AlertDialog → `overlays::AlertDialog` +- [x] ConfirmDialog → `overlays::ConfirmDialog` +- [x] PromptDialog → `overlays::PromptDialog` +- [x] FullscreenDialog → `overlays::Dialog::fullscreen` +- [x] Sheet → `layout::Sheet` +- [x] Drawer → `layout::Drawer` + +## T09b Overlays · Guides & floats -- [ ] Tooltip → `primitives::Tooltip` -- [ ] Popover -- [ ] HoverCard -- [ ] Dialog / Modal -- [ ] AlertDialog -- [ ] ConfirmDialog -- [ ] PromptDialog -- [ ] FullscreenDialog -- [ ] Sheet → `layout::Sheet` -- [ ] Drawer → `layout::Drawer` - [ ] Lightbox - [ ] Spotlight / Coachmark — also T07c Tour / Onboarding and T37 WelcomeTour - [ ] FloatingToolbar