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

+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)