widgets: modal backdrop dismissal + title-row close button
CI / orchestrate (push) Canceled after 0s
CI / fmt (push) Canceled after 0s
CI / clippy (push) Canceled after 0s
CI / typos (push) Canceled after 0s
CI / taplo (push) Canceled after 0s
CI / lockfile (push) Canceled after 0s
CI / machete (push) Canceled after 0s
CI / msrv (push) Canceled after 0s
CI / check-wasm (push) Canceled after 0s
CI / test-linux (push) Canceled after 0s
CI / test-mac (push) Canceled after 0s
CI / test-windows (push) Canceled after 0s
CI / check-examples (push) Canceled after 0s
CI / beta-test (push) Canceled after 0s
CI / tests-pass (push) Canceled after 0s

The Dismissed doc always said "escape or backdrop" but the backdrop
never emitted it: the mask had no click handler and the card no close
affordance, so a buttonless dialog (Help > Search Actions) could not be
closed by mouse at all.

- ModalOptions::dismiss_on_mask (opt-in): a backdrop click emits
  Dismissed; the card stops propagation so content clicks stay inside.
- A ✕ button in the title row emits Dismissed on every modal (same
  semantics as Escape).
This commit is contained in:
2026-08-25 01:08:34 +08:00
parent 79b3a8fbd4
commit 7497035c12
+53 -1
View File
@@ -64,6 +64,10 @@ pub struct ModalOptions {
pub width: gpui::Pixels,
/// The buttons in the footer row.
pub buttons: Vec<DialogButton>,
/// Whether a click on the dimmed backdrop dismisses the dialog
/// (launcher-style dialogs opt in; form dialogs keep the accidental-
/// click protection).
pub dismiss_on_mask: bool,
}
impl ModalOptions {
@@ -73,6 +77,7 @@ impl ModalOptions {
title: title.into(),
width,
buttons: Vec::new(),
dismiss_on_mask: false,
}
}
@@ -81,6 +86,13 @@ impl ModalOptions {
self.buttons.push(button);
self
}
/// Allow a backdrop click to dismiss the dialog (emits
/// [`ModalEvent::Dismissed`] like Escape does).
pub fn with_dismiss_on_mask(mut self, enabled: bool) -> Self {
self.dismiss_on_mask = enabled;
self
}
}
/// A request emitted by a modal dialog.
@@ -175,6 +187,18 @@ impl Render for Modal {
})
.occlude()
.block_mouse_except_scroll()
// Backdrop click dismisses when the dialog opts in (the card
// below stops propagation, so only the true backdrop hits this).
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(move |this, _event: &gpui::MouseDownEvent, _window, cx| {
if this.options.dismiss_on_mask {
let control = this.control;
cx.emit(ModalEvent::Dismissed { control });
cx.notify();
}
}),
)
.flex()
.items_center()
.justify_center()
@@ -204,6 +228,11 @@ impl Render for Modal {
.bg(colors.container)
.debug_selector(|| "dialog-card".into())
.shadow_lg()
// Clicks inside the card never reach the backdrop's
// dismiss handler.
.on_mouse_down(gpui::MouseButton::Left, |_event, _window, cx| {
cx.stop_propagation();
})
.flex()
.flex_col()
.child(
@@ -213,7 +242,30 @@ impl Render for Modal {
.border_b_1()
.border_color(colors.border)
.text_color(colors.text)
.child(self.options.title.clone()),
.flex()
.items_center()
.child(div().flex_1().child(self.options.title.clone()))
.child(
// The universal close affordance (emits
// Dismissed, same as Escape).
div()
.id(ElementId::named_usize("gpui-widgets-modal-close", control))
.debug_selector(|| "dialog-close".into())
.px_1()
.rounded_sm()
.cursor_pointer()
.text_color(colors.disabled)
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(move |this, _event, _window, cx| {
let control = this.control;
cx.emit(ModalEvent::Dismissed { control });
cx.notify();
cx.stop_propagation();
}),
)
.child(""),
),
)
.child(
div()