Files
oak-gpui/crates/gpui_widgets/src/menu/mod.rs
T
Mike-Solar 16ae7c42df gpui_widgets defect fixes: menu checkmarks, dock ratios, meter orientation, CPU frames, drop-frame timecode
- menu: add runtime set-checked path (MenuItem::set_checked/clear_checked,
  Menu::set_item_checked recursive, MenuBar::set_item_checked)
- dock: Split now stores per-child ratios (sum 1.0) so 3+ panels on one
  axis keep distinct sizes; resize_split_child adjusts a single boundary,
  resize_split keeps its two-arg whole-share semantics; one handle per
  boundary; DockLayoutState VERSION bumped to 2 for the new ratios field
- audio_meter: MeterOrientation::Vertical for the 26px transport strip,
  segments lit bottom to top
- viewer: ViewerFrameSource::CpuFrame + set_cpu_frame (BGRA8 RenderImage
  via the sprite atlas) for platforms without CVPixelBuffer
- timeline: TimeDisplay::TimecodeDropFrame (SMPTE drop-frame for NTSC
  rates, non-drop fallback otherwise)
- gpui_widgets: regression test pinning the fractional spacing helpers
  (py_0p5/py_1p5/py_2p5/py_3p5 already exist; no _0_5 aliases added)
2026-08-10 05:45:53 +08:00

790 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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()
}
/// Sets the checked state of the menu item with `id` across all entries
/// (searching submenus recursively), so a host can toggle a checkmark at
/// runtime without rebuilding the [`MenuBar`].
///
/// The checkmark appears on the next repaint (the renderer reads
/// `checked` per frame); pair with a `cx.notify()` after the call.
/// Returns whether an item with that id was found.
pub fn set_item_checked(&mut self, id: usize, checked: bool) -> bool {
let mut found = false;
for entry in &mut self.entries {
found |= entry.menu.set_item_checked(id, checked);
}
found
}
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);
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_mouse_up_out(
MouseButton::Left,
cx.listener(|this, _event: &MouseUpEvent, _window, cx| {
this.close_menu(cx);
}),
)
.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::*;
use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext, point, px, size};
#[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");
}
// --- interaction tests for the menu views ---
fn demo_entries() -> Vec<MenuBarEntry> {
vec![MenuBarEntry::new(
"File",
Menu::new(vec![
MenuItem::new(10, "Open…").with_shortcut("⌘O"),
MenuItem::new(11, "Save").with_shortcut("⌘S"),
MenuItem::new(12, "Quit").separated(),
]),
)]
}
struct Host {
menu_bar: Entity<MenuBar>,
events: Vec<MenuBarEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.menu_bar.clone())
}
}
fn make_bar(cx: &mut TestAppContext) -> (&'static mut VisualTestContext, Entity<Host>) {
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(400.0), px(120.0)), |window, cx| {
let menu_bar = cx.new(|cx| MenuBar::new(1, demo_entries(), window, cx));
let host = Host {
menu_bar,
events: Vec::new(),
};
cx.subscribe(
&host.menu_bar,
|host: &mut Host,
_m: Entity<MenuBar>,
event: &MenuBarEvent,
_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();
(cx, host)
}
#[gpui::test]
async fn clicking_a_menu_title_opens_the_popup(cx: &mut TestAppContext) {
let (cx, _host) = make_bar(cx);
// Click the "File" title (top-left of the bar).
cx.simulate_click(point(px(20.0), px(10.0)), Modifiers::none());
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
let popup = cx.debug_bounds("menu-popup").expect("menu popup rendered");
assert!(popup.size.height > px(60.0), "popup should list the items");
}
#[gpui::test]
async fn clicking_a_menu_item_emits_triggered(cx: &mut TestAppContext) {
let (cx, host) = make_bar(cx);
cx.simulate_click(point(px(20.0), px(10.0)), Modifiers::none());
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
let popup = cx.debug_bounds("menu-popup").expect("menu popup rendered");
// The first item row is the first ~26px of the popup.
cx.simulate_click(
point(popup.left() + px(40.0), popup.top() + px(16.0)),
Modifiers::none(),
);
cx.run_until_parked();
let triggered = cx.read(|app| {
host.read(app).events.iter().any(|e| {
matches!(e, MenuBarEvent::Triggered { item: 10, .. })
})
});
assert!(triggered, "expected Triggered for the first item");
}
#[gpui::test]
async fn keyboard_navigation_triggers_the_hovered_item(cx: &mut TestAppContext) {
let (cx, host) = make_bar(cx);
cx.simulate_click(point(px(20.0), px(10.0)), Modifiers::none());
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
// Down from no selection lands on the first item; the second down
// moves to Save. Enter triggers it.
cx.simulate_keystrokes("down");
cx.run_until_parked();
cx.simulate_keystrokes("down");
cx.run_until_parked();
cx.simulate_keystrokes("enter");
cx.run_until_parked();
let triggered = cx.read(|app| {
host.read(app).events.iter().any(|e| {
matches!(e, MenuBarEvent::Triggered { item: 11, .. })
})
});
assert!(triggered, "expected Triggered for the second item via keyboard");
}
#[gpui::test]
async fn escape_closes_the_menu(cx: &mut TestAppContext) {
let (cx, host) = make_bar(cx);
cx.simulate_click(point(px(20.0), px(10.0)), Modifiers::none());
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
assert!(cx.debug_bounds("menu-popup").is_some());
cx.simulate_keystrokes("escape");
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
assert!(cx.debug_bounds("menu-popup").is_none(), "menu should close on escape");
let closed = cx.read(|app| {
host.read(app).events.iter().any(|e| matches!(e, MenuBarEvent::MenuClosed { .. }))
});
assert!(closed);
}
#[gpui::test]
async fn runtime_set_item_checked_flips_the_checkmark(cx: &mut TestAppContext) {
let (cx, host) = make_bar(cx);
// Toggle item 11 ("Save") at runtime, by id.
let changed = cx.update(|_window, app| {
let bar = host.read(app).menu_bar.clone();
bar.update(app, |bar, _cx| bar.set_item_checked(11, true))
});
assert!(changed, "item 11 exists and should be updated");
let checked = cx.update(|_window, app| {
host.read(app).menu_bar.read(app).entries[0].menu.items[1].checked
});
assert_eq!(checked, Some(true));
// Unknown ids are reported as not found and change nothing.
let changed = cx.update(|_window, app| {
let bar = host.read(app).menu_bar.clone();
bar.update(app, |bar, _cx| bar.set_item_checked(12345, true))
});
assert!(!changed);
let checked = cx.update(|_window, app| {
host.read(app).menu_bar.read(app).entries[0].menu.items[0].checked
});
assert_eq!(checked, None);
}
}