diff --git a/crates/gpui_widgets/src/icons.rs b/crates/gpui_widgets/src/icons.rs new file mode 100644 index 0000000000..8a4f0e10da --- /dev/null +++ b/crates/gpui_widgets/src/icons.rs @@ -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 Option>; + +/// 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 { + cx.try_global::() + .and_then(|global| (global.0)(name, cx)) +} diff --git a/crates/gpui_widgets/src/lib.rs b/crates/gpui_widgets/src/lib.rs index d4f4edd0f6..3fa128c43b 100644 --- a/crates/gpui_widgets/src/lib.rs +++ b/crates/gpui_widgets/src/lib.rs @@ -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; diff --git a/crates/gpui_widgets/src/menu/mod.rs b/crates/gpui_widgets/src/menu/mod.rs index 291342cb5c..47364edc0a 100644 --- a/crates/gpui_widgets/src/menu/mod.rs +++ b/crates/gpui_widgets/src/menu/mod.rs @@ -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 { + let index = self.open?; + fn search(menu: &Menu, item: usize) -> Option { + 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) { 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, + 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 { + 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, events: Vec, @@ -648,9 +690,16 @@ mod tests { } fn make_bar(cx: &mut TestAppContext) -> (&'static mut VisualTestContext, Entity) { + make_bar_with(cx, demo_entries()) + } + + fn make_bar_with( + cx: &mut TestAppContext, + entries: Vec, + ) -> (&'static mut VisualTestContext, Entity) { 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"); + } } diff --git a/crates/gpui_widgets/src/tooltip.rs b/crates/gpui_widgets/src/tooltip.rs new file mode 100644 index 0000000000..52abb52743 --- /dev/null +++ b/crates/gpui_widgets/src/tooltip.rs @@ -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) -> 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() +} diff --git a/crates/gpui_widgets/src/viewer/mod.rs b/crates/gpui_widgets/src/viewer/mod.rs index c69c6ea907..92877b0668 100644 --- a/crates/gpui_widgets/src/viewer/mod.rs +++ b/crates/gpui_widgets/src/viewer/mod.rs @@ -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 Render for ViewerWidget { ); } - // 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 Render for ViewerWidget { ); }), )) - .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 Render for ViewerWidget { 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 Render for ViewerWidget { ); }), )) - .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 Render for ViewerWidget { } } +/// 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, + 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, (), 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,