diff --git a/crates/gpui_widgets/Cargo.toml b/crates/gpui_widgets/Cargo.toml index 743ee95c38..f8d1eb6942 100644 --- a/crates/gpui_widgets/Cargo.toml +++ b/crates/gpui_widgets/Cargo.toml @@ -23,3 +23,7 @@ gpui_platform = { workspace = true, features = ["font-kit", "wayland", "x11"] } [[example]] name = "controls" path = "examples/controls.rs" + +[[example]] +name = "menus_dialogs" +path = "examples/menus_dialogs.rs" diff --git a/crates/gpui_widgets/examples/menus_dialogs.rs b/crates/gpui_widgets/examples/menus_dialogs.rs new file mode 100644 index 0000000000..33bbcf5397 --- /dev/null +++ b/crates/gpui_widgets/examples/menus_dialogs.rs @@ -0,0 +1,268 @@ +//! A demo of the menu and dialog framework: a menu bar, a right-click +//! context menu, and the message/progress/file dialogs. Every action is +//! printed as a request event. + +use gpui::{ + App, Bounds, Context, Entity, Focusable, MouseButton, MouseDownEvent, Render, Window, + WindowBounds, WindowOptions, colors::DefaultColors, div, prelude::*, px, size, +}; +use gpui_widgets::dialog::file_dialog::{FileDialogContent, file_dialog}; +use gpui_widgets::dialog::message_box::{MessageBoxLevel, message_box}; +use gpui_widgets::dialog::progress::{ProgressContent, progress_dialog}; +use gpui_widgets::dialog::{Modal, ModalEvent}; +use gpui_widgets::menu::{ContextMenu, ContextMenuEvent, Menu, MenuBar, MenuBarEntry, MenuBarEvent, MenuItem}; + +struct Example { + menu_bar: Entity, + context_menu: Entity, + message: Entity, + progress: Entity, + progress_content: Entity, + file: Entity, + file_content: Entity, + show_message: bool, + show_progress: bool, + show_file: bool, +} + +impl Example { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let menu_bar = cx.new(|cx| { + MenuBar::new( + 1, + vec![ + MenuBarEntry::new( + "File", + Menu::new(vec![ + MenuItem::new(10, "New…").with_shortcut("⌘N"), + MenuItem::new(11, "Open…").with_shortcut("⌘O"), + MenuItem::new(12, "Save").with_shortcut("⌘S").separated(), + MenuItem::new(13, "Export").disabled(), + ]), + ), + MenuBarEntry::new( + "Edit", + Menu::new(vec![ + MenuItem::new(20, "Undo").with_shortcut("⌘Z"), + MenuItem::new(21, "Redo").with_shortcut("⇧⌘Z").separated(), + MenuItem::new(22, "Show Diagnostics").with_checked(false), + ]), + ), + MenuBarEntry::new( + "View", + Menu::new(vec![ + MenuItem::new(30, "Toolbars"), + MenuItem::new(31, "Scopes"), + MenuItem::new(32, "Theme").with_submenu(Menu::new(vec![ + MenuItem::new(33, "Olive Dark").with_checked(true), + MenuItem::new(34, "Olive Light"), + ])), + ]), + ), + ], + window, + cx, + ) + }); + cx.subscribe( + &menu_bar, + |_this: &mut Self, _m: Entity, event: &MenuBarEvent, _cx| { + println!("menu bar: {event:?}"); + }, + ) + .detach(); + + let context_menu = cx.new(|cx| ContextMenu::new(2, window, cx)); + cx.subscribe( + &context_menu, + |_this: &mut Self, _m: Entity, event: &ContextMenuEvent, _cx| { + println!("context menu: {event:?}"); + }, + ) + .detach(); + + let message = message_box( + 3, + MessageBoxLevel::Warning, + "Unsaved changes", + "Your project has unsaved changes. Export anyway?", + window, + cx, + ); + cx.subscribe( + &message, + |this: &mut Self, _m: Entity, event: &ModalEvent, _cx| { + println!("message box: {event:?}"); + if matches!(event, ModalEvent::ButtonClicked { .. }) { + this.show_message = false; + } + }, + ) + .detach(); + + let (progress, progress_content) = progress_dialog(4, "Exporting…", "Encoding video", window, cx); + cx.subscribe( + &progress, + |this: &mut Self, _m: Entity, event: &ModalEvent, _cx| { + println!("progress: {event:?}"); + if matches!(event, ModalEvent::ButtonClicked { .. }) { + this.show_progress = false; + } + }, + ) + .detach(); + + let (file, file_content) = file_dialog(5, "Open media…", window, cx); + cx.subscribe( + &file, + |this: &mut Self, _m: Entity, event: &ModalEvent, _cx| { + if let ModalEvent::ButtonClicked { button: 0, .. } = event { + let path = this.file_content.read(_cx).path(_cx); + println!("open file: {path}"); + } + println!("file dialog: {event:?}"); + if matches!(event, ModalEvent::ButtonClicked { .. }) { + this.show_file = false; + } + }, + ) + .detach(); + + Self { + menu_bar, + context_menu, + message, + progress, + progress_content, + file, + file_content, + show_message: false, + show_progress: false, + show_file: false, + } + } +} + +impl Render for Example { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + + let mut root = div() + .size_full() + .bg(colors.background) + .flex() + .flex_col() + .child(self.menu_bar.clone()) + .child( + div() + .flex_1() + .flex() + .items_center() + .justify_center() + .gap_2() + .text_color(colors.text) + .id("example-main") + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, event: &MouseDownEvent, _window, cx| { + this.context_menu.update(cx, |menu, cx| { + menu.show( + event.position, + Menu::new(vec![ + MenuItem::new(40, "Cut").with_shortcut("⌘X"), + MenuItem::new(41, "Copy").with_shortcut("⌘C"), + MenuItem::new(42, "Paste").with_shortcut("⌘V"), + MenuItem::new(43, "Delete").disabled().separated(), + MenuItem::new(44, "Properties…"), + ]), + cx, + ); + }); + }), + ) + .child(div().child("Right-click for a context menu")) + .child( + div() + .id("example-btn-message") + .px_3() + .py_1() + .rounded_md() + .bg(colors.selected) + .text_color(colors.selected_text) + .cursor_pointer() + .on_click(cx.listener(|this, _event: &gpui::ClickEvent, window, cx| { + this.show_message = true; + window.focus(&this.message.read(cx).focus_handle(cx), cx); + })) + .child("Message box"), + ) + .child( + div() + .id("example-btn-progress") + .px_3() + .py_1() + .rounded_md() + .bg(colors.selected) + .text_color(colors.selected_text) + .cursor_pointer() + .on_click(cx.listener(|this, _event: &gpui::ClickEvent, window, cx| { + this.show_progress = true; + this.progress_content + .update(cx, |content, cx| content.set_progress(0.4, cx)); + window.focus(&this.progress.read(cx).focus_handle(cx), cx); + })) + .child("Progress"), + ) + .child( + div() + .id("example-btn-file") + .px_3() + .py_1() + .rounded_md() + .bg(colors.selected) + .text_color(colors.selected_text) + .cursor_pointer() + .on_click(cx.listener(|this, _event: &gpui::ClickEvent, window, cx| { + this.show_file = true; + window.focus(&this.file.read(cx).focus_handle(cx), cx); + })) + .child("Open file…"), + ), + ) + .child(self.context_menu.clone()); + + if self.show_message { + root = root.child(self.message.clone()); + } + if self.show_progress { + root = root.child(self.progress.clone()); + } + if self.show_file { + root = root.child(self.file.clone()); + } + root + } +} + +fn main() { + gpui_platform::application().run(|cx: &mut App| { + cx.init_colors(); + let bounds = Bounds::centered(None, size(px(640.0), px(480.0)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |window, cx| cx.new(|cx| Example::new(window, cx)), + ) + .expect("Failed to open window"); + + cx.activate(true); + cx.on_window_closed(|cx, _| { + if cx.windows().is_empty() { + cx.quit(); + } + }) + .detach(); + }); +} diff --git a/crates/gpui_widgets/src/dialog/file_dialog.rs b/crates/gpui_widgets/src/dialog/file_dialog.rs new file mode 100644 index 0000000000..1f1d4e9e18 --- /dev/null +++ b/crates/gpui_widgets/src/dialog/file_dialog.rs @@ -0,0 +1,105 @@ +//! A file dialog: a modal with a path field and OK/Cancel buttons. +//! +//! This fork has no platform open/save panels yet (`prompt_for_paths` / +//! `prompt_for_new_path` do not exist in gpui), so the dialog is a native +//! gpui modal with a directly-editable path field. It emits +//! [`ModalEvent::ButtonClicked`] with the path available from +//! [`FileDialogContent::path`]; a host can back it with a real platform +//! picker later. + +use gpui::{ + App, Context, Entity, Render, SharedString, Window, colors::DefaultColors, div, prelude::*, + px, +}; +use gpui_elements::editable_text::{EditableTextState, StringStorage, text_input}; + +use super::{DialogButton, Modal, ModalOptions}; + +/// The content view of a file dialog: a path text field. +pub struct FileDialogContent { + editor: Entity, +} + +impl FileDialogContent { + /// The path currently entered. + pub fn path(&self, app: &gpui::App) -> SharedString { + self.editor.read(app).as_str().into() + } + + /// Set the path shown in the field. + pub fn set_path(&mut self, path: impl Into, cx: &mut Context) { + let path = path.into(); + self.editor.update(cx, |editor, cx| { + editor.emplace(path.as_ref(), cx); + }); + cx.notify(); + } +} + +impl Render for FileDialogContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let weak = self.editor.downgrade(); + div() + .flex() + .flex_col() + .gap_2() + .child(div().text_color(colors.text).child("Path")) + .child( + div() + .rounded_md() + .border_1() + .border_color(colors.border) + .bg(colors.background) + .px_2() + .py_1() + .child(text_input("gpui-widgets-file-path").state(weak).accepts_input(true)), + ) + } +} + +/// Build a file dialog. OK is button index `0`, Cancel is index `1`. The host +/// reads the chosen path from [`FileDialogContent::path`] when OK is clicked. +pub fn file_dialog( + control: usize, + title: impl Into, + window: &mut Window, + cx: &mut App, +) -> (Entity, Entity) { + let content = cx.new(|cx| { + let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); + FileDialogContent { editor } + }); + let modal = cx.new(|cx| { + Modal::new( + control, + ModalOptions::new(title, px(420.0)) + .with_button(DialogButton::primary("Open")) + .with_button(DialogButton::cancel("Cancel")), + window, + cx, + ) + .with_content(content.clone()) + }); + (modal, content) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + #[gpui::test] + async fn path_round_trips(cx: &mut TestAppContext) { + cx.update(|app| { + let content = app.new(|cx| { + let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); + FileDialogContent { editor } + }); + content.update(app, |content, cx| { + content.set_path("/tmp/movie.mov", cx); + assert_eq!(content.path(cx), "/tmp/movie.mov"); + }); + }); + } +} diff --git a/crates/gpui_widgets/src/dialog/message_box.rs b/crates/gpui_widgets/src/dialog/message_box.rs new file mode 100644 index 0000000000..1639085a98 --- /dev/null +++ b/crates/gpui_widgets/src/dialog/message_box.rs @@ -0,0 +1,173 @@ +//! A message box: an informational dialog with an icon and a message. + +use gpui::{App, Context, Entity, Render, Window, colors::DefaultColors, div, prelude::*, px}; + +use super::{DialogButton, Modal, ModalOptions}; + +/// The severity of a message box. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessageBoxLevel { + /// Informational. + Info, + /// A warning. + Warning, + /// An error. + Error, +} + +impl MessageBoxLevel { + /// The icon color for this level. + fn color(self) -> gpui::Hsla { + match self { + MessageBoxLevel::Info => gpui::Hsla { + h: 0.6, + s: 0.8, + l: 0.5, + a: 1.0, + }, + MessageBoxLevel::Warning => gpui::Hsla { + h: 0.1, + s: 0.9, + l: 0.5, + a: 1.0, + }, + MessageBoxLevel::Error => gpui::Hsla { + h: 0.0, + s: 0.8, + l: 0.5, + a: 1.0, + }, + } + } + + /// The glyph shown next to the message. + fn glyph(self) -> &'static str { + match self { + MessageBoxLevel::Info => "ℹ", + MessageBoxLevel::Warning => "⚠", + MessageBoxLevel::Error => "✖", + } + } +} + +/// The content view of a message box. +struct MessageContent { + level: MessageBoxLevel, + message: gpui::SharedString, +} + +impl Render for MessageContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + div() + .flex() + .items_start() + .gap_3() + .child( + div() + .w(px(24.0)) + .text_color(self.level.color()) + .child(self.level.glyph()), + ) + .child(div().flex_1().text_color(colors.text).child(self.message.clone())) + } +} + +/// Build a message box modal. +/// +/// The host renders the returned [`Modal`] on top of its content and +/// subscribes to its [`ModalEvent`](super::ModalEvent)s (the OK button is +/// button index `0`). +pub fn message_box( + control: usize, + level: MessageBoxLevel, + title: impl Into, + message: impl Into, + window: &mut Window, + cx: &mut App, +) -> Entity { + let content = cx.new(|_| MessageContent { + level, + message: message.into(), + }); + let modal = cx.new(|cx| { + Modal::new( + control, + ModalOptions::new(title, px(380.0)).with_button(DialogButton::primary("OK")), + window, + cx, + ) + .with_content(content) + }); + modal +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Modifiers, TestAppContext, VisualTestContext, px, size}; + + #[test] + fn levels_have_distinct_glyphs() { + assert_ne!(MessageBoxLevel::Info.glyph(), MessageBoxLevel::Error.glyph()); + assert_ne!(MessageBoxLevel::Info.color(), MessageBoxLevel::Warning.color()); + } + + #[gpui::test] + async fn ok_button_emits_index_zero(cx: &mut TestAppContext) { + struct Host { + modal: Entity, + events: Vec, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.modal.clone()) + } + } + + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(size(px(500.0), px(300.0)), |window, cx| { + let modal = message_box( + 1, + MessageBoxLevel::Error, + "Render failed", + "The export could not be completed.", + window, + cx, + ); + let host = Host { + modal, + events: Vec::new(), + }; + cx.subscribe( + &host.modal, + |host: &mut Host, + _m: Entity, + event: &super::super::ModalEvent, + _cx: &mut Context| { + host.events.push(event.clone()); + }, + ) + .detach(); + host + }); + cx.run_until_parked(); + let host = window.root(cx).unwrap(); + + let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + // Click the OK button (index 0). + let ok = cx.debug_bounds("dialog-button-0").expect("OK button rendered"); + cx.simulate_click(ok.center(), Modifiers::none()); + cx.run_until_parked(); + + let routed = cx.read(|app| { + host.read(app).events.iter().any(|e| { + matches!( + e, + super::super::ModalEvent::ButtonClicked { button: 0, .. } + ) + }) + }); + assert!(routed); + } +} diff --git a/crates/gpui_widgets/src/dialog/mod.rs b/crates/gpui_widgets/src/dialog/mod.rs new file mode 100644 index 0000000000..ad3b3b5663 --- /dev/null +++ b/crates/gpui_widgets/src/dialog/mod.rs @@ -0,0 +1,353 @@ +//! Modal dialog framework: a mask, a titled card with a content slot and a +//! button row, plus ready-made dialogs (message box, progress, file path). +//! +//! A [`Modal`] is a view the host renders on top of its content (e.g. as the +//! last child of the root view) and focuses when shown. `escape` dismisses, +//! `enter` activates the primary button, and every button click emits +//! [`ModalEvent::ButtonClicked`] as a request. + +pub mod file_dialog; +pub mod message_box; +pub mod progress; + +use gpui::{ + AnyView, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Hsla, + KeyDownEvent, Render, SharedString, Window, colors::DefaultColors, div, prelude::*, +}; + +/// How a button behaves in the dialog. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DialogButtonRole { + /// The default action (`enter` triggers it). + Primary, + /// A secondary action. + Secondary, + /// Cancels the dialog (`escape` also triggers it). + Cancel, +} + +/// A button in a dialog's button row. +#[derive(Debug, Clone, PartialEq)] +pub struct DialogButton { + /// The button label. + pub label: SharedString, + /// The button's role. + pub role: DialogButtonRole, +} + +impl DialogButton { + /// Create a button. + pub fn new(label: impl Into, role: DialogButtonRole) -> Self { + Self { + label: label.into(), + role, + } + } + + /// A primary button. + pub fn primary(label: impl Into) -> Self { + Self::new(label, DialogButtonRole::Primary) + } + + /// A cancel button. + pub fn cancel(label: impl Into) -> Self { + Self::new(label, DialogButtonRole::Cancel) + } +} + +/// Configuration for a [`Modal`]. +#[derive(Debug, Clone)] +pub struct ModalOptions { + /// The title shown in the card's header. + pub title: SharedString, + /// The width of the card. + pub width: gpui::Pixels, + /// The buttons in the footer row. + pub buttons: Vec, +} + +impl ModalOptions { + /// Create options. + pub fn new(title: impl Into, width: gpui::Pixels) -> Self { + Self { + title: title.into(), + width, + buttons: Vec::new(), + } + } + + /// Add a button. + pub fn with_button(mut self, button: DialogButton) -> Self { + self.buttons.push(button); + self + } +} + +/// A request emitted by a modal dialog. +#[derive(Debug, Clone, PartialEq)] +pub enum ModalEvent { + /// A button was clicked. + ButtonClicked { + /// The modal's stable id. + control: usize, + /// The index of the clicked button in [`ModalOptions::buttons`]. + button: usize, + }, + /// The dialog was dismissed (escape or backdrop). + Dismissed { + /// The modal's stable id. + control: usize, + }, +} + +/// A modal dialog frame: mask, title bar, content slot and button row. +pub struct Modal { + control: usize, + options: ModalOptions, + content: Option, + focus_handle: FocusHandle, +} + +impl Modal { + /// Create a modal with `options` (no content yet). + pub fn new( + control: usize, + options: ModalOptions, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + control, + options, + content: None, + focus_handle: cx.focus_handle(), + } + } + + /// Attach the dialog's content view. + pub fn with_content(mut self, content: impl Into) -> Self { + self.content = Some(content.into()); + self + } + + /// Replace the content view. + pub fn set_content(&mut self, content: impl Into, cx: &mut Context) { + self.content = Some(content.into()); + cx.notify(); + } + + fn emit_button(&mut self, index: usize, cx: &mut Context) { + cx.emit(ModalEvent::ButtonClicked { + control: self.control, + button: index, + }); + cx.notify(); + } +} + +impl EventEmitter for Modal {} + +impl Focusable for Modal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for Modal { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let control = self.control; + let buttons = self.options.buttons.clone(); + let primary = buttons + .iter() + .position(|b| b.role == DialogButtonRole::Primary); + + // Mask + card, centered. + div() + .id(ElementId::named_usize("gpui-widgets-modal", control)) + .absolute() + .size_full() + .bg(Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 0.4, + }) + .occlude() + .block_mouse_except_scroll() + .flex() + .items_center() + .justify_center() + .track_focus(&self.focus_handle) + .on_key_down(cx.listener(move |this, event: &KeyDownEvent, _window, cx| { + match event.keystroke.key.as_str() { + "escape" => { + cx.emit(ModalEvent::Dismissed { + control: this.control, + }); + cx.notify(); + } + "enter" => { + if let Some(index) = primary { + this.emit_button(index, cx); + } + } + _ => {} + } + })) + .child( + div() + .w(self.options.width) + .rounded_lg() + .border_1() + .border_color(colors.border) + .bg(colors.container) + .debug_selector(|| "dialog-card".into()) + .shadow_lg() + .flex() + .flex_col() + .child( + div() + .px_4() + .py_2() + .border_b_1() + .border_color(colors.border) + .text_color(colors.text) + .child(self.options.title.clone()), + ) + .child( + div() + .p_4() + .flex_1() + .child(if let Some(content) = &self.content { + content.clone().into_any_element() + } else { + div().into_any_element() + }), + ) + .child( + div() + .px_4() + .py_3() + .flex() + .justify_end() + .gap_2() + .children( + buttons + .into_iter() + .enumerate() + .map(|(index, button)| { + let role = button.role; + let label = button.label; + let bg = match role { + DialogButtonRole::Primary => colors.selected, + _ => colors.background, + }; + let text = match role { + DialogButtonRole::Primary => colors.selected_text, + _ => colors.text, + }; + div() + .id(ElementId::named_usize( + format!("gpui-widgets-modal-button-{control}"), + index, + )) + .debug_selector(|| { + format!("dialog-button-{index}").into() + }) + .px_3() + .py_1() + .rounded_md() + .bg(bg) + .text_color(text) + .cursor_pointer() + .on_click(cx.listener( + move |this, _event: &ClickEvent, _window, cx| { + this.emit_button(index, cx); + cx.stop_propagation(); + }, + )) + .child(label) + }), + ), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext, px, size}; + + #[test] + fn button_roles_and_primary_index() { + let options = ModalOptions::new("Save", px(400.0)) + .with_button(DialogButton::cancel("Cancel")) + .with_button(DialogButton::primary("Save")); + assert_eq!(options.buttons.len(), 2); + assert_eq!(options.buttons[0].role, DialogButtonRole::Cancel); + assert_eq!(options.buttons[1].role, DialogButtonRole::Primary); + } + + #[gpui::test] + async fn button_click_emits_routed_event(cx: &mut TestAppContext) { + struct Host { + modal: Entity, + events: Vec, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.modal.clone()) + } + } + + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(size(px(500.0), px(300.0)), |window, cx| { + let modal = cx.new(|cx| { + Modal::new( + 1, + ModalOptions::new("Prompt", px(360.0)) + .with_button(DialogButton::cancel("Cancel")) + .with_button(DialogButton::primary("Apply")), + window, + cx, + ) + }); + let host = Host { + modal, + events: Vec::new(), + }; + cx.subscribe( + &host.modal, + |host: &mut Host, + _m: Entity, + event: &ModalEvent, + _cx: &mut Context| { + host.events.push(event.clone()); + }, + ) + .detach(); + host + }); + cx.run_until_parked(); + let host = window.root(cx).unwrap(); + + let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + // Click the last button in the card's bottom-right corner. + let card = cx.debug_bounds("dialog-card").expect("dialog card rendered"); + eprintln!("card={card:?} btn0={:?} btn1={:?}", + cx.debug_bounds("dialog-button-0"), + cx.debug_bounds("dialog-button-1")); + let btn = cx.debug_bounds("dialog-button-1").expect("button rendered"); + cx.simulate_click(btn.center(), Modifiers::none()); + cx.run_until_parked(); + + let routed = cx.read(|app| { + host.read(app).events.iter().any(|e| { + matches!(e, ModalEvent::ButtonClicked { button: 1, .. }) + }) + }); + assert!(routed, "expected ButtonClicked for the Apply (index 1) button"); + } +} diff --git a/crates/gpui_widgets/src/dialog/progress.rs b/crates/gpui_widgets/src/dialog/progress.rs new file mode 100644 index 0000000000..6c43106f1a --- /dev/null +++ b/crates/gpui_widgets/src/dialog/progress.rs @@ -0,0 +1,118 @@ +//! A progress dialog: a cancelable dialog with a progress bar. +//! +//! The host updates the bar via [`ProgressContent::set_progress`]; the Cancel +//! button (index `1`) emits the modal's button event. + +use gpui::{App, Context, Entity, Render, Window, colors::DefaultColors, div, prelude::*, px}; + +use super::{DialogButton, Modal, ModalOptions}; + +/// The content view of a progress dialog: a labeled progress bar. +pub struct ProgressContent { + label: gpui::SharedString, + /// Progress in `0..=1`. + fraction: f32, +} + +impl ProgressContent { + /// Create a progress content view. + pub fn new(label: impl Into, fraction: f32) -> Self { + Self { + label: label.into(), + fraction: fraction.clamp(0.0, 1.0), + } + } + + /// Update the progress and repaint. + pub fn set_progress(&mut self, fraction: f32, cx: &mut Context) { + self.fraction = fraction.clamp(0.0, 1.0); + cx.notify(); + } + + /// The current progress. + pub fn fraction(&self) -> f32 { + self.fraction + } +} + +impl Render for ProgressContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let fraction = self.fraction; + div() + .flex() + .flex_col() + .gap_2() + .child(div().text_color(colors.text).child(self.label.clone())) + .child( + div() + .h(px(10.0)) + .rounded_full() + .bg(colors.background) + .border_1() + .border_color(colors.border) + .overflow_hidden() + .child( + div() + .h_full() + .w(px((fraction * 100.0).clamp(0.0, 100.0))) + .bg(colors.selected), + ), + ) + .child( + div() + .text_color(colors.disabled) + .child(format!("{:.0}%", fraction * 100.0)), + ) + } +} + +/// Build a progress dialog with a Cancel button (index `1`) and an implicit +/// primary "Run" button (index `0`). Returns the modal and its content so the +/// host can drive the bar. +pub fn progress_dialog( + control: usize, + title: impl Into, + label: impl Into, + window: &mut Window, + cx: &mut App, +) -> (Entity, Entity) { + let content = cx.new(|_| ProgressContent::new(label, 0.0)); + let modal = cx.new(|cx| { + Modal::new( + control, + ModalOptions::new(title, px(360.0)) + .with_button(DialogButton::primary("Run")) + .with_button(DialogButton::cancel("Cancel")), + window, + cx, + ) + .with_content(content.clone()) + }); + (modal, content) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + #[test] + fn progress_clamps_at_construction() { + assert_eq!(ProgressContent::new("Encoding", 2.0).fraction(), 1.0); + assert_eq!(ProgressContent::new("Encoding", -0.5).fraction(), 0.0); + } + + #[gpui::test] + async fn set_progress_clamps(cx: &mut TestAppContext) { + cx.update(|app| { + let content = app.new(|_| ProgressContent::new("Encoding", 0.0)); + content.update(app, |content, cx| { + content.set_progress(1.5, cx); + assert_eq!(content.fraction(), 1.0); + content.set_progress(-1.0, cx); + assert_eq!(content.fraction(), 0.0); + }); + }); + } +} diff --git a/crates/gpui_widgets/src/lib.rs b/crates/gpui_widgets/src/lib.rs index 7928290f61..ca0e1b5255 100644 --- a/crates/gpui_widgets/src/lib.rs +++ b/crates/gpui_widgets/src/lib.rs @@ -21,7 +21,9 @@ pub mod checkbox; pub mod color; pub mod combo_box; pub mod curve_editor; +pub mod dialog; pub mod keyable; +pub mod menu; pub mod radio_group; pub mod slider; pub mod spinbox; diff --git a/crates/gpui_widgets/src/menu/mod.rs b/crates/gpui_widgets/src/menu/mod.rs new file mode 100644 index 0000000000..3ab2a63150 --- /dev/null +++ b/crates/gpui_widgets/src/menu/mod.rs @@ -0,0 +1,609 @@ +//! In-window menus: a menu bar and right-click context menus, built with +//! gpui's `anchored` + `deferred` popups (no platform menus). +//! +//! The pure data model lives in [`model`]; the views here render it. Keyboard +//! navigation (up/down/enter/escape) works while the popup is focused; items +//! with submenus open them on hover to the right of the parent menu. +//! Activating an item emits [`MenuBarEvent::Triggered`] / +//! [`ContextMenuEvent::Triggered`] as a request. + +pub mod model; + +use gpui::{ + Anchor, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, KeyDownEvent, + MouseButton, MouseUpEvent, Pixels, Point, Render, SharedString, Window, anchored, + colors::DefaultColors, deferred, div, point, prelude::*, px, +}; + +pub use model::{Menu, MenuItem}; + +/// The height of one menu row, used for submenu positioning estimates. +const ROW_HEIGHT: f32 = 26.0; + +/// A fully transparent color (for un-hovered rows). +fn transparent() -> gpui::Rgba { + gpui::Rgba { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.0, + } +} + +/// A request emitted by a menu bar. +#[derive(Debug, Clone, PartialEq)] +pub enum MenuBarEvent { + /// An item was activated. + Triggered { + /// The menu bar's stable id. + control: usize, + /// The item's id (from [`MenuItem::id`]). + item: usize, + /// The item's label. + label: SharedString, + }, + /// A menu was opened. + MenuOpened { + /// The menu bar's stable id. + control: usize, + /// The menu index. + index: usize, + }, + /// The open menu was closed. + MenuClosed { + /// The menu bar's stable id. + control: usize, + }, +} + +/// A request emitted by a context menu. +#[derive(Debug, Clone, PartialEq)] +pub struct ContextMenuEvent { + /// The item's id (from [`MenuItem::id`]). + pub item: usize, + /// The item's label. + pub label: SharedString, +} + +/// A titled menu in a menu bar. +#[derive(Debug, Clone, PartialEq)] +pub struct MenuBarEntry { + /// The title shown in the bar. + pub title: SharedString, + /// The menu opened by the title. + pub menu: Menu, +} + +impl MenuBarEntry { + /// Create an entry. + pub fn new(title: impl Into, menu: Menu) -> Self { + Self { + title: title.into(), + menu, + } + } +} + +/// A horizontal menu bar with drop-down menus. +pub struct MenuBar { + control: usize, + entries: Vec, + focus_handle: FocusHandle, + open: Option, + popup_position: Point, + was_open_at_down: bool, + hovered: Option, + submenu: Option, +} + +impl MenuBar { + /// Create a menu bar. + pub fn new( + control: usize, + entries: Vec, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + control, + entries, + focus_handle: cx.focus_handle(), + open: None, + popup_position: Point::default(), + was_open_at_down: false, + hovered: None, + submenu: None, + } + } + + /// Whether any menu is open. + pub fn is_open(&self) -> bool { + self.open.is_some() + } + + fn open_menu(&mut self, index: usize, position: Point, cx: &mut Context) { + if self.open != Some(index) { + self.open = Some(index); + self.popup_position = position; + self.hovered = None; + self.submenu = None; + cx.emit(MenuBarEvent::MenuOpened { + control: self.control, + index, + }); + cx.notify(); + } + } + + fn close_menu(&mut self, cx: &mut Context) { + if self.open.take().is_some() { + self.hovered = None; + self.submenu = None; + cx.emit(MenuBarEvent::MenuClosed { + control: self.control, + }); + cx.notify(); + } + } + + fn trigger(&mut self, item: usize, cx: &mut Context) { + let label = self + .open + .as_ref() + .and_then(|index| self.entries.get(*index)) + .and_then(|entry| entry.menu.items.iter().find(|i| i.id == item)) + .map(|i| i.label.clone()) + .or_else(|| { + self.entries + .iter() + .flat_map(|e| &e.menu.items) + .find(|i| i.id == item) + .map(|i| i.label.clone()) + }) + .unwrap_or_default(); + cx.emit(MenuBarEvent::Triggered { + control: self.control, + item, + label, + }); + self.close_menu(cx); + } + + fn navigate(&mut self, delta: i32, cx: &mut Context) { + if let Some(index) = self.open { + let menu = &self.entries[index].menu; + if let Some(next) = menu.navigate(self.hovered, delta) { + self.hovered = Some(next); + self.submenu = None; + cx.notify(); + } + } + } +} + +impl EventEmitter for MenuBar {} + +impl Focusable for MenuBar { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for MenuBar { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let mut bar = div() + .id(ElementId::named_usize("gpui-widgets-menubar", self.control)) + .flex() + .items_center() + .px_2() + .gap_1() + .bg(colors.container) + .on_mouse_down_out( + cx.listener(|this, _event: &gpui::MouseDownEvent, _window, cx| { + this.close_menu(cx); + }), + ); + + for (index, entry) in self.entries.clone().into_iter().enumerate() { + let is_open = self.open == Some(index); + bar = bar.child( + div() + .id(ElementId::named_usize( + format!("gpui-widgets-menu-title-{}", self.control), + index, + )) + .px_2() + .py_1() + .rounded_md() + .bg(if is_open { colors.selected } else { transparent() }) + .text_color(if is_open { + colors.selected_text + } else { + colors.text + }) + .cursor_pointer() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _event: &gpui::MouseDownEvent, _window, _cx| { + this.was_open_at_down = this.open.is_some(); + }), + ) + .on_click(cx.listener( + move |this, event: &ClickEvent, _window, cx| { + if this.was_open_at_down { + this.close_menu(cx); + } else { + this.open_menu(index, event.position(), cx); + } + cx.stop_propagation(); + }, + )) + .child(entry.title), + ); + } + + // The open menu popup. + if let Some(open_index) = self.open { + let entry = self.entries[open_index].clone(); + let hovered = self.hovered; + let menu_popup = menu_popup_element( + self.control, + &entry.menu, + hovered, + &colors, + cx.listener(|this, item: &MenuClicked, _window, cx| { + this.trigger(item.id, cx); + }), + cx.listener(|this, item: &MenuHovered, _window, cx| { + if item.submenu { + this.submenu = Some(item.index); + cx.notify(); + } + }), + ) + .track_focus(&self.focus_handle) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + match event.keystroke.key.as_str() { + "up" => this.navigate(-1, cx), + "down" => this.navigate(1, cx), + "enter" | "space" => { + if let Some(hovered) = this.hovered { + if let Some(item) = entry_at(this, hovered) { + if item.enabled && item.submenu.is_none() { + this.trigger(item.id, cx); + } + } + } + } + "escape" | "left" => this.close_menu(cx), + _ => {} + } + })); + + bar = bar.child( + deferred( + anchored() + .position(self.popup_position) + .anchor(Anchor::TopLeft) + .offset(point(px(0.0), px(ROW_HEIGHT))) + .snap_to_window_with_margin(px(8.0)) + .child(menu_popup), + ) + .with_priority(1), + ); + + // A hovered item's submenu, anchored to the right of the popup. + if let Some(hovered) = self.hovered + && let Some(item) = self.entries[open_index].menu.items.get(hovered) + && let Some(submenu) = item.submenu.clone() + { + let sub_hovered = self.submenu.and_then(|_| None); + let sub_popup = menu_popup_element( + self.control + 1000, + &submenu, + sub_hovered, + &colors, + cx.listener(|this, clicked: &MenuClicked, _window, cx| { + this.trigger(clicked.id, cx); + }), + cx.listener(|_this, _item: &MenuHovered, _window, _cx| {}), + ); + let width = f32::from(menu_width_estimate()); + bar = bar.child( + deferred( + anchored() + .position(self.popup_position) + .anchor(Anchor::TopLeft) + .offset(point(px(width + 2.0), px(ROW_HEIGHT))) + .snap_to_window_with_margin(px(8.0)) + .child(sub_popup), + ) + .with_priority(2), + ); + } + + // Focus the popup so keyboard navigation works. + window.focus(&self.focus_handle, cx); + } + + bar + } +} + +/// A right-click context menu. +pub struct ContextMenu { + focus_handle: FocusHandle, + open: Option, +} + +struct ContextMenuState { + position: Point, + menu: Menu, + hovered: Option, +} + +impl ContextMenu { + /// Create a context menu (hidden until [`Self::show`]). + pub fn new(_control: usize, _window: &mut Window, cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + open: None, + } + } + + /// Whether the menu is visible. + pub fn is_open(&self) -> bool { + self.open.is_some() + } + + /// Show the menu at `position` (window coordinates). + pub fn show(&mut self, position: Point, menu: Menu, cx: &mut Context) { + self.open = Some(ContextMenuState { + position, + menu, + hovered: None, + }); + cx.notify(); + } + + /// Hide the menu. + pub fn hide(&mut self, cx: &mut Context) { + if self.open.take().is_some() { + cx.notify(); + } + } +} + +impl EventEmitter for ContextMenu {} + +impl Focusable for ContextMenu { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ContextMenu { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let mut root = div(); + + if let Some(state) = self.open.take() { + let menu = state.menu.clone(); + let hovered = state.hovered; + let position = state.position; + let popup = menu_popup_element( + 0, + &menu, + hovered, + &colors, + cx.listener(|this, clicked: &MenuClicked, _window, cx| { + cx.emit(ContextMenuEvent { + item: clicked.id, + label: clicked.label.clone(), + }); + this.hide(cx); + }), + cx.listener(|_this, _item: &MenuHovered, _window, _cx| {}), + ) + .track_focus(&self.focus_handle) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|this, _event: &MouseUpEvent, _window, cx| { + this.hide(cx); + }), + ) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + match event.keystroke.key.as_str() { + "up" | "down" => { + if let Some(state) = this.open.as_mut() { + let delta = if event.keystroke.key == "up" { -1 } else { 1 }; + if let Some(next) = state.menu.navigate(state.hovered, delta) { + state.hovered = Some(next); + cx.notify(); + } + } + } + "enter" | "space" => { + if let Some(state) = this.open.as_ref() + && let Some(hovered) = state.hovered + && let Some(item) = state.menu.items.get(hovered) + && item.enabled + { + cx.emit(ContextMenuEvent { + item: item.id, + label: item.label.clone(), + }); + this.hide(cx); + } + } + "escape" => this.hide(cx), + _ => {} + } + })); + + root = root.child( + deferred(anchored().position(position).child(popup)).with_priority(1), + ); + window.focus(&self.focus_handle, cx); + self.open = Some(ContextMenuState { + position, + menu, + hovered, + }); + } + + root + } +} + +/// Marker event types passed to the shared popup builder. +struct MenuClicked { + id: usize, + label: SharedString, +} +struct MenuHovered { + index: usize, + submenu: bool, +} + +/// Build a menu popup list. `on_click` receives the clicked item, `on_hover` +/// receives hovered-item info (used to open submenus). +fn menu_popup_element( + control: usize, + menu: &Menu, + hovered: Option, + colors: &gpui::colors::Colors, + on_click: impl Fn(&MenuClicked, &mut Window, &mut App) + 'static, + on_hover: impl Fn(&MenuHovered, &mut Window, &mut App) + 'static, +) -> gpui::Stateful { + use std::sync::Arc; + let on_click = Arc::new(on_click); + let on_hover = Arc::new(on_hover); + let mut column = div() + .id(ElementId::named_usize("gpui-widgets-menu-popup", control)) + .debug_selector(|| "menu-popup".into()) + .min_w(px(180.0)) + .rounded_md() + .border_1() + .border_color(colors.border) + .bg(colors.container) + .py_1() + .flex() + .flex_col(); + + for (index, item) in menu.items.iter().enumerate() { + let id = item.id; + let label = item.label.clone(); + let shortcut = item.shortcut.clone(); + let checked = item.checked; + let enabled = item.enabled; + let has_submenu = item.submenu.is_some(); + let is_hovered = hovered == Some(index); + let is_separator = Menu::is_separator(item); + + if is_separator { + column = column.child(div().h(px(1.0)).my_1().bg(colors.separator)); + continue; + } + + let row = div() + .id(ElementId::named_usize( + format!("gpui-widgets-menu-item-{control}"), + id, + )) + .px_2() + .h(px(ROW_HEIGHT)) + .flex() + .items_center() + .gap_2() + .bg(if is_hovered { colors.selected } else { transparent() }) + .text_color(if enabled { + colors.text + } else { + colors.disabled + }) + .cursor_pointer() + .child( + div() + .w(px(16.0)) + .child(if checked == Some(true) { "✓" } else { "" }), + ) + .child(div().flex_1().child(label.clone())) + .child(if has_submenu { "›" } else { "" }) + .child( + div() + .text_color(colors.disabled) + .child(shortcut.unwrap_or_default()), + ); + + let row = if enabled { + let on_click = on_click.clone(); + let on_hover = on_hover.clone(); + row.on_click(move |_event: &ClickEvent, window, cx| { + on_click( + &MenuClicked { + id, + label: label.clone(), + }, + window, + cx, + ); + }) + .on_hover(move |hovered: &bool, window, cx| { + if *hovered { + on_hover( + &MenuHovered { + index, + submenu: has_submenu, + }, + window, + cx, + ); + } + }) + } else { + row + }; + + column = column.child(row); + } + column +} + +/// A rough menu width estimate for submenu placement (matches `min_w`). +fn menu_width_estimate() -> Pixels { + px(180.0) +} + +/// Find the menu item at a raw index in the currently open menu. +fn entry_at(bar: &MenuBar, index: usize) -> Option<&MenuItem> { + bar.entries + .get(bar.open?) + .and_then(|entry| entry.menu.items.get(index)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn menu_bar_open_close_round_trip() { + // Pure state checks are in model tests; here we just ensure the + // model types are wired through the view API. + let menu = Menu::new(vec![MenuItem::new(1, "Save").with_shortcut("⌘S")]); + let entry = MenuBarEntry::new("File", menu); + assert_eq!(entry.title, "File"); + assert_eq!(entry.menu.items[0].id, 1); + } + + #[test] + fn context_menu_event_carries_item() { + let event = ContextMenuEvent { + item: 7, + label: "Paste".into(), + }; + assert_eq!(event.item, 7); + assert_eq!(event.label, "Paste"); + } +} diff --git a/crates/gpui_widgets/src/menu/model.rs b/crates/gpui_widgets/src/menu/model.rs new file mode 100644 index 0000000000..a71e27480d --- /dev/null +++ b/crates/gpui_widgets/src/menu/model.rs @@ -0,0 +1,176 @@ +//! Pure menu data model: items with nesting, enable/check state, keyboard +//! navigation arithmetic. No gpui coupling, unit-tested. + +use gpui::SharedString; + +/// A menu item. +#[derive(Debug, Clone, PartialEq)] +pub struct MenuItem { + /// The item's stable id (used in [`MenuEvent`](super::MenuEvent)). + pub id: usize, + /// The label shown in the menu. + pub label: SharedString, + /// A shortcut to display on the right (e.g. `"⌘S"`). + pub shortcut: Option, + /// Whether the item can be activated. + pub enabled: bool, + /// `None` = no checkmark; `Some(checked)` = a check/tick state. + pub checked: Option, + /// A nested submenu, opened on hover/click. + pub submenu: Option>, + /// Whether a separator line follows this item. + pub separator_after: bool, +} + +impl MenuItem { + /// Create a plain enabled item. + pub fn new(id: usize, label: impl Into) -> Self { + Self { + id, + label: label.into(), + shortcut: None, + enabled: true, + checked: None, + submenu: None, + separator_after: false, + } + } + + /// Mark the item disabled. + pub fn disabled(mut self) -> Self { + self.enabled = false; + self + } + + /// Attach a shortcut label. + pub fn with_shortcut(mut self, shortcut: impl Into) -> Self { + self.shortcut = Some(shortcut.into()); + self + } + + /// Set a checked state. + pub fn with_checked(mut self, checked: bool) -> Self { + self.checked = Some(checked); + self + } + + /// Attach a submenu. + pub fn with_submenu(mut self, submenu: Menu) -> Self { + self.submenu = Some(Box::new(submenu)); + self + } + + /// Draw a separator line after this item. + pub fn separated(mut self) -> Self { + self.separator_after = true; + self + } +} + +/// A menu: an ordered list of items. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Menu { + /// The items of this menu. + pub items: Vec, +} + +impl Menu { + /// Create a menu. + pub fn new(items: Vec) -> Self { + Self { items } + } + + /// Whether the item at `index` is a visual separator (an empty item). + pub fn is_separator(item: &MenuItem) -> bool { + item.label.is_empty() + } + + /// The next selectable index from `current`, moving `delta` steps + /// (skipping separators and disabled items). `None` returns the first + /// (or last) selectable item. Returns `None` if nothing is selectable. + pub fn navigate(&self, current: Option, delta: i32) -> Option { + let selectable: Vec = self + .items + .iter() + .enumerate() + .filter(|(_, item)| item.enabled && !Self::is_separator(item)) + .map(|(index, _)| index) + .collect(); + if selectable.is_empty() { + return None; + } + let position = current.and_then(|c| selectable.iter().position(|&i| i == c)); + let next = match position { + Some(pos) => { + ((pos as i64 + delta as i64).rem_euclid(selectable.len() as i64)) as usize + } + None if delta > 0 => 0, + None => selectable.len() - 1, + }; + Some(selectable[next]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_menu() -> Menu { + Menu::new(vec![ + MenuItem::new(1, "Open…").with_shortcut("⌘O"), + MenuItem::new(2, "Save").with_shortcut("⌘S"), + MenuItem::new(3, "Save As…").with_shortcut("⇧⌘S").separated(), + MenuItem::new(4, "Export").disabled(), + MenuItem::new(5, "Export Again").with_checked(true), + ]) + } + + #[test] + fn navigate_skips_disabled_and_separators() { + let menu = sample_menu(); + // From none, down lands on the first selectable (Open). + assert_eq!(menu.navigate(None, 1), Some(0)); + // From none, up lands on the last selectable (Export Again, idx 4). + assert_eq!(menu.navigate(None, -1), Some(4)); + // From Open (0), down skips nothing until Save (1). + assert_eq!(menu.navigate(Some(0), 1), Some(1)); + // From Save As (2), down skips the disabled Export (3) to Export Again (4). + assert_eq!(menu.navigate(Some(2), 1), Some(4)); + // Wrap around. + assert_eq!(menu.navigate(Some(4), 1), Some(0)); + assert_eq!(menu.navigate(Some(0), -1), Some(4)); + } + + #[test] + fn navigate_returns_none_when_nothing_selectable() { + let menu = Menu::new(vec![ + MenuItem::new(1, "Only").disabled(), + MenuItem::new(2, ""), + ]); + assert_eq!(menu.navigate(None, 1), None); + assert_eq!(menu.navigate(Some(1), 1), None); + } + + #[test] + fn checked_state_is_optional() { + let menu = sample_menu(); + assert_eq!(menu.items[0].checked, None); + assert_eq!(menu.items[4].checked, Some(true)); + } + + #[test] + fn cascade_nesting() { + let sub = Menu::new(vec![MenuItem::new(10, "A"), MenuItem::new(11, "B")]); + let item = MenuItem::new(5, "Nested").with_submenu(sub.clone()); + assert_eq!(item.submenu.as_ref().unwrap().items.len(), 2); + assert_eq!(item.submenu.as_ref().unwrap().items[1].id, 11); + // The nested menu itself navigates independently. + assert_eq!(item.submenu.as_ref().unwrap().navigate(None, -1), Some(1)); + } + + #[test] + fn separator_detection() { + assert!(Menu::is_separator(&MenuItem::new(0, ""))); + assert!(!Menu::is_separator(&MenuItem::new(0, "Save"))); + } +} diff --git a/docs/zh/oak-app-rewrite.md b/docs/zh/oak-app-rewrite.md index b97622840e..8fb1ce897b 100644 --- a/docs/zh/oak-app-rewrite.md +++ b/docs/zh/oak-app-rewrite.md @@ -38,15 +38,18 @@ 位置:`crates/gpui_widgets/`(或独立 `gpui_dialogs`)。 -- [ ] `ContextMenu`/`MenuBar` 窗口内菜单组件(Zed 的菜单在 zed app +- [x] `ContextMenu`/`MenuBar` 窗口内菜单组件(Zed 的菜单在 zed app crate 而非 gpui,需要自带):弹层定位、键盘导航、子菜单、勾选/ 禁用态、快捷键展示。 -- [ ] `Modal` 对话框框架:模态遮罩、标题栏、按钮行(确定/取消/ +- [x] `Modal` 对话框框架:模态遮罩、标题栏、按钮行(确定/取消/ 应用)、Esc/Enter 默认键、尺寸约束。 -- [ ] 常用对话框原语:消息框(info/warning/error 三档)、文件选择 +- [x] 常用对话框原语:消息框(info/warning/error 三档)、文件选择 (包 `prompt_for_paths`/`prompt_for_new_path` 平台 API)、进度条 对话框(可取消)。 -- [ ] 单测:菜单模型(勾选/禁用/级联)、对话框结果路由。 + > 注:本 fork 尚无 `prompt_for_paths`/`prompt_for_new_path` 平台 + > API(已核实 window/platform 均无),文件对话框先用纯 gpui 模态 + + > 路径输入框实现;后续接入真实平台选择器时替换内容即可。 +- [x] 单测:菜单模型(勾选/禁用/级联)、对话框结果路由。 ## W3. macOS 视频帧桥接(关键路径)