blob: a36b6ae098e6f416c8752513af908fd22504a3e4 [file]
//! The window-level overlay host.
//!
//! `gpui-base` ships the overlay *parts* — [`Dialog`] and [`Sheet`] each build
//! their own viewport-sized host, [`ToastManager`] and [`ToastStackState`] own
//! stacking geometry and lifecycle, [`gpui_base::FocusTrapElement`] owns focus
//! trapping — but nothing in Base decides what happens when two of them are
//! open at once. [`ShellRoot`] is that decision, and it is the only reason this
//! module exists: it is a stacking order plus a dismissal order, with the
//! smallest presentation that makes them visible.
//!
//! It is deliberately not `gpui_component::Root`. The shell binds to
//! `gpui-base` only (see `docs/gpui-shell.md` §4.2), so the equivalent
//! host has to be written here rather than reused.
use std::{cell::RefCell, path::PathBuf, rc::Rc, time::Duration};
use gpui::{
Anchor, AnyElement, AnyView, App, AppContext as _, ClickEvent, ClipboardItem, Context,
ElementId, Entity, FocusHandle, Global, Hsla, InteractiveElement as _, IntoElement, KeyBinding,
MouseButton, MouseDownEvent, ParentElement as _, Render, SharedString,
StatefulInteractiveElement as _, StyleRefinement, Styled as _, Subscription, WeakFocusHandle,
Window, actions, deferred, div, hsla, prelude::FluentBuilder as _, px,
};
use gpui_base::{
ColorTokens, Dialog, POPUP_PRIORITY, Placement, RadiusTokens, Sheet, SpacingTokens,
StyledExt as _, TextSelection, TextSelectionLayer, Theme, Toast, ToastManager, ToastMotion,
ToastOptions, ToastStack, ToastStackState, TooltipOverlay, TooltipTransition,
active_focus_trap,
animation::{EffectTransition, ease_in_out_cubic, ease_out_cubic},
v_flex,
};
use crate::{scope, view::ScriptView};
actions!(shell_root, [Tab, TabPrev, Copy]);
/// The key context the root installs. Overlay hosts add their own contexts
/// (`Dialog`, `Sheet`) below this one, so a binding declared here is reachable
/// from inside an overlay while an overlay binding is not reachable outside it.
const CONTEXT: &str = "ShellRoot";
/// How often the toast lifecycle clock is advanced.
///
/// Toast timeouts are wall-clock, not frame-driven, so they have to be sampled;
/// 50ms is below the threshold where a dismissal reads as late and far above
/// the cost of one comparison per mounted toast.
const TOAST_TICK: Duration = Duration::from_millis(50);
/// How many active toasts are mounted at once. Older toasts stay in the manager
/// and reappear as newer ones leave, so a burst is throttled rather than lost.
const TOAST_VISIBLE_LIMIT: usize = 3;
/// Paint priority of the toast layer.
///
/// Above [`POPUP_PRIORITY`] because a toast reports the outcome of the action
/// the user just took, and an open dropdown or dialog is exactly the situation
/// where that outcome matters most. It is the one layer that is never occluded.
const TOAST_PRIORITY: usize = POPUP_PRIORITY + 1;
/// Width of the toast column. The single geometric constant in this module:
/// toasts are anchored to a viewport corner, so unlike every other overlay here
/// they cannot take their measure from their content or from the viewport.
const TOAST_WIDTH: gpui::Pixels = px(320.);
/// The window-level overlay host: content, one sheet, a dialog stack, toasts.
///
/// The first view of a shell window is a `ShellRoot`, the same way the first
/// view of a `gpui-component` window is a `Root` — or, when a component catalog
/// opens the window around its own `Root`, the view directly inside that.
/// Scripts reach it through [`ShellRoot::update`], never by constructing
/// overlays themselves.
///
/// # Stacking order
///
/// Painted back to front, each layer above the one before it:
///
/// 1. **Content** — the script's root view.
/// 2. **Sheet** — at most one, anchored to a viewport edge. A sheet is a
/// *place* in the window, so it sits below the dialog stack: a dialog raised
/// from inside a sheet must be readable, and a sheet raised under a dialog
/// must not cover it.
/// 3. **Dialog stack** — in open order, oldest at the bottom. Each dialog is
/// deferred at `10 + index`, so a later dialog always paints over an earlier
/// one regardless of the order the elements were built in.
/// 4. **Toasts** — above the overlay stack, at the root's toast priority.
/// 5. **Tooltip** — one layer, always topmost. Base defers it above every
/// priority the root uses, so it is the one layer whose order is not the
/// root's to choose.
///
/// Only the topmost dialog draws a backdrop. A stack of three dialogs dims the
/// window once, not three times, and the single backdrop is what separates the
/// live dialog from the inert ones behind it.
///
/// # Dismissal order
///
/// Dismissal is always *one* layer, never a cascade:
///
/// - **Escape** closes the topmost dialog only. Lower dialogs render with
/// keyboard handling disabled, so a repeated Escape unwinds the stack one
/// dialog per press and never reaches the sheet while a dialog is open.
/// [`DialogOptions::escape_dismissable`] withdraws the *key binding*, not the
/// underlying cancel action: a close control the script puts inside the
/// dialog still works, which is what makes an undismissable dialog a dialog
/// the user must answer rather than one they cannot leave.
/// - **Backdrop press** closes the topmost dialog, and only if that dialog was
/// opened with [`DialogOptions::backdrop_dismissable`]. A dialog that is
/// asking a question the user must answer keeps its backdrop inert while
/// still dimming what is behind it.
/// - **Enter** does nothing at this layer. Base's dialog host treats Enter as
/// "confirm and close"; that belongs to the dialog's own primary button,
/// which the script owns, so the root vetoes the built-in confirmation
/// instead of guessing which content wants it.
/// - A **sheet** is dismissed by Escape or by its overlay only when no dialog
/// is open, because a dialog above it holds focus.
/// - [`ShellRoot::close_all_dialogs`] is the one operation that unwinds the
/// whole stack, and it leaves the sheet alone.
///
/// # Focus
///
/// Opening an overlay records the currently focused handle and focuses the
/// overlay; closing it restores that handle. Because each open records what was
/// focused at the time, a stack restores focus through its own history: closing
/// the second dialog returns focus to the first, and closing the first returns
/// it to whatever the window was on before either opened.
///
/// Tab and Shift-Tab honour the focus trap that Base's dialog and sheet hosts
/// register, so tabbing inside an overlay cycles within it instead of walking
/// into the content behind it.
///
/// # Presentation
///
/// Backdrop, surface, and spacing, drawn from [`gpui_base::Theme`]'s semantic
/// tokens — nothing else. This type positions and layers; the visual language
/// of what goes *inside* an overlay belongs to the script.
pub struct ShellRoot {
content: AnyView,
application: Option<MountedApplication>,
/// Retains a script application's policy and cancels its work when this
/// window root is dropped. Ordinary Rust content leaves it empty.
application_policy: Option<Rc<crate::policy::Policy>>,
application_generation: Option<Rc<crate::runtime::ApplicationGeneration>>,
/// Open dialogs, oldest first. The last entry is the topmost and the only
/// interactive one.
dialogs: Vec<ActiveDialog>,
sheet: Option<ActiveSheet>,
toasts: ToastManager<SharedString, ToastRequest>,
toast_state: ToastStackState,
toast_focus_handle: FocusHandle,
/// Source of ids for toasts pushed without one. Monotonic so that a
/// replaced id can never collide with a live toast.
next_toast_ordinal: u64,
/// The window's one tooltip layer. Base owns the delay, the grace period
/// between two triggers and the paint priority; the root owns only the
/// decision to have one, and what an appearing tooltip looks like.
tooltip_overlay: Entity<TooltipOverlay>,
/// The performance HUD, while a script has asked for one. See
/// [`ShellRoot::show_fps_monitor`].
fps_hud: Option<FpsHudRequest>,
/// What had the keyboard when this root last rendered. See
/// [`restore_lost_focus`].
focused_at_render: Rc<RefCell<Option<WeakFocusHandle>>>,
_focus_lost: Subscription,
}
/// Where the performance HUD sits over the window and how it behaves.
///
/// What a script's `show_fps_monitor(options)` names, with the same defaults
/// `gpui_fps` gives an overlay a Rust host places by hand.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FpsHudRequest {
/// Corner or edge of the window.
pub anchor: Anchor,
/// The per-frame budget the HUD grades frame cost against. `None` keeps
/// the HUD's own default.
pub frame_budget: Option<Duration>,
}
impl Default for FpsHudRequest {
fn default() -> Self {
Self {
anchor: Anchor::TopRight,
frame_budget: None,
}
}
}
pub(crate) struct MountedApplication {
pub(crate) view: gpui::Entity<ScriptView>,
pub(crate) root: PathBuf,
pub(crate) entry: String,
}
struct ActiveDialog {
content: AnyView,
focus_handle: FocusHandle,
/// What was focused when this dialog opened, to be restored when it closes.
/// Weak, because the view that held it may be gone by then.
restore_focus: Option<WeakFocusHandle>,
options: DialogOptions,
}
struct ActiveSheet {
content: AnyView,
placement: Placement,
focus_handle: FocusHandle,
restore_focus: Option<WeakFocusHandle>,
}
/// Marks that this `App` already has the root's key bindings.
///
/// The root installs its own bindings rather than relying on `gpui_shell::init`
/// so that adding it to a window is the only step required, and guards on a
/// global so that opening a second window does not append a duplicate binding.
struct KeyBindingsInstalled;
impl Global for KeyBindingsInstalled {}
impl ShellRoot {
/// Wraps a script view as the content of a window.
///
/// Takes an [`AnyView`] rather than an `Entity<ScriptView>` so the host can
/// mount a Rust view too — the error screen a failed script load falls back
/// to is not a `ScriptView`, and the root must be able to carry it.
pub fn new(content: AnyView, window: &mut Window, cx: &mut Context<Self>) -> Self {
install_key_bindings(cx);
Self::spawn_toast_clock(window, cx);
let focused_at_render = Rc::new(RefCell::new(None));
let focus_lost = window.on_focus_lost(cx, {
let focused_at_render = Rc::clone(&focused_at_render);
move |window, cx| restore_lost_focus(&focused_at_render, window, cx)
});
Self {
content,
application: None,
application_policy: None,
application_generation: None,
dialogs: Vec::new(),
sheet: None,
toasts: ToastManager::new(ToastMotion::sonner()),
toast_state: ToastStackState::default(),
toast_focus_handle: cx.focus_handle().tab_stop(true),
next_toast_ordinal: 0,
tooltip_overlay: cx.new(|_| TooltipOverlay::new().render_with(render_tooltip)),
fps_hud: None,
focused_at_render,
_focus_lost: focus_lost,
}
}
pub(crate) fn with_application(
view: gpui::Entity<ScriptView>,
root: PathBuf,
entry: String,
policy: Rc<crate::policy::Policy>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let mut shell_root = Self::new(view.clone().into(), window, cx);
shell_root.application_generation = view.read(cx).application_generation();
shell_root.application = Some(MountedApplication { view, root, entry });
shell_root.application_policy = Some(policy);
shell_root
}
/// Reaches the root of the window a call is happening in.
///
/// This is how a script-facing binding gets from the `&mut Window` its call
/// scope carries to the overlay host, without the script ever holding the
/// root itself. `None` when the window has no `ShellRoot` (see
/// [`Self::find`]), which is a host wiring mistake rather than a script
/// error — hence an `Option` and not a panic.
pub fn update<R>(
window: &mut Window,
cx: &mut App,
f: impl FnOnce(&mut Self, &mut Window, &mut Context<Self>) -> R,
) -> Option<R> {
let root = Self::find(window, cx)?;
Some(root.update(cx, |root, cx| f(root, window, cx)))
}
/// The tooltip layer of the window a call is happening in.
///
/// The counterpart to [`ShellRoot::update`] for the one overlay a script
/// never opens by hand: a tooltip trigger is an element, so what reaches it
/// is a hover listener holding a `&mut Window`, not a call scope. `None`
/// when the window has no `ShellRoot` — the same host wiring mistake
/// `update` reports, and the same reason it is not a panic.
pub fn tooltip_overlay(window: &mut Window, cx: &mut App) -> Option<Entity<TooltipOverlay>> {
let root = Self::find(window, cx)?;
Some(root.read(cx).tooltip_overlay.clone())
}
/// The `ShellRoot` of `window`: its first view, or the view directly inside
/// a [`gpui_base::Root`] that is.
///
/// A component catalog may open the window itself so that its own
/// overlays find the `Root` they require (see
/// [`ComponentWindowOpener`](crate::ComponentWindowOpener)); the runtime's
/// root then renders one level down, and is still the one this window has.
fn find(window: &Window, cx: &App) -> Option<Entity<Self>> {
if let Some(root) = window.root::<Self>().flatten() {
return Some(root);
}
let root = window.root::<gpui_base::Root>().flatten()?;
root.read(cx).view().clone().downcast::<Self>().ok()
}
/// The view this window was opened with, below every overlay.
pub fn content(&self) -> &AnyView {
&self.content
}
pub(crate) fn application(&self) -> Option<&MountedApplication> {
self.application.as_ref()
}
/// How many dialogs are open. The topmost is the only interactive one.
pub fn dialog_count(&self) -> usize {
self.dialogs.len()
}
/// The dialog that owns focus, keyboard dismissal, and the backdrop.
pub fn topmost_dialog(&self) -> Option<&AnyView> {
self.dialogs.last().map(|dialog| &dialog.content)
}
/// The open sheet, if any. At most one sheet exists at a time: a sheet is a
/// region of the window rather than a stack of them.
pub fn sheet(&self) -> Option<&AnyView> {
self.sheet.as_ref().map(|sheet| &sheet.content)
}
/// How many toasts are mounted, including ones playing their exit.
pub fn toast_count(&self) -> usize {
self.toasts.len()
}
/// Draws the performance HUD over the window, above every other layer,
/// until [`hide_fps_monitor`](Self::hide_fps_monitor). Calling it again
/// moves or reconfigures the HUD that is already up.
///
/// The root owns the HUD rather than the script's tree: the script says
/// whether and where, and what it renders can neither move the HUD nor
/// rebuild it. The monitor behind the overlay is `gpui_fps`'s own, one per
/// window, so a HUD hidden and shown again keeps its history.
///
/// Refused, with `false`, from a render pass; see `overlay_mutation_allowed`.
pub fn show_fps_monitor(&mut self, request: FpsHudRequest, cx: &mut Context<Self>) -> bool {
if !overlay_mutation_allowed("show_fps_monitor") {
return false;
}
self.fps_hud = Some(request);
cx.notify();
true
}
/// Takes the performance HUD down. `true` if one was up.
pub fn hide_fps_monitor(&mut self, cx: &mut Context<Self>) -> bool {
if !overlay_mutation_allowed("hide_fps_monitor") {
return false;
}
let was_visible = self.fps_hud.take().is_some();
if was_visible {
cx.notify();
}
was_visible
}
/// Whether the performance HUD is up.
pub fn fps_monitor_visible(&self) -> bool {
self.fps_hud.is_some()
}
/// The HUD, when a script has asked for one. Above every other layer: it
/// is a diagnostic, and a dialog over it would hide the reading the
/// dialog's own frames are producing.
fn fps_layer(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<gpui_fps::FpsOverlay> {
let request = self.fps_hud?;
let mut overlay = gpui_fps::fps_monitor(window, cx).anchor(request.anchor);
if let Some(budget) = request.frame_budget {
overlay = overlay.frame_budget(budget);
}
Some(overlay)
}
/// Opens a dialog on top of the stack, with default dismissal.
///
/// The previously topmost dialog stays mounted and visible but becomes
/// inert: it keeps its place in the stack so that closing this one returns
/// the user exactly where they were.
pub fn open_dialog(&mut self, content: AnyView, window: &mut Window, cx: &mut Context<Self>) {
self.open_dialog_with(content, DialogOptions::default(), window, cx);
}
/// Opens a dialog whose dismissal differs from the default.
///
/// Separate from [`ShellRoot::open_dialog`] because refusing dismissal is a
/// deliberate, rare choice: a confirmation the user must answer. Making it
/// the longer call keeps the easy path the dismissable one.
pub fn open_dialog_with(
&mut self,
content: AnyView,
options: DialogOptions,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !overlay_mutation_allowed("open_dialog") {
return;
}
let focus_handle = cx.focus_handle();
let restore_focus = window.focused(cx).map(|handle| handle.downgrade());
focus_handle.focus(window, cx);
self.dialogs.push(ActiveDialog {
content,
focus_handle,
restore_focus,
options,
});
cx.notify();
}
/// Closes the topmost dialog and restores the focus it took.
///
/// Returns whether a dialog was actually closed, so that a caller unwinding
/// a stack — or a script asking "was there anything to close?" — does not
/// have to read the stack depth first.
pub fn close_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
if !overlay_mutation_allowed("close_dialog") {
return false;
}
let Some(dialog) = self.dialogs.pop() else {
return false;
};
restore_focus(dialog.restore_focus, window, cx);
cx.notify();
true
}
/// Closes every dialog at once, restoring focus to where the *first* dialog
/// took it from.
///
/// Restoring through each dialog in turn would flicker focus across views
/// that are about to be dropped; the first dialog's record is the only one
/// that describes the window as it was before the stack existed.
///
/// Leaves an open sheet alone: a sheet is not part of the dialog stack.
pub fn close_all_dialogs(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !overlay_mutation_allowed("close_all_dialogs") {
return;
}
if self.dialogs.is_empty() {
return;
}
let restore = self
.dialogs
.first()
.and_then(|dialog| dialog.restore_focus.clone());
self.dialogs.clear();
restore_focus(restore, window, cx);
cx.notify();
}
/// Opens a sheet on the given edge, replacing any sheet already open.
///
/// Replacing rather than stacking keeps the focus record honest: the
/// incoming sheet inherits the outgoing one's restore target, so closing it
/// returns focus to the window rather than to a sheet that no longer
/// exists.
pub fn open_sheet(
&mut self,
placement: Placement,
content: AnyView,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !overlay_mutation_allowed("open_sheet") {
return;
}
let focus_handle = cx.focus_handle();
let restore_focus = self
.sheet
.take()
.and_then(|sheet| sheet.restore_focus)
.or_else(|| window.focused(cx).map(|handle| handle.downgrade()));
focus_handle.focus(window, cx);
self.sheet = Some(ActiveSheet {
content,
placement,
focus_handle,
restore_focus,
});
cx.notify();
}
/// Closes the sheet, returning whether one was open.
pub fn close_sheet(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
if !overlay_mutation_allowed("close_sheet") {
return false;
}
let Some(sheet) = self.sheet.take() else {
return false;
};
restore_focus(sheet.restore_focus, window, cx);
cx.notify();
true
}
/// Posts a toast to the stack.
///
/// Toasts never take focus: they report what already happened, so stealing
/// focus from the work that caused them would be a regression, not a
/// notification. `_window` is unused but kept in the signature because a
/// `&mut Window` is what makes this operation legal at all — it can only be
/// reached from an `Event` or `Task` call scope (`docs/gpui-shell.md`
/// §16.2).
pub fn push_toast(
&mut self,
toast: ToastRequest,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if !overlay_mutation_allowed("push_toast") {
return;
}
let id = toast.id.clone().unwrap_or_else(|| {
self.next_toast_ordinal += 1;
SharedString::from(format!("shell-toast-{}", self.next_toast_ordinal))
});
let options = ToastOptions {
timeout: toast.timeout,
};
self.toasts
.push(id, toast, options, cx.background_executor().now());
cx.notify();
}
/// Begins a toast's exit, returning whether it was mounted and active.
///
/// The toast stays mounted until its exit transition finishes, which is why
/// this reports "started closing" rather than "removed": a caller that
/// wants to know when it is gone should read [`ShellRoot::toast_count`].
pub fn remove_toast(&mut self, id: impl Into<SharedString>, cx: &mut Context<Self>) -> bool {
let dismissed = self
.toasts
.dismiss(&id.into(), cx.background_executor().now());
if dismissed {
cx.notify();
}
dismissed
}
/// Begins the exit of every active toast, for a script clearing its own
/// notifications.
pub fn clear_toasts(&mut self, cx: &mut Context<Self>) {
if !self
.toasts
.dismiss_all(cx.background_executor().now())
.is_empty()
{
cx.notify();
}
}
/// Samples the toast lifecycle clock forever, until the root is dropped.
///
/// Toast timeouts are the one part of this host that advances without any
/// input, so nothing else would wake the view to retire them.
fn spawn_toast_clock(window: &mut Window, cx: &mut Context<Self>) {
cx.spawn_in(window, async move |this, cx| {
loop {
cx.background_executor().timer(TOAST_TICK).await;
if this.update(cx, |this, cx| this.advance_toasts(cx)).is_err() {
break;
}
}
})
.detach();
}
fn advance_toasts(&mut self, cx: &mut Context<Self>) {
// Timers pause while the user is reading the stack, not while the window
// is inactive: a visible side-by-side window would hold its toasts forever.
let paused = self.toast_state.is_expanded();
if self
.toasts
.advance(cx.background_executor().now(), paused)
.changed
{
cx.notify();
}
}
fn on_action_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
cycle_focus(true, window, cx);
}
fn on_action_tab_prev(&mut self, _: &TabPrev, window: &mut Window, cx: &mut Context<Self>) {
cycle_focus(false, window, cx);
}
/// Builds the dialog stack, oldest first.
///
/// Each dialog gets Base's dialog host, which supplies the focus trap, the
/// Escape action, and the backdrop press handling; what this adds is the
/// stack semantics — layer index, who is topmost, who draws the backdrop,
/// and closing through the root rather than through a per-dialog handle.
fn dialog_layer(
&self,
colors: &ColorTokens,
radius: &RadiusTokens,
spacing: &SpacingTokens,
cx: &mut Context<Self>,
) -> Vec<Dialog> {
let root = cx.entity();
let topmost_index = self.dialogs.len().saturating_sub(1);
self.dialogs
.iter()
.enumerate()
.map(|(index, dialog)| {
let topmost = index == topmost_index;
let root = root.clone();
rebuild_script_overlay(&dialog.content, cx);
Dialog::new(cx)
.focus_handle(dialog.focus_handle.clone())
.layer(index, topmost)
// Keyboard dismissal belongs to the topmost dialog alone,
// stated here rather than left to focus placement.
.close_on_escape(topmost && dialog.options.is_escape_dismissable())
.close_on_backdrop_press(dialog.options.is_backdrop_dismissable())
// Veto Base's "Enter confirms and closes": the dialog's
// primary action is script-owned.
.on_ok(|_, _, _| false)
.request_close(move |_, window, cx| {
root.update(cx, |root, cx| {
root.close_dialog(window, cx);
});
})
.when(topmost, |this| {
this.backdrop(div().absolute().inset_0().bg(backdrop_color()))
})
.popup(
div()
.absolute()
.inset_0()
.flex()
.items_center()
.justify_center()
.child(
v_flex()
.occlude()
.bg(colors.surface)
.text_color(colors.surface_foreground)
.border_1()
.border_color(colors.border)
.rounded(radius.lg)
.p(spacing.lg)
.child(dialog.content.clone()),
),
)
})
.collect()
}
fn sheet_layer(
&self,
colors: &ColorTokens,
spacing: &SpacingTokens,
cx: &mut Context<Self>,
) -> Option<Sheet> {
let sheet = self.sheet.as_ref()?;
let root = cx.entity();
let placement = sheet.placement;
rebuild_script_overlay(&sheet.content, cx);
Some(
Sheet::new(cx)
.focus_handle(sheet.focus_handle.clone())
.request_close(move |window, cx| {
root.update(cx, |root, cx| {
root.close_sheet(window, cx);
});
})
.overlay(div().absolute().inset_0().bg(backdrop_color()))
.surface(
v_flex()
.occlude()
.absolute()
.map(|this| match placement {
Placement::Left => {
this.top_0().bottom_0().left_0().w_1_3().border_r_1()
}
Placement::Right => {
this.top_0().bottom_0().right_0().w_1_3().border_l_1()
}
Placement::Top => this.left_0().right_0().top_0().h_1_3().border_b_1(),
Placement::Bottom => {
this.left_0().right_0().bottom_0().h_1_3().border_t_1()
}
})
.bg(colors.surface)
.text_color(colors.surface_foreground)
.border_color(colors.border)
.p(spacing.lg)
.child(sheet.content.clone()),
),
)
}
fn toast_layer(
&self,
colors: &ColorTokens,
radius: &RadiusTokens,
spacing: &SpacingTokens,
cx: &mut Context<Self>,
) -> impl IntoElement {
// Cloned out of the manager first: building the items needs `cx`
// mutably for the dismissal listeners.
let items = self
.toasts
.visible(TOAST_VISIBLE_LIMIT)
.map(|(id, toast, status)| (id.clone(), toast.clone(), status))
.collect::<Vec<_>>();
deferred(
items
.into_iter()
.fold(
ToastStack::new("shell-toasts", self.toast_state.clone()),
|stack, (id, toast, status)| {
let dismiss_id = id.clone();
stack.item(
id.clone(),
Toast::new(id)
.transition_status(status)
.occlude()
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.remove_toast(dismiss_id.clone(), cx);
}))
.v_flex()
.gap(spacing.xxs)
.p(spacing.md)
.rounded(radius.md)
.bg(colors.surface)
.text_color(colors.surface_foreground)
.border_1()
.border_color(level_color(toast.level, colors))
.child(toast.title.clone())
.children(toast.description.clone().map(|description| {
div().text_color(colors.muted_foreground).child(description)
})),
)
},
)
.placement(Anchor::TopRight)
.focus_handle(self.toast_focus_handle.clone())
.v_flex()
.absolute()
.top(spacing.lg)
.right(spacing.lg)
.w(TOAST_WIDTH),
)
.with_priority(TOAST_PRIORITY)
}
}
impl Drop for ShellRoot {
fn drop(&mut self) {
if let Some(application) = self.application_generation.take() {
crate::engine::quickjs::cancel_application_tasks(&application);
}
if let Some(policy) = self.application_policy.take() {
crate::engine::quickjs::cancel_policy_tasks(&policy);
}
}
}
impl ShellRoot {
/// Clears the keyboard when a press lands on nothing that wants it.
///
/// Neither Base nor GPUI owns this: a text field takes focus when it is
/// pressed and keeps it until something else asks, so clicking the page
/// beside it leaves a caret blinking in a field the pointer has left. The
/// window is the only scope that can answer "nothing here wants the
/// keyboard", and in a shell application this element is the window.
///
/// The test for *did this press land on something focusable* is not a new
/// one. GPUI transfers focus to a `track_focus` element from a bubble-phase
/// mouse-down listener and calls `prevent_default` when it does, precisely
/// so an outer focusable ancestor does not steal it back. The root is the
/// outermost element, so its own bubble listener runs after every
/// descendant's, and that flag is already the answer.
///
/// Two things it deliberately does not do. It does not blur when a focus
/// trap is up: a modal owns the keyboard for as long as it is open, and a
/// press on its scrim is a dismissal for the modal to interpret, not a
/// reason to strand the trap with nothing focused. And it does nothing at
/// all when nothing is focused, because `Window::blur` refreshes the window
/// whether or not it changed anything, and every background click would
/// otherwise cost a frame.
fn blur_on_background_press(
_: &mut Self,
_: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if window.default_prevented() {
return;
}
if window.focused(cx).is_none() {
return;
}
if active_focus_trap(window, cx).is_some() {
return;
}
window.blur(cx);
}
}
/// Gives the keyboard back when the element holding it went away.
///
/// A pressed control takes focus, and the press can remove it: a button that
/// closes the panel it is in, or one that turns disabled and stops tracking
/// focus. GPUI is then focused on nothing it rendered and sends every key to
/// the window root alone, so each action bound further in stops answering
/// until the next press. Focus goes to the nearest tab stop left on the path
/// it was on (`Window::focus_lost_restore_target`).
///
/// Only when the focused element went away. A deliberate blur, such as a
/// press on the background or a component clearing focus, leaves focus on
/// nothing as well, and that has to stay. A blur before this frame left
/// nothing focused when the root rendered, so nothing is restored. A blur
/// during the frame left the blurred element rendered, and when it is a tab
/// stop it is its own target, which is not restored either.
fn restore_lost_focus(
focused_at_render: &RefCell<Option<WeakFocusHandle>>,
window: &mut Window,
cx: &mut App,
) {
let Some(previous) = focused_at_render.borrow_mut().take() else {
return;
};
let Some(target) = window.focus_lost_restore_target(cx) else {
return;
};
if previous
.upgrade()
.is_some_and(|previous| previous == target)
{
return;
}
window.focus(&target, cx);
}
impl Render for ShellRoot {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
*self.focused_at_render.borrow_mut() = window.focused(cx).map(|focus| focus.downgrade());
let tokens = Theme::global(cx).tokens;
let (colors, radius, spacing) = (tokens.colors, tokens.radius, tokens.spacing);
div()
.id("shell-root")
// The window's base text size, from the theme rather than from
// GPUI's default rem.
//
// Everything this root draws itself -- toasts, the sheet, the
// dialog scrim's chrome -- states no size of its own and inherits
// this one. Without it that chrome sat at GPUI's 16px default
// while the components an application builds set their own sizes,
// so a dense application drawn at 12px got 16px notifications over
// it. `md` is the base by the library's own convention: it is what
// `gpui_component::Theme` already takes its `font_size` from.
//
// The default `md` is that same 16px, so a theme that says nothing
// about type is drawn exactly as it was before this existed.
.text_size(tokens.typography.md.size)
.key_context(CONTEXT)
.on_action(cx.listener(Self::on_action_tab))
.on_action(cx.listener(Self::on_action_tab_prev))
.on_action(cx.listener(|_, _: &Copy, window, cx| {
let text = TextSelection::selected_text(window, cx).trim().to_owned();
if text.is_empty() {
cx.propagate();
} else {
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
}))
.on_mouse_down(
MouseButton::Left,
cx.listener(Self::blur_on_background_press),
)
.relative()
.size_full()
.bg(colors.background)
.text_color(colors.foreground)
// Painted back to front; see the stacking order on `ShellRoot`.
.child(TextSelectionLayer)
.child(
self.content
.clone()
.cached(StyleRefinement::default().size_full()),
)
.children(self.sheet_layer(&colors, &spacing, cx))
.children(self.dialog_layer(&colors, &radius, &spacing, cx))
.child(self.toast_layer(&colors, &radius, &spacing, cx))
.child(self.tooltip_overlay.clone())
.children(self.fps_layer(window, cx))
}
}
/// How long a tooltip takes to slide and fade in.
const TOOLTIP_ENTER: Duration = Duration::from_millis(150);
/// How long the box takes to travel when the pointer moves straight from one
/// trigger to the next one beside it.
const TOOLTIP_SLIDE: Duration = Duration::from_millis(200);
/// How far apart two triggers may sit vertically and still count as the same
/// row. Past that the box is somewhere else entirely and sliding it there reads
/// as a stray element rather than as one label following the pointer.
const TOOLTIP_SAME_ROW: gpui::Pixels = px(10.);
/// What an appearing tooltip does on its way in.
///
/// The whole of the root's presentation for the layer: base decides *when* a
/// tooltip is up and where the box goes, and hands back the transition it is
/// in. Enter slides up and fades; a switch between two triggers on one row
/// slides across, because the box that was already up is the same box.
fn render_tooltip(
content: AnyView,
transition: TooltipTransition,
_: &mut Window,
_: &mut App,
) -> AnyElement {
div().child(content).map(|element| match transition {
TooltipTransition::Switch {
epoch,
previous,
current,
} => {
if (current.origin.y - previous.origin.y).abs() >= TOOLTIP_SAME_ROW {
return element.into_any_element();
}
let travelled = current.center().x - previous.center().x;
EffectTransition::new(TOOLTIP_SLIDE)
.ease(ease_in_out_cubic)
.slide_x(-travelled, px(0.))
.apply(
element,
ElementId::NamedInteger("shell-tooltip-slide".into(), epoch as u64),
)
.into_any_element()
}
TooltipTransition::Enter { epoch } => EffectTransition::new(TOOLTIP_ENTER)
.ease(ease_out_cubic)
.slide_y(px(4.), px(0.))
.fade(0.0, 1.0)
.apply(
element,
ElementId::NamedInteger("shell-tooltip-enter".into(), epoch as u64),
)
.into_any_element(),
})
}
/// How a dialog may be dismissed.
///
/// Both default to `true`. A dialog that refuses dismissal is asking a question
/// the user has to answer, which is rare enough that it should be spelled out
/// at the call site.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct DialogOptions {
escape_dismissable: bool,
backdrop_dismissable: bool,
}
impl Default for DialogOptions {
fn default() -> Self {
Self {
escape_dismissable: true,
backdrop_dismissable: true,
}
}
}
impl DialogOptions {
/// Sets whether Escape closes this dialog while it is topmost.
pub fn escape_dismissable(mut self, dismissable: bool) -> Self {
self.escape_dismissable = dismissable;
self
}
/// Sets whether pressing the backdrop closes this dialog.
pub fn backdrop_dismissable(mut self, dismissable: bool) -> Self {
self.backdrop_dismissable = dismissable;
self
}
/// Whether Escape closes this dialog.
pub fn is_escape_dismissable(self) -> bool {
self.escape_dismissable
}
/// Whether a backdrop press closes this dialog.
pub fn is_backdrop_dismissable(self) -> bool {
self.backdrop_dismissable
}
}
/// How urgent a toast is.
///
/// Kept to four levels because that is the set a script can name in
/// `cx.toast({ level: "…" })`, and because the base palette has semantic colors
/// for roughly that many distinctions.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum ToastLevel {
#[default]
Info,
Success,
Warning,
Error,
}
impl ToastLevel {
/// The name a script uses, in `cx.toast({ level: "success" })`.
pub fn as_str(self) -> &'static str {
match self {
ToastLevel::Info => "info",
ToastLevel::Success => "success",
ToastLevel::Warning => "warning",
ToastLevel::Error => "error",
}
}
/// Parses a script-supplied name; `None` for an unknown one, so the binding
/// layer reports it instead of quietly downgrading an error to info.
pub fn from_name(name: &str) -> Option<Self> {
match name {
"info" => Some(ToastLevel::Info),
"success" => Some(ToastLevel::Success),
"warning" => Some(ToastLevel::Warning),
"error" => Some(ToastLevel::Error),
_ => None,
}
}
}
/// One toast to post.
///
/// A value rather than a view: a toast is a sentence, not a layout, and keeping
/// it data is what lets the root own the stack's geometry and lifecycle without
/// asking the script to render anything.
#[derive(Clone, Debug)]
pub struct ToastRequest {
title: SharedString,
description: Option<SharedString>,
level: ToastLevel,
/// `None` means the toast stays until it is dismissed.
timeout: Option<Duration>,
/// A caller-chosen identity. Pushing the same id twice replaces the toast
/// rather than stacking a duplicate, which is what makes a repeated "Saved"
/// read as one event.
id: Option<SharedString>,
}
impl ToastRequest {
/// A toast with the default level and timeout.
pub fn new(title: impl Into<SharedString>) -> Self {
Self {
title: title.into(),
description: None,
level: ToastLevel::default(),
timeout: Some(Self::DEFAULT_TIMEOUT),
id: None,
}
}
/// How long a toast stays before retiring itself, unless overridden. Long
/// enough to read one sentence, short enough not to accumulate.
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
/// Sets the secondary line shown under the title.
pub fn with_description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
/// Sets the urgency level.
pub fn with_level(mut self, level: ToastLevel) -> Self {
self.level = level;
self
}
/// Sets how long the toast stays; `None` keeps it until dismissed.
pub fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
self.timeout = timeout;
self
}
/// Sets the identity used to replace and to dismiss this toast.
pub fn with_id(mut self, id: impl Into<SharedString>) -> Self {
self.id = Some(id.into());
self
}
pub fn title(&self) -> &SharedString {
&self.title
}
pub fn description(&self) -> Option<&SharedString> {
self.description.as_ref()
}
pub fn level(&self) -> ToastLevel {
self.level
}
pub fn timeout(&self) -> Option<Duration> {
self.timeout
}
pub fn id(&self) -> Option<&SharedString> {
self.id.as_ref()
}
}
/// Binds root navigation and selected-text copy once per `App`.
fn install_key_bindings(cx: &mut App) {
if cx.has_global::<KeyBindingsInstalled>() {
return;
}
cx.bind_keys([
KeyBinding::new("tab", Tab, Some(CONTEXT)),
KeyBinding::new("shift-tab", TabPrev, Some(CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-c", Copy, Some(CONTEXT)),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-c", Copy, Some(CONTEXT)),
]);
cx.set_global(KeyBindingsInstalled);
}
/// Whether an overlay may be opened or closed right now.
///
/// Overlay changes mutate the window, so they are only legal from an `Event` or
/// `Task` call scope (`crate::scope`). Outside any scope — host Rust code, a
/// test — there is nothing to check. A `Render` or `Layout` scope is a script
/// bug: it is reported and ignored rather than panicked on, because a script
/// error must not take the window down (`docs/gpui-shell.md` §5.8).
/// Rebuilds a script overlay's description before it draws.
///
/// An overlay's content is a function, and what it closes over is somebody
/// else's state -- that is the contract `open_dialog` and `open_sheet`
/// document, and the only one they can have: neither answers a view handle, so
/// there is nothing for a script to notify when the state behind the closure
/// moves. Without this, an overlay materializes the description it was built
/// with, once, for as long as it is open: a dialog that looks up what someone
/// typed shows the answer to nothing.
///
/// So the root rebuilds it whenever the root itself draws, which is what
/// `window.refresh()` -- the call whose whole purpose is "there is no view to
/// notify" -- now reaches. Marking it dirty schedules no frame of its own: the
/// overlay is about to render as part of this one, and it renders from the
/// script rather than from the cache.
///
/// A non-script overlay owns its own state and is left alone.
fn rebuild_script_overlay(content: &AnyView, cx: &mut App) {
if let Ok(view) = content.clone().downcast::<ScriptView>() {
view.update(cx, |view, _| view.invalidate());
}
}
fn overlay_mutation_allowed(operation: &str) -> bool {
match scope::current_phase() {
None => true,
Some(phase) if phase.allows_notify() => true,
Some(phase) => {
tracing::warn!(
"`{operation}` is not allowed during the `{}` phase; \
overlays may only be opened or closed while handling an event or a task",
phase.as_str()
);
false
}
}
}
fn restore_focus(handle: Option<WeakFocusHandle>, window: &mut Window, cx: &mut App) {
if let Some(handle) = handle.and_then(|handle| handle.upgrade()) {
window.focus(&handle, cx);
}
}
/// Moves focus one step, staying inside the active focus trap.
///
/// GPUI's `focus_next` walks the whole window, so inside a trapped overlay it
/// can step past the last focusable child and into the content behind it. When
/// that happens, keep stepping until focus is back inside the trap — that is
/// the wrap-around — and give up if a full cycle finds nothing, so a trap with
/// no focusable child cannot spin.
fn cycle_focus(forward: bool, window: &mut Window, cx: &mut App) {
let step = |window: &mut Window, cx: &mut App| {
if forward {
window.focus_next(cx);
} else {
window.focus_prev(cx);
}
};
let Some(trap) = active_focus_trap(window, cx) else {
step(window, cx);
return;
};
let before = window.focused(cx);
step(window, cx);
// A generous bound on the focusable elements in one window; reaching it
// means the trap has none, not that the window is unusually deep.
const MAX_STEPS: usize = 100;
let mut steps = 0;
while !trap.contains_focused(window, cx) && steps < MAX_STEPS {
step(window, cx);
steps += 1;
if window.focused(cx) == before {
break;
}
}
}
/// The scrim behind a dialog or sheet.
///
/// A fixed translucent black rather than a token: `gpui_base`'s [`ColorTokens`]
/// has no overlay color, and every candidate token inverts with the palette —
/// `foreground` would veil a dark window in white. A scrim is a dimming, not a
/// palette entry.
fn backdrop_color() -> Hsla {
hsla(0., 0., 0., 0.5)
}
/// The border color that carries a toast's level.
///
/// Border rather than fill: the toast surface stays a surface, and the level is
/// an accent on it. `Warning` borrows the accent pair because the base palette
/// has no warning token — `destructive` is the only semantic status color it
/// defines.
fn level_color(level: ToastLevel, colors: &ColorTokens) -> Hsla {
match level {
ToastLevel::Info => colors.border,
ToastLevel::Success => colors.primary,
ToastLevel::Warning => colors.accent_foreground,
ToastLevel::Error => colors.destructive,
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Entity, TestAppContext, VisualTestContext};
struct Content;
impl Render for Content {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
}
}
fn shell_root(cx: &mut TestAppContext) -> (Entity<ShellRoot>, &mut VisualTestContext) {
cx.update(crate::init);
cx.add_window_view(|window, cx| {
let content = cx.new(|_| Content).into();
ShellRoot::new(content, window, cx)
})
}
fn view(cx: &mut VisualTestContext) -> AnyView {
cx.update(|_, cx| cx.new(|_| Content).into())
}
struct Field {
handle: FocusHandle,
}
impl Render for Field {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
// A focusable box in the top-left corner; everything else in the
// window is background that wants nothing.
div().size_full().child(
div()
.id("field")
.track_focus(&self.handle)
.w(px(100.))
.h(px(50.)),
)
}
}
fn shell_root_with_field(cx: &mut TestAppContext) -> (FocusHandle, &mut VisualTestContext) {
cx.update(crate::init);
let handle = std::rc::Rc::new(std::cell::RefCell::new(None));
let (_, cx) = cx.add_window_view({
let handle = handle.clone();
move |window, cx| {
let content = cx
.new(|cx| {
let focus = cx.focus_handle();
*handle.borrow_mut() = Some(focus.clone());
Field { handle: focus }
})
.into();
ShellRoot::new(content, window, cx)
}
});
cx.update(|window, cx| window.draw(cx).clear(cx));
let handle = handle.borrow().clone().expect("field focus handle");
(handle, cx)
}
#[gpui::test]
fn a_press_on_the_background_clears_the_keyboard(cx: &mut TestAppContext) {
let (field, cx) = shell_root_with_field(cx);
cx.update(|window, cx| window.focus(&field, cx));
cx.update(|window, cx| window.draw(cx).clear(cx));
assert!(cx.update(|window, _| field.is_focused(window)));
// Well clear of the field, on nothing that tracks focus.
cx.simulate_click(gpui::point(px(400.), px(400.)), gpui::Modifiers::default());
assert!(
!cx.update(|window, _| field.is_focused(window)),
"a press on the background must not leave a caret blinking in a field the pointer has left"
);
}
#[gpui::test]
fn a_press_on_a_focusable_element_does_not_clear_it(cx: &mut TestAppContext) {
let (field, cx) = shell_root_with_field(cx);
// Pressing the field focuses it, and pressing it again while it is
// already focused must not blur and refocus it: that churn is what
// would interrupt an IME composition and fire a spurious blur.
cx.simulate_click(gpui::point(px(10.), px(10.)), gpui::Modifiers::default());
assert!(cx.update(|window, _| field.is_focused(window)));
cx.simulate_click(gpui::point(px(20.), px(20.)), gpui::Modifiers::default());
assert!(
cx.update(|window, _| field.is_focused(window)),
"the root must leave focus alone when the press landed on something that took it"
);
}
/// A tab stop around a control that can be taken away, the way a panel's
/// close button goes with the panel.
struct Panel {
outer: FocusHandle,
control: FocusHandle,
shown: bool,
}
impl Render for Panel {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
.id("panel")
.track_focus(&self.outer)
.size_full()
.when(self.shown, |panel| {
panel.child(
div()
.id("control")
.track_focus(&self.control)
.w(px(100.))
.h(px(50.)),
)
})
}
}
fn shell_root_with_panel(cx: &mut TestAppContext) -> (Entity<Panel>, &mut VisualTestContext) {
cx.update(crate::init);
let panel = std::rc::Rc::new(std::cell::RefCell::new(None));
let (_, cx) = cx.add_window_view({
let panel = panel.clone();
move |window, cx| {
let content = cx.new(|cx| Panel {
outer: cx.focus_handle().tab_stop(true),
control: cx.focus_handle(),
shown: true,
});
*panel.borrow_mut() = Some(content.clone());
ShellRoot::new(content.into(), window, cx)
}
});
cx.update(|window, cx| window.draw(cx).clear(cx));
let panel = panel.borrow().clone().expect("panel");
let control = panel.read_with(cx, |panel, _| panel.control.clone());
cx.update(|window, cx| window.focus(&control, cx));
cx.update(|window, cx| window.draw(cx).clear(cx));
assert!(cx.update(|window, _| control.is_focused(window)));
(panel, cx)
}
#[gpui::test]
fn a_focused_control_that_goes_away_gives_the_keyboard_back(cx: &mut TestAppContext) {
let (panel, cx) = shell_root_with_panel(cx);
panel.update(cx, |panel, cx| {
panel.shown = false;
cx.notify();
});
cx.update(|window, cx| window.draw(cx).clear(cx));
let outer = panel.read_with(cx, |panel, _| panel.outer.clone());
assert!(
cx.update(|window, _| outer.is_focused(window)),
"the nearest tab stop left on the path must have the keyboard, or keys reach the \
window root alone"
);
}
#[gpui::test]
fn a_blur_is_not_undone(cx: &mut TestAppContext) {
let (panel, cx) = shell_root_with_panel(cx);
cx.update(|window, cx| window.blur(cx));
cx.update(|window, cx| window.draw(cx).clear(cx));
assert!(
cx.update(|window, cx| window.focused(cx)).is_none(),
"a blur leaves nothing focused, and the root must not put the focus back"
);
let _ = panel;
}
#[gpui::test]
fn closing_the_top_dialog_leaves_the_one_below(cx: &mut TestAppContext) {
let (root, cx) = shell_root(cx);
let (first, second) = (view(cx), view(cx));
root.update_in(cx, |root, window, cx| {
root.open_dialog(first.clone(), window, cx)
});
root.update_in(cx, |root, window, cx| {
root.open_dialog(second.clone(), window, cx)
});
assert_eq!(root.read_with(cx, |root, _| root.dialog_count()), 2);
assert!(root.update_in(cx, |root, window, cx| root.close_dialog(window, cx)));
assert_eq!(root.read_with(cx, |root, _| root.dialog_count()), 1);
assert_eq!(
root.read_with(cx, |root, _| root.topmost_dialog().map(|v| v.entity_id())),
Some(first.entity_id())
);
// The stack empties one dialog per close, and reports when it is done.
assert!(root.update_in(cx, |root, window, cx| root.close_dialog(window, cx)));
assert!(!root.update_in(cx, |root, window, cx| root.close_dialog(window, cx)));
}
#[gpui::test]
fn escape_closes_only_the_topmost_dialog(cx: &mut TestAppContext) {
let (root, cx) = shell_root(cx);
let (first, second) = (view(cx), view(cx));
root.update_in(cx, |root, window, cx| {
root.open_dialog(first.clone(), window, cx)
});
root.update_in(cx, |root, window, cx| root.open_dialog(second, window, cx));
cx.update(|window, cx| window.draw(cx).clear(cx));
cx.simulate_keystrokes("escape");
assert_eq!(root.read_with(cx, |root, _| root.dialog_count()), 1);
assert_eq!(
root.read_with(cx, |root, _| root.topmost_dialog().map(|v| v.entity_id())),
Some(first.entity_id())
);
}
#[gpui::test]
fn a_dialog_that_refuses_escape_stays_open(cx: &mut TestAppContext) {
let (root, cx) = shell_root(cx);
let content = view(cx);
root.update_in(cx, |root, window, cx| {
root.open_dialog_with(
content,
DialogOptions::default().escape_dismissable(false),
window,
cx,
)
});
cx.update(|window, cx| window.draw(cx).clear(cx));
cx.simulate_keystrokes("escape");
assert_eq!(root.read_with(cx, |root, _| root.dialog_count()), 1);
}
#[gpui::test]
fn closing_a_dialog_restores_the_previous_focus(cx: &mut TestAppContext) {
let (root, cx) = shell_root(cx);
let content = view(cx);
let before = cx.update(|window, cx| {
let handle = cx.focus_handle();
handle.focus(window, cx);
handle
});
root.update_in(cx, |root, window, cx| root.open_dialog(content, window, cx));
assert_ne!(
cx.update(|window, cx| window.focused(cx)),
Some(before.clone())
);
root.update_in(cx, |root, window, cx| root.close_dialog(window, cx));
assert_eq!(cx.update(|window, cx| window.focused(cx)), Some(before));
}
#[gpui::test]
fn a_nested_dialog_restores_focus_to_the_one_below(cx: &mut TestAppContext) {
let (root, cx) = shell_root(cx);
let (first, second) = (view(cx), view(cx));
root.update_in(cx, |root, window, cx| root.open_dialog(first, window, cx));
let outer_focus = cx.update(|window, cx| window.focused(cx));
root.update_in(cx, |root, window, cx| root.open_dialog(second, window, cx));
root.update_in(cx, |root, window, cx| root.close_dialog(window, cx));
assert_eq!(cx.update(|window, cx| window.focused(cx)), outer_focus);
}
#[gpui::test]
fn closing_all_dialogs_restores_the_focus_the_stack_started_from(cx: &mut TestAppContext) {
let (root, cx) = shell_root(cx);
let (first, second) = (view(cx), view(cx));
let before = cx.update(|window, cx| {
let handle = cx.focus_handle();
handle.focus(window, cx);
handle
});
root.update_in(cx, |root, window, cx| root.open_dialog(first, window, cx));
root.update_in(cx, |root, window, cx| root.open_dialog(second, window, cx));
root.update_in(cx, |root, window, cx| root.close_all_dialogs(window, cx));
assert_eq!(root.read_with(cx, |root, _| root.dialog_count()), 0);
assert_eq!(cx.update(|window, cx| window.focused(cx)), Some(before));
}
#[gpui::test]
fn a_sheet_is_replaced_rather_than_stacked(cx: &mut TestAppContext) {
let (root, cx) = shell_root(cx);
let (first, second) = (view(cx), view(cx));
let before = cx.update(|window, cx| {
let handle = cx.focus_handle();
handle.focus(window, cx);
handle
});
root.update_in(cx, |root, window, cx| {
root.open_sheet(Placement::Right, first, window, cx)
});
root.update_in(cx, |root, window, cx| {
root.open_sheet(Placement::Left, second.clone(), window, cx)
});
assert_eq!(
root.read_with(cx, |root, _| root.sheet().map(|v| v.entity_id())),
Some(second.entity_id())
);
// The replacement inherited the first sheet's restore target, so one
// close returns to the window rather than to the sheet it replaced.
assert!(root.update_in(cx, |root, window, cx| root.close_sheet(window, cx)));
assert_eq!(cx.update(|window, cx| window.focused(cx)), Some(before));
assert!(!root.update_in(cx, |root, window, cx| root.close_sheet(window, cx)));
}
/// Every layer painting at once is the case the stacking order exists for,
/// and the one where a duplicated element id or a missing hitbox shows up.
#[gpui::test]
fn every_layer_draws_together(cx: &mut TestAppContext) {
let (root, cx) = shell_root(cx);
let (sheet, first, second) = (view(cx), view(cx), view(cx));
root.update_in(cx, |root, window, cx| {
root.open_sheet(Placement::Right, sheet, window, cx);
root.open_dialog(first, window, cx);
root.open_dialog(second, window, cx);
root.push_toast(ToastRequest::new("Saved"), window, cx);
});
cx.update(|window, cx| window.draw(cx).clear(cx));
assert_eq!(root.read_with(cx, |root, _| root.dialog_count()), 2);
assert!(root.read_with(cx, |root, _| root.sheet().is_some()));
assert_eq!(root.read_with(cx, |root, _| root.toast_count()), 1);
}
#[gpui::test]
fn a_dismissed_toast_is_unmounted_once_its_exit_completes(cx: &mut TestAppContext) {
let (root, cx) = shell_root(cx);
root.update_in(cx, |root, window, cx| {
root.push_toast(ToastRequest::new("Saved").with_id("saved"), window, cx)
});
assert_eq!(root.read_with(cx, |root, _| root.toast_count()), 1);
assert!(root.update(cx, |root, cx| root.remove_toast("saved", cx)));
// Still mounted: dismissal starts the exit transition.
assert_eq!(root.read_with(cx, |root, _| root.toast_count()), 1);
cx.executor().advance_clock(Duration::from_secs(1));
cx.run_until_parked();
assert_eq!(root.read_with(cx, |root, _| root.toast_count()), 0);
}
#[gpui::test]
fn a_toast_retires_itself_when_its_timeout_elapses(cx: &mut TestAppContext) {
let (root, cx) = shell_root(cx);
cx.update(|window, _| window.activate_window());
cx.run_until_parked();
root.update_in(cx, |root, window, cx| {
root.push_toast(
ToastRequest::new("Saved").with_timeout(Some(Duration::from_secs(1))),
window,
cx,
)
});
cx.executor().advance_clock(Duration::from_secs(2));
cx.run_until_parked();
assert_eq!(root.read_with(cx, |root, _| root.toast_count()), 0);
}
#[gpui::test]
fn a_toast_expires_while_the_window_is_in_the_background(cx: &mut TestAppContext) {
let (root, cx) = shell_root(cx);
root.update_in(cx, |root, window, cx| {
assert!(!window.is_window_active());
root.push_toast(
ToastRequest::new("Saved").with_timeout(Some(Duration::from_secs(1))),
window,
cx,
)
});
cx.executor().advance_clock(Duration::from_secs(10));
cx.run_until_parked();
assert_eq!(root.read_with(cx, |root, _| root.toast_count()), 0);
}
#[gpui::test]
fn pushing_the_same_toast_id_replaces_it(cx: &mut TestAppContext) {
let (root, cx) = shell_root(cx);
for _ in 0..3 {
root.update_in(cx, |root, window, cx| {
root.push_toast(ToastRequest::new("Saved").with_id("saved"), window, cx)
});
}
assert_eq!(root.read_with(cx, |root, _| root.toast_count()), 1);
}
#[gpui::test]
fn toasts_pushed_without_an_id_stack(cx: &mut TestAppContext) {
let (root, cx) = shell_root(cx);
for _ in 0..3 {
root.update_in(cx, |root, window, cx| {
root.push_toast(ToastRequest::new("Saved"), window, cx)
});
}
assert_eq!(root.read_with(cx, |root, _| root.toast_count()), 3);
}
#[test]
fn script_facing_names_round_trip() {
for level in [
ToastLevel::Info,
ToastLevel::Success,
ToastLevel::Warning,
ToastLevel::Error,
] {
assert_eq!(ToastLevel::from_name(level.as_str()), Some(level));
}
assert!(ToastLevel::from_name("fatal").is_none());
}
#[test]
fn toast_request_builder_keeps_every_field() {
let toast = ToastRequest::new("Saved")
.with_description("3 files written")
.with_level(ToastLevel::Success)
.with_timeout(None)
.with_id("saved");
assert_eq!(toast.title(), "Saved");
assert_eq!(
toast.description().map(|d| d.as_ref()),
Some("3 files written")
);
assert_eq!(toast.level(), ToastLevel::Success);
assert_eq!(toast.timeout(), None);
assert_eq!(toast.id().map(|id| id.as_ref()), Some("saved"));
assert_eq!(
ToastRequest::new("Saved").timeout(),
Some(ToastRequest::DEFAULT_TIMEOUT)
);
}
#[test]
fn dialog_options_default_to_dismissable() {
let options = DialogOptions::default();
assert!(options.is_escape_dismissable());
assert!(options.is_backdrop_dismissable());
let options = options
.escape_dismissable(false)
.backdrop_dismissable(false);
assert!(!options.is_escape_dismissable());
assert!(!options.is_backdrop_dismissable());
}
}