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:
17 files changed
+1750
-16
No files matched your search
+1
-1
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user