feat(gpui_widgets): add menu bar, context menu and modal dialog framework
W2 of the oak task list. A pure menu model (nesting, disabled items, separators, checked state, wrap-around keyboard navigation arithmetic) feeds two views: a MenuBar with drop-down popups and a right-click ContextMenu, both built on anchored+deferred with keyboard navigation (up/down/enter/ escape), hover-opened submenus, check marks, shortcut labels and request events. The Modal framework provides the mask, title bar, content slot and button row with escape/enter defaults; message_box (info/warning/error), progress_dialog (driven by ProgressContent) and file_dialog (native gpui fallback - the fork has no prompt_for_paths platform API) are built on it. A menus_dialogs example demonstrates everything. 79 tests pass.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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<MenuBar>,
|
||||
context_menu: Entity<ContextMenu>,
|
||||
message: Entity<Modal>,
|
||||
progress: Entity<Modal>,
|
||||
progress_content: Entity<ProgressContent>,
|
||||
file: Entity<Modal>,
|
||||
file_content: Entity<FileDialogContent>,
|
||||
show_message: bool,
|
||||
show_progress: bool,
|
||||
show_file: bool,
|
||||
}
|
||||
|
||||
impl Example {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> 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<MenuBar>, 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<ContextMenu>, 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<Modal>, 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<Modal>, 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<Modal>, 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<Self>) -> 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();
|
||||
});
|
||||
}
|
||||
@@ -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<EditableTextState>,
|
||||
}
|
||||
|
||||
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<SharedString>, cx: &mut Context<Self>) {
|
||||
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<Self>) -> 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<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (Entity<Modal>, Entity<FileDialogContent>) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<Self>) -> 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<gpui::SharedString>,
|
||||
message: impl Into<gpui::SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Modal> {
|
||||
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<Modal>,
|
||||
events: Vec<super::super::ModalEvent>,
|
||||
}
|
||||
impl Render for Host {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> 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<Modal>,
|
||||
event: &super::super::ModalEvent,
|
||||
_cx: &mut Context<Host>| {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<SharedString>, role: DialogButtonRole) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
role,
|
||||
}
|
||||
}
|
||||
|
||||
/// A primary button.
|
||||
pub fn primary(label: impl Into<SharedString>) -> Self {
|
||||
Self::new(label, DialogButtonRole::Primary)
|
||||
}
|
||||
|
||||
/// A cancel button.
|
||||
pub fn cancel(label: impl Into<SharedString>) -> 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<DialogButton>,
|
||||
}
|
||||
|
||||
impl ModalOptions {
|
||||
/// Create options.
|
||||
pub fn new(title: impl Into<SharedString>, 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<AnyView>,
|
||||
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 {
|
||||
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<AnyView>) -> Self {
|
||||
self.content = Some(content.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Replace the content view.
|
||||
pub fn set_content(&mut self, content: impl Into<AnyView>, cx: &mut Context<Self>) {
|
||||
self.content = Some(content.into());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn emit_button(&mut self, index: usize, cx: &mut Context<Self>) {
|
||||
cx.emit(ModalEvent::ButtonClicked {
|
||||
control: self.control,
|
||||
button: index,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<ModalEvent> 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<Self>) -> 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<Modal>,
|
||||
events: Vec<ModalEvent>,
|
||||
}
|
||||
impl Render for Host {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> 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<Modal>,
|
||||
event: &ModalEvent,
|
||||
_cx: &mut Context<Host>| {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<gpui::SharedString>, 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>) {
|
||||
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<Self>) -> 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<gpui::SharedString>,
|
||||
label: impl Into<gpui::SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (Entity<Modal>, Entity<ProgressContent>) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SharedString>, menu: Menu) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
menu,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A horizontal menu bar with drop-down menus.
|
||||
pub struct MenuBar {
|
||||
control: usize,
|
||||
entries: Vec<MenuBarEntry>,
|
||||
focus_handle: FocusHandle,
|
||||
open: Option<usize>,
|
||||
popup_position: Point<Pixels>,
|
||||
was_open_at_down: bool,
|
||||
hovered: Option<usize>,
|
||||
submenu: Option<usize>,
|
||||
}
|
||||
|
||||
impl MenuBar {
|
||||
/// Create a menu bar.
|
||||
pub fn new(
|
||||
control: usize,
|
||||
entries: Vec<MenuBarEntry>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<Pixels>, cx: &mut Context<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<MenuBarEvent> 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<Self>) -> 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<ContextMenuState>,
|
||||
}
|
||||
|
||||
struct ContextMenuState {
|
||||
position: Point<Pixels>,
|
||||
menu: Menu,
|
||||
hovered: Option<usize>,
|
||||
}
|
||||
|
||||
impl ContextMenu {
|
||||
/// Create a context menu (hidden until [`Self::show`]).
|
||||
pub fn new(_control: usize, _window: &mut Window, cx: &mut Context<Self>) -> 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<Pixels>, menu: Menu, cx: &mut Context<Self>) {
|
||||
self.open = Some(ContextMenuState {
|
||||
position,
|
||||
menu,
|
||||
hovered: None,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Hide the menu.
|
||||
pub fn hide(&mut self, cx: &mut Context<Self>) {
|
||||
if self.open.take().is_some() {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<ContextMenuEvent> 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<Self>) -> 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<usize>,
|
||||
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<gpui::Div> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<SharedString>,
|
||||
/// Whether the item can be activated.
|
||||
pub enabled: bool,
|
||||
/// `None` = no checkmark; `Some(checked)` = a check/tick state.
|
||||
pub checked: Option<bool>,
|
||||
/// A nested submenu, opened on hover/click.
|
||||
pub submenu: Option<Box<Menu>>,
|
||||
/// 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<SharedString>) -> 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<SharedString>) -> 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<MenuItem>,
|
||||
}
|
||||
|
||||
impl Menu {
|
||||
/// Create a menu.
|
||||
pub fn new(items: Vec<MenuItem>) -> 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<usize>, delta: i32) -> Option<usize> {
|
||||
let selectable: Vec<usize> = 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")));
|
||||
}
|
||||
}
|
||||
@@ -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 视频帧桥接(关键路径)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user