fix(gpui_widgets): submenu hover opens nested popup; add icons + tooltip helpers

- MenuBar: mouse hover now sets the hovered row and opens submenus next
  to the hovered item (previously hover state was never set from the
  mouse, so '>' rows never opened their submenu)
- Menu popup: debug selectors for test automation
- viewer: transport bar renders 16px icon buttons with localized
  tooltips via a host-registered icon resolver (glyph fallback kept)
- new icons.rs (theme-aware icon resolver) and tooltip.rs helpers
This commit is contained in:
2026-08-11 00:23:59 +08:00
parent 49e471ae64
commit 507d7e6e8d
5 changed files with 281 additions and 26 deletions
+34
View File
@@ -0,0 +1,34 @@
//! Theme-aware toolbar icon resolution.
//!
//! The widget crates render icons from PNG files (a 16px logical grid, with
//! 2× files for retina); the host application owns the icon files and knows
//! which theme is active, so it registers a resolver here once at startup.
//! Widgets that want an icon ask [`path`], and fall back to a text/glyph
//! label when no resolver (or no matching file) is registered — which keeps
//! gpui_widgets self-contained under its own tests.
use std::path::PathBuf;
use std::sync::Arc;
use gpui::{App, Global};
/// Resolves the file path of a named icon (e.g. `"play"`) for the current
/// theme, or `None` when the icon does not exist.
pub type IconResolver = Arc<dyn Fn(&str, &App) -> Option<PathBuf>>;
/// The registered icon resolver (set by the host app).
struct IconResolverGlobal(IconResolver);
impl Global for IconResolverGlobal {}
/// Registers the host's icon resolver. Call once at startup, before any
/// window renders.
pub fn set_resolver(resolver: IconResolver, cx: &mut App) {
cx.set_global(IconResolverGlobal(resolver));
}
/// The file path of the named icon in the current theme, if resolvable.
pub fn path(name: &str, cx: &App) -> Option<PathBuf> {
cx.try_global::<IconResolverGlobal>()
.and_then(|global| (global.0)(name, cx))
}
+2
View File
@@ -24,6 +24,7 @@ pub mod combo_box;
pub mod curve_editor;
pub mod dialog;
pub mod i18n;
pub mod icons;
pub mod keyable;
pub mod menu;
pub mod project_explorer;
@@ -32,6 +33,7 @@ pub mod scopes;
pub mod slider;
pub mod spinbox;
pub mod theme;
pub mod tooltip;
pub mod value;
pub mod viewer;
+113 -15
View File
@@ -168,13 +168,7 @@ impl MenuBar {
.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())
})
.or_else(|| self.find_label(item))
.unwrap_or_default();
cx.emit(MenuBarEvent::Triggered {
control: self.control,
@@ -184,6 +178,24 @@ impl MenuBar {
self.close_menu(cx);
}
/// The label of an item with `id` anywhere in the open menu, including
/// nested submenus (the fallback when the item is not a top-level row).
fn find_label(&self, item: usize) -> Option<SharedString> {
let index = self.open?;
fn search(menu: &Menu, item: usize) -> Option<SharedString> {
menu.items
.iter()
.find_map(|i| {
if i.id == item {
Some(i.label.clone())
} else {
i.submenu.as_deref().and_then(|sub| search(sub, item))
}
})
}
search(&self.entries[index].menu, item)
}
fn navigate(&mut self, delta: i32, cx: &mut Context<Self>) {
if let Some(index) = self.open {
let menu = &self.entries[index].menu;
@@ -261,15 +273,22 @@ impl Render for MenuBar {
self.control,
&entry.menu,
hovered,
"menu-popup",
&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();
}
// Mouse hover drives both the row highlight and the
// submenu: remember the hovered row so the render pass
// can open the nested menu next to it.
this.hovered = Some(item.index);
this.submenu = if item.submenu {
Some(item.index)
} else {
None
};
cx.notify();
}),
)
.track_focus(&self.focus_handle)
@@ -319,6 +338,7 @@ impl Render for MenuBar {
self.control + 1000,
&submenu,
sub_hovered,
"menu-submenu-popup",
&colors,
cx.listener(|this, clicked: &MenuClicked, _window, cx| {
this.trigger(clicked.id, cx);
@@ -326,12 +346,15 @@ impl Render for MenuBar {
cx.listener(|_this, _item: &MenuHovered, _window, _cx| {}),
);
let width = f32::from(menu_width_estimate());
// Align the submenu with the hovered row (one row height per
// item) so it opens next to the item, not the first row.
let top = ROW_HEIGHT * (hovered as f32 + 1.0);
bar = bar.child(
deferred(
anchored()
.position(self.popup_position)
.anchor(Anchor::TopLeft)
.offset(point(px(width + 2.0), px(ROW_HEIGHT)))
.offset(point(px(width + 2.0), px(top)))
.snap_to_window_with_margin(px(8.0))
.child(sub_popup),
)
@@ -412,6 +435,7 @@ impl Render for ContextMenu {
0,
&menu,
hovered,
"menu-popup",
&colors,
cx.listener(|this, clicked: &MenuClicked, _window, cx| {
cx.emit(ContextMenuEvent {
@@ -484,11 +508,13 @@ struct MenuHovered {
}
/// Build a menu popup list. `on_click` receives the clicked item, `on_hover`
/// receives hovered-item info (used to open submenus).
/// receives hovered-item info (used to open submenus). `debug_key` is the
/// test selector registered for the popup's bounds.
fn menu_popup_element(
control: usize,
menu: &Menu,
hovered: Option<usize>,
debug_key: &'static str,
colors: &gpui::colors::Colors,
on_click: impl Fn(&MenuClicked, &mut Window, &mut App) + 'static,
on_hover: impl Fn(&MenuHovered, &mut Window, &mut App) + 'static,
@@ -498,7 +524,7 @@ fn menu_popup_element(
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())
.debug_selector(move || debug_key.into())
.min_w(px(180.0))
.rounded_md()
.border_1()
@@ -637,6 +663,22 @@ mod tests {
)]
}
/// An entry whose first item carries a nested submenu (like the app's
/// 视图 → 语言 / 主题).
fn demo_entries_with_submenu() -> Vec<MenuBarEntry> {
let sub = Menu::new(vec![
MenuItem::new(21, "简体中文"),
MenuItem::new(22, "English"),
]);
vec![MenuBarEntry::new(
"View",
Menu::new(vec![
MenuItem::new(20, "Language").with_submenu(sub),
MenuItem::new(23, "Preferences…"),
]),
)]
}
struct Host {
menu_bar: Entity<MenuBar>,
events: Vec<MenuBarEvent>,
@@ -648,9 +690,16 @@ mod tests {
}
fn make_bar(cx: &mut TestAppContext) -> (&'static mut VisualTestContext, Entity<Host>) {
make_bar_with(cx, demo_entries())
}
fn make_bar_with(
cx: &mut TestAppContext,
entries: Vec<MenuBarEntry>,
) -> (&'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 menu_bar = cx.new(|cx| MenuBar::new(1, entries, window, cx));
let host = Host {
menu_bar,
events: Vec::new(),
@@ -786,4 +835,53 @@ mod tests {
});
assert_eq!(checked, None);
}
/// Hovering a menu item that carries a nested menu must open the second
/// level to the right of the popup (the app's 视图 → 语言 / 主题 flow).
#[gpui::test]
async fn hovering_a_submenu_item_opens_the_submenu(cx: &mut TestAppContext) {
let (cx, host) = make_bar_with(cx, demo_entries_with_submenu());
// Click the "View" title to open the menu.
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");
// Hover the first row ("Language", which has the submenu).
cx.simulate_mouse_move(
point(popup.left() + px(40.0), popup.top() + px(13.0)),
None,
Modifiers::none(),
);
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
let submenu = cx
.debug_bounds("menu-submenu-popup")
.expect("submenu popup opens next to the hovered item");
assert!(
submenu.left() > popup.left(),
"the submenu opens to the right of the parent popup"
);
// Clicking a submenu row triggers it (and closes the menus).
cx.simulate_click(
point(submenu.left() + px(40.0), submenu.top() + px(13.0)),
Modifiers::none(),
);
cx.run_until_parked();
let triggered = cx.read(|app| {
host.read(app).events.iter().any(|e| {
matches!(e, MenuBarEvent::Triggered { item: 21, .. })
})
});
assert!(triggered, "expected Triggered for the submenu item");
assert!(cx.debug_bounds("menu-popup").is_none(), "menu closed after triggering");
}
}
+35
View File
@@ -0,0 +1,35 @@
//! A small tooltip view for icon/toolbar buttons.
//!
//! gpui's [`Div::tooltip`](gpui::Div::tooltip) builder must return an
//! `AnyView`; this module supplies the standard Oak tooltip: a compact,
//! theme-colored label. Widgets attach it with
//! `el.tooltip(move |window, cx| tooltip_view(label.clone(), window, cx))`.
use gpui::{
AnyView, App, Context, Render, SharedString, Window, colors::DefaultColors, div, prelude::*,
};
/// The tooltip view: a small rounded label in the theme's accent.
pub struct TooltipView {
label: SharedString,
}
impl Render for TooltipView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
div()
.px_2()
.py_1()
.rounded_md()
.bg(colors.selected)
.text_color(colors.selected_text)
.text_xs()
.child(self.label.clone())
}
}
/// Builds a tooltip view for `label`, for use in a
/// [`Div::tooltip`](gpui::Div::tooltip) builder.
pub fn tooltip_view(label: SharedString, _window: &mut Window, cx: &mut App) -> AnyView {
cx.new(|_cx| TooltipView { label }).into()
}
+97 -11
View File
@@ -15,11 +15,13 @@ pub use transport::*;
use gpui::timeline::{FrameRate, TimeDisplay, format_timecode};
use gpui::{
AnyElement, App, AsyncWindowContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle,
Focusable, ObjectFit, Render, RenderImage, SurfaceSource, Window, colors::DefaultColors, div,
img, prelude::*, px, surface,
Focusable, ObjectFit, Render, RenderImage, SharedString, SurfaceSource, Window, colors::DefaultColors,
div, img, prelude::*, px, surface,
};
use std::sync::Arc;
use crate::{icons, tooltip::tooltip_view};
/// A request emitted by the viewer.
#[derive(Debug, Clone, PartialEq)]
pub enum ViewerEvent {
@@ -246,26 +248,37 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
);
}
// Transport bar.
// Transport bar. The transport controls are icon buttons (16px icon
// on a 24px hit target, localized tooltips); without a registered
// icon resolver the buttons fall back to the glyph labels below.
let playing = self.transport.playing;
let play_label = if playing { "" } else { "" };
let in_icon = icons::path("prev", cx);
let step_back_icon = icons::path("rew", cx);
let play_icon = icons::path(if playing { "pause" } else { "play" }, cx);
let step_forward_icon = icons::path("ff", cx);
let out_icon = icons::path("next", cx);
let transport_bar = div()
.flex()
.items_center()
.gap_1()
.gap_2()
.px_2()
.py_1()
.bg(colors.container)
.child(button(
.child(transport_button(
"gpui-widgets-viewer-in",
in_icon,
"",
crate::i18n::tr("viewer.in_point", "入点"),
cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.emit(ViewerEvent::InPointRequested { control: this.control }, cx);
}),
))
.child(button(
.child(transport_button(
"gpui-widgets-viewer-step-back",
step_back_icon,
"",
crate::i18n::tr("viewer.step_back", "上一帧"),
cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.emit(
ViewerEvent::StepRequested {
@@ -276,9 +289,15 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
);
}),
))
.child(button(
.child(transport_button(
"gpui-widgets-viewer-play",
play_icon,
play_label,
if playing {
crate::i18n::tr("viewer.pause", "暂停")
} else {
crate::i18n::tr("viewer.play", "播放")
},
cx.listener(|this, _event: &ClickEvent, _window, cx| {
let event = if this.transport.playing {
ViewerEvent::PauseRequested { control: this.control }
@@ -288,9 +307,11 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
this.emit(event, cx);
}),
))
.child(button(
.child(transport_button(
"gpui-widgets-viewer-step-forward",
step_forward_icon,
"",
crate::i18n::tr("viewer.step_forward", "下一帧"),
cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.emit(
ViewerEvent::StepRequested {
@@ -301,16 +322,20 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
);
}),
))
.child(button(
.child(transport_button(
"gpui-widgets-viewer-out",
out_icon,
"",
crate::i18n::tr("viewer.out_point", "出点"),
cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.emit(ViewerEvent::OutPointRequested { control: this.control }, cx);
}),
))
.child(button(
.child(transport_button(
"gpui-widgets-viewer-clear-range",
"",
None,
x_glyph(colors.text),
crate::i18n::tr("viewer.clear_range", "清除入出点"),
cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.emit(ViewerEvent::ClearRangeRequested { control: this.control }, cx);
}),
@@ -348,6 +373,67 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
}
}
/// A transport icon button: a 16px icon on a 24px hit target with a
/// localized tooltip. Falls back to the `fallback` glyph when `icon` is
/// `None` (no resolver registered, or no file for the name).
fn transport_button(
id: &'static str,
icon: Option<std::path::PathBuf>,
fallback: impl IntoElement,
tooltip: SharedString,
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> impl IntoElement {
let mut el = div()
.id(id)
.debug_selector(move || id.into())
.w(px(24.0))
.h(px(24.0))
.flex()
.items_center()
.justify_center()
.rounded_md()
.cursor_pointer()
.hover(|style| style.bg(gpui::colors::Colors::dark().selected))
.tooltip(move |window, cx| tooltip_view(tooltip.clone(), window, cx))
.on_click(on_click);
if let Some(path) = icon {
el = el.child(img(path).w(px(16.0)).h(px(16.0)));
} else {
el = el.child(fallback);
}
el
}
/// A small painted ✕ (clear-range / close), drawn with a canvas so it stays
/// crisp and theme-colored instead of relying on a font glyph that may
/// rasterize faintly or not at all.
fn x_glyph(color: gpui::Rgba) -> impl IntoElement {
use gpui::{canvas, point, px, Bounds, PathBuilder, Pixels};
canvas(
move |_bounds, _window, _cx| (),
move |bounds: Bounds<Pixels>, (), window, cx| {
let _ = cx;
// Two diagonal strokes across the 16px box, with a small inset so
// the mark reads as a clean X.
let inset = px(4.0);
for stroke in [true, false] {
let mut path = PathBuilder::stroke(px(1.5));
let (x0, x1) = if stroke {
(bounds.left() + inset, bounds.right() - inset)
} else {
(bounds.right() - inset, bounds.left() + inset)
};
path.move_to(point(x0, bounds.top() + inset));
path.line_to(point(x1, bounds.bottom() - inset));
if let Ok(path) = path.build() {
window.paint_path(path, color);
}
}
},
)
}
/// A small labeled button.
fn button(
id: &'static str,