T09a overlays: popover, hover card, dialog with fullscreen, alert, confirm and prompt dialogs; shared severity; tooltip, sheet and drawer point to their homes

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 14:07:30 -04:00
1 parent d6b982016d
commit ca73d24c01
17 files changed
+1750 -16

No files matched your search

+2
View File
@@ -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.
+5 -1
View File
@@ -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 | |
+2
View File
@@ -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,
];
+381
View File
@@ -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<Option<Open>>,
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::<Open>, window, cx);
let said = keep("overlay-said", || None::<SharedString>, 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<AnyElement> = (*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()
}
+1 -1
View File
@@ -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};
+30 -3
View File
@@ -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<Pixels>, 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<Pixels>,
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() {
+1
View File
@@ -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;
+271
View File
@@ -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<dyn Fn(&mut Window, &mut App)>;
type Action = Box<dyn FnOnce(Close) -> AnyElement>;
type OnEnter = Box<dyn FnOnce(Close) -> 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<SharedString>,
body: SmallVec<[AnyElement; 2]>,
actions: SmallVec<[Action; 2]>,
enter: Option<OnEnter>,
scrim_closes: bool,
fullscreen: bool,
first: Option<FocusHandle>,
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<ElementId>,
title: impl Into<SharedString>,
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<SharedString>) -> 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<E: IntoElement>(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<Item = AnyElement>) {
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<AnyElement> = 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()
}
}
+326
View File
@@ -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<Severity>, 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<ElementId>,
severity: Severity,
title: impl Into<SharedString>,
message: impl Into<SharedString>,
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<SharedString>) -> 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<Run>,
on_close: Run,
}
impl ConfirmDialog {
/// `on_close` runs on every way out, the confirm button included.
pub fn new(
id: impl Into<ElementId>,
title: impl Into<SharedString>,
message: impl Into<SharedString>,
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<SharedString>) -> 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<dyn Fn(&str) -> Result<(), SharedString>>;
type Submit = Rc<dyn Fn(&str, &mut Window, &mut App)>;
type Send = Rc<dyn Fn(&Close, &mut Window, &mut App)>;
/// 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<TextInput>,
label: Option<SharedString>,
submit: SharedString,
check: Option<Check>,
on_submit: Option<Submit>,
on_close: Run,
}
impl PromptDialog {
pub fn new(
id: impl Into<ElementId>,
title: impl Into<SharedString>,
field: &Entity<TextInput>,
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<SharedString>) -> Self {
self.label = Some(label.into());
self
}
/// The submit button's label.
pub fn submit(mut self, label: impl Into<SharedString>) -> 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))
})
}
}
+135
View File
@@ -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<dyn Fn(&mut Window, &mut App) -> 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<Pixels>,
height: Pixels,
_pending: Option<Task<()>>,
}
/// After `after`, shows or hides the card as the pointer then warrants.
fn settle_after(state: &Entity<Hover>, 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<E: IntoElement>(
id: impl Into<ElementId>,
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))
}
}
+11
View File
@@ -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;
+170
View File
@@ -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<dyn FnOnce(&mut Window, &mut App) -> AnyElement>;
/// Whether the panel is open, where the trigger sits, and the focus to hand back.
#[derive(Default)]
struct Pop {
open: bool,
host: Bounds<Pixels>,
height: Pixels,
takeover: Option<Entity<Takeover>>,
}
fn close(state: &Entity<Pop>, 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<IconName>,
variant: ButtonVariant,
content: Content,
}
impl Popover {
/// `content` is built only while the panel is open.
pub fn new<E: IntoElement>(
id: impl Into<ElementId>,
label: impl Into<SharedString>,
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))
}
}
+351
View File
@@ -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<TextInput>,
open: Option<Open>,
log: Vec<String>,
}
impl Stage {
fn note(
&self,
cx: &mut Context<Self>,
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<Self>) -> 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<Self>) -> 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<Stage>, &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<Stage>, cx: &mut VisualTestContext) -> Vec<String> {
view.read_with(cx, |stage, _| stage.log.clone())
}
fn show(view: &Entity<Stage>, 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<Stage>, 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"]);
}
+2
View File
@@ -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};
+43
View File
@@ -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,
}
}
}
+5
View File
@@ -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)
+14 -11
View File
@@ -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