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 curve_editor;
|
||||||
pub mod dialog;
|
pub mod dialog;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
|
pub mod icons;
|
||||||
pub mod keyable;
|
pub mod keyable;
|
||||||
pub mod menu;
|
pub mod menu;
|
||||||
pub mod project_explorer;
|
pub mod project_explorer;
|
||||||
@@ -32,6 +33,7 @@ pub mod scopes;
|
|||||||
pub mod slider;
|
pub mod slider;
|
||||||
pub mod spinbox;
|
pub mod spinbox;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
pub mod tooltip;
|
||||||
pub mod value;
|
pub mod value;
|
||||||
pub mod viewer;
|
pub mod viewer;
|
||||||
|
|
||||||
|
|||||||
@@ -168,13 +168,7 @@ impl MenuBar {
|
|||||||
.and_then(|index| self.entries.get(*index))
|
.and_then(|index| self.entries.get(*index))
|
||||||
.and_then(|entry| entry.menu.items.iter().find(|i| i.id == item))
|
.and_then(|entry| entry.menu.items.iter().find(|i| i.id == item))
|
||||||
.map(|i| i.label.clone())
|
.map(|i| i.label.clone())
|
||||||
.or_else(|| {
|
.or_else(|| self.find_label(item))
|
||||||
self.entries
|
|
||||||
.iter()
|
|
||||||
.flat_map(|e| &e.menu.items)
|
|
||||||
.find(|i| i.id == item)
|
|
||||||
.map(|i| i.label.clone())
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
cx.emit(MenuBarEvent::Triggered {
|
cx.emit(MenuBarEvent::Triggered {
|
||||||
control: self.control,
|
control: self.control,
|
||||||
@@ -184,6 +178,24 @@ impl MenuBar {
|
|||||||
self.close_menu(cx);
|
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>) {
|
fn navigate(&mut self, delta: i32, cx: &mut Context<Self>) {
|
||||||
if let Some(index) = self.open {
|
if let Some(index) = self.open {
|
||||||
let menu = &self.entries[index].menu;
|
let menu = &self.entries[index].menu;
|
||||||
@@ -261,15 +273,22 @@ impl Render for MenuBar {
|
|||||||
self.control,
|
self.control,
|
||||||
&entry.menu,
|
&entry.menu,
|
||||||
hovered,
|
hovered,
|
||||||
|
"menu-popup",
|
||||||
&colors,
|
&colors,
|
||||||
cx.listener(|this, item: &MenuClicked, _window, cx| {
|
cx.listener(|this, item: &MenuClicked, _window, cx| {
|
||||||
this.trigger(item.id, cx);
|
this.trigger(item.id, cx);
|
||||||
}),
|
}),
|
||||||
cx.listener(|this, item: &MenuHovered, _window, cx| {
|
cx.listener(|this, item: &MenuHovered, _window, cx| {
|
||||||
if item.submenu {
|
// Mouse hover drives both the row highlight and the
|
||||||
this.submenu = Some(item.index);
|
// submenu: remember the hovered row so the render pass
|
||||||
cx.notify();
|
// 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)
|
.track_focus(&self.focus_handle)
|
||||||
@@ -319,6 +338,7 @@ impl Render for MenuBar {
|
|||||||
self.control + 1000,
|
self.control + 1000,
|
||||||
&submenu,
|
&submenu,
|
||||||
sub_hovered,
|
sub_hovered,
|
||||||
|
"menu-submenu-popup",
|
||||||
&colors,
|
&colors,
|
||||||
cx.listener(|this, clicked: &MenuClicked, _window, cx| {
|
cx.listener(|this, clicked: &MenuClicked, _window, cx| {
|
||||||
this.trigger(clicked.id, cx);
|
this.trigger(clicked.id, cx);
|
||||||
@@ -326,12 +346,15 @@ impl Render for MenuBar {
|
|||||||
cx.listener(|_this, _item: &MenuHovered, _window, _cx| {}),
|
cx.listener(|_this, _item: &MenuHovered, _window, _cx| {}),
|
||||||
);
|
);
|
||||||
let width = f32::from(menu_width_estimate());
|
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(
|
bar = bar.child(
|
||||||
deferred(
|
deferred(
|
||||||
anchored()
|
anchored()
|
||||||
.position(self.popup_position)
|
.position(self.popup_position)
|
||||||
.anchor(Anchor::TopLeft)
|
.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))
|
.snap_to_window_with_margin(px(8.0))
|
||||||
.child(sub_popup),
|
.child(sub_popup),
|
||||||
)
|
)
|
||||||
@@ -412,6 +435,7 @@ impl Render for ContextMenu {
|
|||||||
0,
|
0,
|
||||||
&menu,
|
&menu,
|
||||||
hovered,
|
hovered,
|
||||||
|
"menu-popup",
|
||||||
&colors,
|
&colors,
|
||||||
cx.listener(|this, clicked: &MenuClicked, _window, cx| {
|
cx.listener(|this, clicked: &MenuClicked, _window, cx| {
|
||||||
cx.emit(ContextMenuEvent {
|
cx.emit(ContextMenuEvent {
|
||||||
@@ -484,11 +508,13 @@ struct MenuHovered {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a menu popup list. `on_click` receives the clicked item, `on_hover`
|
/// 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(
|
fn menu_popup_element(
|
||||||
control: usize,
|
control: usize,
|
||||||
menu: &Menu,
|
menu: &Menu,
|
||||||
hovered: Option<usize>,
|
hovered: Option<usize>,
|
||||||
|
debug_key: &'static str,
|
||||||
colors: &gpui::colors::Colors,
|
colors: &gpui::colors::Colors,
|
||||||
on_click: impl Fn(&MenuClicked, &mut Window, &mut App) + 'static,
|
on_click: impl Fn(&MenuClicked, &mut Window, &mut App) + 'static,
|
||||||
on_hover: impl Fn(&MenuHovered, &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 on_hover = Arc::new(on_hover);
|
||||||
let mut column = div()
|
let mut column = div()
|
||||||
.id(ElementId::named_usize("gpui-widgets-menu-popup", control))
|
.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))
|
.min_w(px(180.0))
|
||||||
.rounded_md()
|
.rounded_md()
|
||||||
.border_1()
|
.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 {
|
struct Host {
|
||||||
menu_bar: Entity<MenuBar>,
|
menu_bar: Entity<MenuBar>,
|
||||||
events: Vec<MenuBarEvent>,
|
events: Vec<MenuBarEvent>,
|
||||||
@@ -648,9 +690,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn make_bar(cx: &mut TestAppContext) -> (&'static mut VisualTestContext, Entity<Host>) {
|
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());
|
cx.update(|cx| cx.init_colors());
|
||||||
let window = cx.open_window(size(px(400.0), px(120.0)), |window, cx| {
|
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 {
|
let host = Host {
|
||||||
menu_bar,
|
menu_bar,
|
||||||
events: Vec::new(),
|
events: Vec::new(),
|
||||||
@@ -786,4 +835,53 @@ mod tests {
|
|||||||
});
|
});
|
||||||
assert_eq!(checked, None);
|
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::timeline::{FrameRate, TimeDisplay, format_timecode};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AsyncWindowContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle,
|
AnyElement, App, AsyncWindowContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle,
|
||||||
Focusable, ObjectFit, Render, RenderImage, SurfaceSource, Window, colors::DefaultColors, div,
|
Focusable, ObjectFit, Render, RenderImage, SharedString, SurfaceSource, Window, colors::DefaultColors,
|
||||||
img, prelude::*, px, surface,
|
div, img, prelude::*, px, surface,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{icons, tooltip::tooltip_view};
|
||||||
|
|
||||||
/// A request emitted by the viewer.
|
/// A request emitted by the viewer.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum ViewerEvent {
|
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 playing = self.transport.playing;
|
||||||
let play_label = if playing { "⏸" } else { "▶" };
|
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()
|
let transport_bar = div()
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap_1()
|
.gap_2()
|
||||||
.px_2()
|
.px_2()
|
||||||
.py_1()
|
.py_1()
|
||||||
.bg(colors.container)
|
.bg(colors.container)
|
||||||
.child(button(
|
.child(transport_button(
|
||||||
"gpui-widgets-viewer-in",
|
"gpui-widgets-viewer-in",
|
||||||
|
in_icon,
|
||||||
"⏮",
|
"⏮",
|
||||||
|
crate::i18n::tr("viewer.in_point", "入点"),
|
||||||
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||||
this.emit(ViewerEvent::InPointRequested { control: this.control }, cx);
|
this.emit(ViewerEvent::InPointRequested { control: this.control }, cx);
|
||||||
}),
|
}),
|
||||||
))
|
))
|
||||||
.child(button(
|
.child(transport_button(
|
||||||
"gpui-widgets-viewer-step-back",
|
"gpui-widgets-viewer-step-back",
|
||||||
|
step_back_icon,
|
||||||
"⏪",
|
"⏪",
|
||||||
|
crate::i18n::tr("viewer.step_back", "上一帧"),
|
||||||
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||||
this.emit(
|
this.emit(
|
||||||
ViewerEvent::StepRequested {
|
ViewerEvent::StepRequested {
|
||||||
@@ -276,9 +289,15 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
))
|
))
|
||||||
.child(button(
|
.child(transport_button(
|
||||||
"gpui-widgets-viewer-play",
|
"gpui-widgets-viewer-play",
|
||||||
|
play_icon,
|
||||||
play_label,
|
play_label,
|
||||||
|
if playing {
|
||||||
|
crate::i18n::tr("viewer.pause", "暂停")
|
||||||
|
} else {
|
||||||
|
crate::i18n::tr("viewer.play", "播放")
|
||||||
|
},
|
||||||
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||||
let event = if this.transport.playing {
|
let event = if this.transport.playing {
|
||||||
ViewerEvent::PauseRequested { control: this.control }
|
ViewerEvent::PauseRequested { control: this.control }
|
||||||
@@ -288,9 +307,11 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
|
|||||||
this.emit(event, cx);
|
this.emit(event, cx);
|
||||||
}),
|
}),
|
||||||
))
|
))
|
||||||
.child(button(
|
.child(transport_button(
|
||||||
"gpui-widgets-viewer-step-forward",
|
"gpui-widgets-viewer-step-forward",
|
||||||
|
step_forward_icon,
|
||||||
"⏩",
|
"⏩",
|
||||||
|
crate::i18n::tr("viewer.step_forward", "下一帧"),
|
||||||
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||||
this.emit(
|
this.emit(
|
||||||
ViewerEvent::StepRequested {
|
ViewerEvent::StepRequested {
|
||||||
@@ -301,16 +322,20 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
))
|
))
|
||||||
.child(button(
|
.child(transport_button(
|
||||||
"gpui-widgets-viewer-out",
|
"gpui-widgets-viewer-out",
|
||||||
|
out_icon,
|
||||||
"⏭",
|
"⏭",
|
||||||
|
crate::i18n::tr("viewer.out_point", "出点"),
|
||||||
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||||
this.emit(ViewerEvent::OutPointRequested { control: this.control }, cx);
|
this.emit(ViewerEvent::OutPointRequested { control: this.control }, cx);
|
||||||
}),
|
}),
|
||||||
))
|
))
|
||||||
.child(button(
|
.child(transport_button(
|
||||||
"gpui-widgets-viewer-clear-range",
|
"gpui-widgets-viewer-clear-range",
|
||||||
"✕",
|
None,
|
||||||
|
x_glyph(colors.text),
|
||||||
|
crate::i18n::tr("viewer.clear_range", "清除入出点"),
|
||||||
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
cx.listener(|this, _event: &ClickEvent, _window, cx| {
|
||||||
this.emit(ViewerEvent::ClearRangeRequested { control: this.control }, 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.
|
/// A small labeled button.
|
||||||
fn button(
|
fn button(
|
||||||
id: &'static str,
|
id: &'static str,
|
||||||
|
|||||||
Reference in New Issue
Block a user