diff --git a/crates/core/src/dialog.rs b/crates/core/src/dialog.rs new file mode 100644 index 0000000..d99277f --- /dev/null +++ b/crates/core/src/dialog.rs @@ -0,0 +1,265 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + Animation, AnimationExt as _, AnyElement, App, Bounds, ElementId, FontWeight, + InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, + ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, + Styled as _, Window, canvas, div, ease_out_quint, relative, +}; +use gpui_native_theme::ActiveTheme as _; + +use crate::common::v_flex; + +/// Requests the dialog's close: `false` closes. +pub type ChangeHandler = Rc; +type ContentFn = Box AnyElement>; +type ActionsFn = Box Vec>; +/// Bounds captured during the last paint, shared with the scrim listener. +type BoundsCell = Rc>>>; + +/// A centred panel over a dim scrim: the platform's modal. +#[derive(IntoElement)] +pub struct Dialog { + id: ElementId, + open: bool, + alert: bool, + title: Option, + a11y_label: Option, + on_change: Option, + content: Option, + actions: Option, +} + +impl Dialog { + /// Creates a closed dialog. + pub fn new(id: I) -> Self + where + I: Into, + { + Self { + id: id.into(), + open: false, + alert: false, + title: None, + a11y_label: None, + on_change: None, + content: None, + actions: None, + } + } + + /// Sets the current open state. + pub fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + /// Sets the alert mode. + pub fn alert(mut self, alert: bool) -> Self { + self.alert = alert; + self + } + + /// Requests close through `on_change` on escape and scrim clicks. + pub fn on_change(mut self, handler: F) -> Self + where + F: Fn(bool, &mut Window, &mut App) + 'static, + { + self.on_change = Some(Rc::new(handler)); + self + } + + /// Sets the dialog's title. + pub fn title(mut self, title: L) -> Self + where + L: Into, + { + self.title = Some(title.into()); + self + } + + /// Sets the body content between the title and the actions. + pub fn content(mut self, content: F) -> Self + where + F: Fn(&mut Window, &mut App) -> AnyElement + 'static, + { + self.content = Some(Box::new(content)); + self + } + + /// Sets the footer actions, laid out right-aligned in the given order. + pub fn actions(mut self, actions: F) -> Self + where + F: Fn(&mut Window, &mut App) -> Vec + 'static, + { + self.actions = Some(Box::new(actions)); + self + } + + /// Overrides the accessibility label of the panel. + pub fn accessibility_label(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } +} + +impl RenderOnce for Dialog { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let root = div().absolute().inset_0(); + + if !self.open { + return root; + } + + let ( + scrim_color, + border, + foreground, + muted_foreground, + surface, + radius, + shadows, + title_size, + enter_duration, + ) = { + let theme = cx.theme(); + ( + theme.colors.overlay, + theme.colors.border, + theme.colors.foreground, + theme.colors.muted_foreground, + theme.colors.surface, + theme.radius.overlay, + theme.elevations.dialog.shadows(), + theme.sizes.medium.text_size, + theme.motion.overlay, + ) + }; + + let role = if self.alert { + Role::AlertDialog + } else { + Role::Dialog + }; + + let label = self + .a11y_label + .clone() + .or_else(|| self.title.clone()) + .unwrap_or_else(|| SharedString::from(self.id.to_string())); + + let on_change = self.on_change.clone(); + let alert = self.alert; + + // Keyboard focus rides the panel across frames. + let focus_handle = window.use_keyed_state("dialog-focus", cx, |_, cx| cx.focus_handle()); + + let content = self.content.map(|build| build(window, cx)); + let actions = self.actions.map(|build| build(window, cx)); + + let panel_bounds: BoundsCell = Rc::new(RefCell::new(None)); + + let panel = v_flex() + .id("dialog-panel") + .track_focus(&focus_handle.read(cx).clone()) + .role(role) + .aria_label(label) + .min_w_64() + .max_w(relative(0.9)) + .p_4() + .gap_3() + .bg(surface) + .text_color(foreground) + .rounded(radius) + .border_1() + .border_color(border) + .shadow(shadows) + .overflow_hidden() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_key_down({ + let on_change = on_change.clone(); + move |event: &KeyDownEvent, window, cx| { + if event.keystroke.key == "escape" + && !alert + && let Some(on_change) = &on_change + { + on_change(false, window, cx); + } + cx.stop_propagation(); + } + }) + .when_some(self.title, |panel, title| { + panel.child( + div() + .text_size(title_size) + .font_weight(FontWeight::SEMIBOLD) + .child(title), + ) + }) + .when_some(content, |panel, content| { + panel.child(div().text_color(muted_foreground).child(content)) + }) + .when_some(actions, |panel, actions| { + panel.child(div().flex().justify_end().gap_2().children(actions)) + }) + .child( + canvas( + { + let cell = panel_bounds.clone(); + move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds) + }, + { + let focus_handle = focus_handle.clone(); + move |_, _, window, cx| { + let handle = focus_handle.read(cx).clone(); + if !handle.contains_focused(window, cx) { + window.focus(&handle, cx); + } + } + }, + ) + .absolute() + .inset_0(), + ); + + let scrim = div() + .absolute() + .inset_0() + .bg(scrim_color) + .occlude() + .when(!alert, |scrim| { + scrim.on_mouse_down(MouseButton::Left, { + let panel_bounds = panel_bounds.clone(); + let on_change = on_change.clone(); + move |event: &MouseDownEvent, _window, cx| { + let on_panel = panel_bounds + .borrow() + .is_some_and(|bounds| bounds.contains(&event.position)); + if on_panel { + return; + } + if let Some(on_change) = &on_change { + on_change(false, _window, cx); + } + cx.stop_propagation(); + } + }) + }); + + root.flex() + .items_center() + .justify_center() + .p_4() + .child(scrim) + .child(panel.with_animation( + "dialog-enter", + Animation::new(enter_duration).with_easing(ease_out_quint()), + |panel, delta| panel.opacity(delta), + )) + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 531d620..72f86f0 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -36,6 +36,9 @@ pub mod avatar; #[cfg(feature = "input")] pub mod input; +#[cfg(feature = "dialog")] +pub mod dialog; + #[cfg(feature = "menu")] pub mod menu; diff --git a/crates/core/tests/dialog.rs b/crates/core/tests/dialog.rs new file mode 100644 index 0000000..787d55d --- /dev/null +++ b/crates/core/tests/dialog.rs @@ -0,0 +1,168 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use gpui::{ + Context, IntoElement, Modifiers, MouseButton, ParentElement as _, Render, Styled as _, + TestAppContext, VisualTestContext, Window, div, point, px, +}; +use gpui_native_core::button::{Button, ButtonVariant}; +use gpui_native_core::dialog::Dialog; +use gpui_native_theme::Theme; + +struct Harness { + open: bool, + alert: bool, + changes: Rc>>, +} + +impl Render for Harness { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let changes = self.changes.clone(); + let view = cx.entity(); + let on_change_view = view.clone(); + let close_changes = self.changes.clone(); + let close_view = view.clone(); + div().size_full().child( + Dialog::new("test-dialog") + .open(self.open) + .alert(self.alert) + .title("Delete file?") + .on_change(move |next, _, cx| { + eprintln!("HARNESS on_change: {next}"); + changes.borrow_mut().push(next); + on_change_view.update(cx, |harness, cx| { + harness.open = next; + cx.notify(); + }); + }) + .content(|_, _| { + div() + .w(px(300.)) + .h(px(80.)) + .child("This cannot be undone.") + .into_any_element() + }) + .actions(move |_, _| { + vec![ + Button::new("dialog-cancel") + .label("Cancel") + .variant(ButtonVariant::Secondary) + .on_click({ + let close_changes = close_changes.clone(); + let close_view = close_view.clone(); + move |_, _, cx| { + close_changes.borrow_mut().push(false); + close_view.update(cx, |harness, cx| { + harness.open = false; + cx.notify(); + }); + } + }) + .into_any_element(), + Button::new("dialog-delete") + .label("Delete") + .variant(ButtonVariant::Destructive) + .on_click({ + let close_changes = close_changes.clone(); + let close_view = close_view.clone(); + move |_, _, cx| { + close_changes.borrow_mut().push(false); + close_view.update(cx, |harness, cx| { + harness.open = false; + cx.notify(); + }); + } + }) + .into_any_element(), + ] + }), + ) + } +} + +/// An open 332px-wide dialog centered in the window: the panel spans +/// roughly `(794..1126, 448..640)`, so `(100, 100)` is on the scrim and +/// the window center is on the panel. +fn dialog_window( + open: bool, + alert: bool, +) -> (TestAppContext, VisualTestContext, Rc>>) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let changes = Rc::new(RefCell::new(Vec::new())); + let harness_changes = changes.clone(); + let window = cx.add_window(move |_, _| Harness { + open, + alert, + changes: harness_changes, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + (cx, visual, changes) +} + +fn draw(visual: &mut VisualTestContext) { + visual.update(|window, cx| { + eprintln!("DRAW"); + window.draw(cx).clear(cx); + }); +} + +fn click(visual: &mut VisualTestContext, position: gpui::Point) { + visual.simulate_mouse_down(position, MouseButton::Left, Modifiers::default()); + visual.simulate_mouse_up(position, MouseButton::Left, Modifiers::default()); +} + +#[test] +fn escape_closes_a_modal_dialog_and_stays_closed() { + let (_cx, mut visual, changes) = dialog_window(true, false); + + visual.simulate_keystrokes("escape"); + assert_eq!(*changes.borrow(), vec![false]); + draw(&mut visual); + + // Closed: escape on the inert remainder reports nothing further. + visual.simulate_keystrokes("escape"); + assert_eq!(*changes.borrow(), vec![false]); +} + +#[test] +fn escape_is_inert_on_an_alert() { + let (_cx, mut visual, changes) = dialog_window(true, true); + visual.simulate_keystrokes("escape"); + assert!(changes.borrow().is_empty()); +} + +#[test] +fn scrim_click_closes_a_modal_but_not_an_alert() { + let (_cx, mut visual, changes) = dialog_window(true, false); + click(&mut visual, point(px(100.), px(100.))); + assert_eq!(*changes.borrow(), vec![false]); + + draw(&mut visual); + click(&mut visual, point(px(100.), px(100.))); + assert_eq!(*changes.borrow(), vec![false]); + + // The same click on an alert's scrim answers nothing. + let (_cx, mut visual, changes) = dialog_window(true, true); + click(&mut visual, point(px(100.), px(100.))); + assert!(changes.borrow().is_empty()); +} + +#[test] +fn clicking_the_panel_does_not_dismiss() { + // The window center is where the centered panel sits. + let (_cx, mut visual, changes) = dialog_window(true, false); + click(&mut visual, point(px(960.), px(540.))); + assert!(changes.borrow().is_empty()); +} + +#[test] +fn a_closed_dialog_is_inert() { + let (_cx, mut visual, changes) = dialog_window(false, false); + visual.simulate_keystrokes("escape"); + click(&mut visual, point(px(100.), px(100.))); + assert!(changes.borrow().is_empty()); +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 7b792dc..a7c44ff 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -212,7 +212,7 @@ Each row is one review unit: build it, show it in the gallery, review, then move | 11.5 | core | ScrollArea + Scrollbar | ✅ Landed (2026-10-29). Inserted before the remaining overlays — Menus, Lists and Tables all scroll. gpui owns the mechanics (`overflow_*_scroll` + caller-held `ScrollHandle`); the component paints an overlay thumb (`scrollbar_thumb` token, `Scrollbar` metrics) with drag and geometry unit-tested. Wheel input is gpui's; the area stops wheel propagation so nested areas don't co-scroll. Horizontal thumb deferred; auto-hide on idle deferred | | 12 | core | Menu | ✅ Landed (2026-10-29). Controlled `open`/`on_change` + `trigger`/`items` (MenuItem: label, icon, shortcut hint, disabled, destructive, checked, separator). Popover shell patterns duplicated per D16 (promote at Dialog); panel = surface + overlay radius + popover elevation, role Menu; claims focus while open; keyboard nav — arrows/home/end over selectable items (wrapping, skipping separators + disabled), enter/space activates (closes menu before the action), escape dismisses; hover selects, click activates; trigger clicks toggle (popover-owned); selection resets on each open; long menus scroll via the ScrollArea once a height is styled. Deferred: submenus, type-ahead, scroll-selected-item-into-view, real shortcut handling | | 11 | core | Popover | ✅ Landed (2026-10-29). First overlay. Controlled `open` + `on_change` (Rc-shared handler); `trigger`/`content` element closures; panel = surface + `Radius.overlay` + `Elevations.popover` + border, role Dialog with label; `anchored()` + `deferred()` (window-overflow flip for free); enter fade via gpui `with_animation` at `Motion.overlay` (respects reduce-motion); claims keyboard focus while open; escape and click-outside dismiss (outside clicks swallowed). No scrim — native macOS popover model; the overlay token waits for Dialog. Known v1 gaps: no exit animation, trigger aria-expanded left to the app | -| 11+ | core | Menu ✅ (2026-10-29), Tooltip ✅ (2026-10-29), Dialog, AlertDialog, Sheet, Toast | One unit each, same gate. Menu reuses the popover shell + `Radius.overlay`/`Elevations.popover`; Dialog consumes `colors.overlay` (scrim) + `Elevations.dialog`. Tooltip rides gpui's own `.tooltip()` lifecycle (hover delay, anchoring, dismissal — all framework-owned); ours is the styled view (`surface` + border + `Radius.sm`, small text) plus a `Tooltip::text` closure and a Button passthrough | +| 11+ | core | Tooltip ✅ (2026-10-29), Dialog + AlertDialog ✅ (2026-10-29), Toast | One unit each, same gate. Tooltip rides gpui's own `.tooltip()` lifecycle (hover delay, anchoring, dismissal — all framework-owned); ours is the styled view (`surface` + border + `Radius.sm`, small text) plus a `Tooltip::text` closure and a Button passthrough. Dialog + AlertDialog (one component, `alert(true)` switches semantics): scrim (`colors.overlay`, occluding) + centered panel (surface, `Radius.overlay`, `Elevations.dialog`), role Dialog/AlertDialog, focus claimed while open; modal kind closes on escape + scrim click, alert kind is inert to both (the user answers through the actions); actions footer is caller-built elements closing through owner state. Sheet dropped from the roadmap. Toast lands `success`/`warning` tokens when it comes | | then | data (new) | List, Table, Tree, Tabs, Sidebar | Created on demand, same gate. | | then | pickers or data | Calendar/DatePicker, TimeField, ColorPicker | Decide at unit time. | | editor | editor (new) | Text/code editor, syntax highlighting (D12) | Same gate. | diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index f5519be..c97644a 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -8,6 +8,7 @@ use gpui_native_core::avatar::Avatar; use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::checkbox::Checkbox; use gpui_native_core::common::{Sizable as _, h_flex, v_flex}; +use gpui_native_core::dialog::Dialog; use gpui_native_core::input::{Input, InputState}; use gpui_native_core::link::Link; use gpui_native_core::menu::{Menu, MenuItem}; @@ -33,6 +34,8 @@ struct Gallery { input_state: Entity, popover_open: bool, menu_open: bool, + dialog_open: bool, + alert_open: bool, scroll: gpui::ScrollHandle, } @@ -332,6 +335,42 @@ impl Render for Gallery { }) }), ) + .child( + v_flex() + .gap_2() + .child(section_label( + "dialog · alert · native refs — NSAlert, WinUI ContentDialog, Adwaita MessageDialog", + colors, + )) + .child( + div().flex().gap_2().children([ + { + let gallery = gallery.clone(); + Button::new("gallery-dialog-open") + .label("Delete file…") + .variant(ButtonVariant::Secondary) + .on_click(move |_, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.dialog_open = true; + cx.notify(); + }); + }) + }, + { + let gallery = gallery.clone(); + Button::new("gallery-alert-open") + .label("Sign out…") + .variant(ButtonVariant::Secondary) + .on_click(move |_, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.alert_open = true; + cx.notify(); + }); + }) + }, + ]), + ), + ) .child( v_flex() .gap_2() @@ -640,11 +679,120 @@ impl Render for Gallery { ); // The whole gallery scrolls, demonstrating the scroll area at the - // page level: the thumb floats over the window's content. - ScrollArea::new("gallery-scroll") - .handle(self.scroll.clone()) + // page level: the thumb floats over the window's content. The + // dialogs render outside it, as the last children of the window, so + // their overlay layer paints over everything. + let dialog_gallery = gallery.clone(); + let dialog = Dialog::new("gallery-dialog") + .open(self.dialog_open) + .title("Delete file?") + .on_change({ + let gallery = dialog_gallery.clone(); + move |open, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.dialog_open = open; + cx.notify(); + }); + } + }) + .content(|_, _| { + div() + .child("\"report-final.pdf\" will be removed permanently.") + .into_any_element() + }) + .actions(move |_, _| { + let gallery = dialog_gallery.clone(); + vec![ + Button::new("gallery-dialog-cancel") + .label("Cancel") + .variant(ButtonVariant::Secondary) + .on_click({ + let gallery = gallery.clone(); + move |_, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.dialog_open = false; + cx.notify(); + }); + } + }) + .into_any_element(), + Button::new("gallery-dialog-delete") + .label("Delete") + .variant(ButtonVariant::Destructive) + .on_click({ + let gallery = gallery.clone(); + move |_, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.dialog_open = false; + cx.notify(); + }); + } + }) + .into_any_element(), + ] + }); + + let alert_gallery = gallery.clone(); + let alert = Dialog::new("gallery-alert") + .open(self.alert_open) + .alert(true) + .title("Sign out of the workspace?") + .on_change({ + let gallery = alert_gallery.clone(); + move |open, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.alert_open = open; + cx.notify(); + }); + } + }) + .content(|_, _| { + div() + .child("Any running agents will keep working in the background.") + .into_any_element() + }) + .actions(move |_, _| { + let gallery = alert_gallery.clone(); + vec![ + Button::new("gallery-alert-stay") + .label("Stay") + .variant(ButtonVariant::Secondary) + .on_click({ + let gallery = gallery.clone(); + move |_, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.alert_open = false; + cx.notify(); + }); + } + }) + .into_any_element(), + Button::new("gallery-alert-signout") + .label("Sign out") + .variant(ButtonVariant::Primary) + .on_click({ + let gallery = gallery.clone(); + move |_, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.alert_open = false; + cx.notify(); + }); + } + }) + .into_any_element(), + ] + }); + + div() .size_full() - .child(content) + .child( + ScrollArea::new("gallery-scroll") + .handle(self.scroll.clone()) + .size_full() + .child(content), + ) + .child(dialog) + .child(alert) } } @@ -734,6 +882,8 @@ fn main() { input_state, popover_open: false, menu_open: false, + dialog_open: false, + alert_open: false, scroll: gpui::ScrollHandle::new(), }) },