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:
@@ -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))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user