feat(gpui_widgets): menu scrubbing, content-width popups, density pass

- MenuBar: hover another title with a menu open to switch (scrubbing)
- menu popup width computed from the longest label; ROW_HEIGHT 22
- dock tab bar 26px / smaller tab text; viewer transport tightened
This commit is contained in:
2026-08-13 18:16:10 +08:00
parent af5482d774
commit 888c39bf27
5 changed files with 158 additions and 22 deletions
+3 -2
View File
@@ -92,7 +92,7 @@ pub(crate) struct TabBar {
impl TabBar {
/// Minimum width a tab is allowed to shrink to before the strip starts
/// scrolling instead.
pub(crate) const MIN_TAB_WIDTH: Pixels = Pixels(80.0);
pub(crate) const MIN_TAB_WIDTH: Pixels = Pixels(64.0);
/// Creates a strip for the given tabs; `active` is clamped into range.
pub(crate) fn new(tabs: Vec<PanelId>, active: usize) -> Self {
@@ -216,7 +216,7 @@ impl Render for TabBar {
.flex()
.flex_row()
.items_center()
.h(px(32.0))
.h(px(26.0))
.w_full()
.overflow_hidden()
.on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, cx| {
@@ -277,6 +277,7 @@ impl Render for TabBar {
.flex_none()
.h_full()
.cursor_pointer()
.text_sm()
.bg(if active {
colors.selected
} else {
+20 -1
View File
@@ -227,6 +227,8 @@ pub struct TimelineView<D: TimelineDataSource> {
pub state: TimelineState,
/// The set of tracks selected via their headers.
selected_tracks: BTreeSet<usize>,
/// Rich clip content (thumbnails / waveforms), replaced by the host.
decorator: std::sync::Arc<std::sync::RwLock<dyn ClipDecorator>>,
focus_handle: FocusHandle,
}
@@ -253,10 +255,27 @@ impl<D: TimelineDataSource> TimelineView<D> {
source,
state: TimelineState::new(),
selected_tracks: BTreeSet::new(),
decorator: std::sync::Arc::new(std::sync::RwLock::new(NoopClipDecorator)),
focus_handle,
}
}
/// Builder: installs the host's rich-clip decorator (M12 P4 — Oak's
/// waveform decorator). The default is the no-op decorator.
pub fn clip_decorator(
mut self,
decorator: std::sync::Arc<std::sync::RwLock<dyn ClipDecorator>>,
) -> Self {
self.decorator = decorator;
self
}
/// Replaces the clip decorator after construction (the app wires its
/// waveform cache once the engine is up).
pub fn set_clip_decorator(&mut self, decorator: std::sync::Arc<std::sync::RwLock<dyn ClipDecorator>>) {
self.decorator = decorator;
}
/// The set of tracks selected via header clicks.
pub fn selected_tracks(&self) -> &BTreeSet<usize> {
&self.selected_tracks
@@ -731,7 +750,7 @@ impl<D: TimelineDataSource> Render for TimelineView<D> {
// (the clip-area child iterator consumes `rows` below).
let marquee_rows = Arc::new(rows.clone());
let playhead_x = state.point_at_frame(state.playhead).0;
let decorator: Arc<RwLock<dyn ClipDecorator>> = Arc::new(RwLock::new(NoopClipDecorator));
let decorator = self.decorator.clone();
let ruler = div()
.flex_row()
+126 -10
View File
@@ -3,7 +3,9 @@
//!
//! 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.
//! with submenus open them on hover to the right of the parent menu. With a
//! menu open, hovering another top-level title switches to that menu (menu
//! scrubbing), like a native menu bar.
//! Activating an item emits [`MenuBarEvent::Triggered`] /
//! [`ContextMenuEvent::Triggered`] as a request.
@@ -11,14 +13,14 @@ 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,
KeyDownEvent, MouseButton, MouseMoveEvent, 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;
const ROW_HEIGHT: f32 = 22.0;
/// A fully transparent color (for un-hovered rows).
fn transparent() -> gpui::Rgba {
@@ -222,6 +224,7 @@ impl Render for MenuBar {
.flex()
.items_center()
.px_2()
.py_0p5()
.gap_1()
.bg(colors.container);
@@ -233,8 +236,9 @@ impl Render for MenuBar {
format!("gpui-widgets-menu-title-{}", self.control),
index,
))
.debug_selector(move || format!("menu-title-{index}").into())
.px_2()
.py_1()
.py_0p5()
.rounded_md()
.bg(if is_open {
colors.selected
@@ -246,6 +250,7 @@ impl Render for MenuBar {
} else {
colors.text
})
.text_xs()
.cursor_pointer()
.on_mouse_down(
MouseButton::Left,
@@ -253,6 +258,15 @@ impl Render for MenuBar {
this.was_open_at_down = this.open.is_some();
}),
)
.on_mouse_move(cx.listener(move |this, event: &MouseMoveEvent, _window, cx| {
// Menu scrubbing: with a menu open, moving over another
// title switches the open menu to it (re-anchoring the
// popup under the pointer). Nothing happens while no
// menu is open, so the titles only open on click.
if this.open.is_some() {
this.open_menu(index, event.position, cx);
}
}))
.on_click(cx.listener(move |this, event: &ClickEvent, _window, cx| {
if this.was_open_at_down {
this.close_menu(cx);
@@ -341,7 +355,7 @@ impl Render for MenuBar {
}),
cx.listener(|_this, _item: &MenuHovered, _window, _cx| {}),
);
let width = f32::from(menu_width_estimate());
let width = f32::from(menu_width(&entry.menu));
// 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);
@@ -520,12 +534,13 @@ fn menu_popup_element(
let mut column = div()
.id(ElementId::named_usize("gpui-widgets-menu-popup", control))
.debug_selector(move || debug_key.into())
.min_w(px(180.0))
.w(menu_width(menu))
.rounded_md()
.border_1()
.border_color(colors.border)
.bg(colors.container)
.py_1()
.text_xs()
.flex()
.flex_col();
@@ -612,9 +627,29 @@ fn menu_popup_element(
column
}
/// A rough menu width estimate for submenu placement (matches `min_w`).
fn menu_width_estimate() -> Pixels {
px(180.0)
/// A menu popup's content-aware width: the longest label (CJK glyphs
/// count double) plus the checkmark, submenu-arrow and shortcut columns,
/// clamped to a sane range. The popup sizes itself with this and the
/// submenu x-offset reads the same value, so the two never disagree.
fn menu_width(menu: &Menu) -> Pixels {
let mut max_units = 0.0f32;
for item in &menu.items {
if Menu::is_separator(item) {
continue;
}
let label: f32 = item
.label
.chars()
.map(|c| if c.is_ascii() { 1.0 } else { 2.0 })
.sum();
let shortcut = item
.shortcut
.as_ref()
.map(|s| s.chars().count() as f32 + 2.0)
.unwrap_or(0.0);
max_units = max_units.max(label + shortcut);
}
(px(16.0 + 48.0) + px(max_units * 7.0)).clamp(px(96.0), px(360.0))
}
/// Find the menu item at a raw index in the currently open menu.
@@ -678,6 +713,27 @@ mod tests {
)]
}
/// Two top-level menus ("File" then "View"), for scrubbing between titles.
fn demo_entries_two() -> 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(),
]),
),
MenuBarEntry::new(
"View",
Menu::new(vec![
MenuItem::new(20, "Language"),
MenuItem::new(23, "Preferences…"),
]),
),
]
}
struct Host {
menu_bar: Entity<MenuBar>,
events: Vec<MenuBarEvent>,
@@ -820,6 +876,66 @@ mod tests {
assert!(closed);
}
/// Menu scrubbing: while a menu is open, moving over another top-level
/// title switches the open menu to it (and re-anchors the popup under that
/// title). With nothing open, hovering does not open a menu.
#[gpui::test]
async fn hovering_another_title_switches_the_open_menu(cx: &mut TestAppContext) {
let (cx, host) = make_bar_with(cx, demo_entries_two());
// Open the first menu ("File").
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("first menu open");
// Move over the second title ("View"): the open menu must switch to it.
let second_title = cx
.debug_bounds("menu-title-1")
.expect("second title rendered");
cx.simulate_mouse_move(second_title.center(), None, Modifiers::none());
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
let switched = cx.read(|app| {
host.read(app)
.events
.iter()
.any(|e| matches!(e, MenuBarEvent::MenuOpened { index: 1, .. }))
});
assert!(
switched,
"hovering the second title should switch the open menu"
);
let moved = cx.debug_bounds("menu-popup").expect("popup stays open");
assert!(
moved.left() > popup.left() + px(10.0),
"the popup follows the hovered title"
);
// Hovering back over the first title switches back.
let first_title = cx
.debug_bounds("menu-title-0")
.expect("first title rendered");
cx.simulate_mouse_move(first_title.center(), None, Modifiers::none());
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
let switched_back = cx.read(|app| {
host.read(app)
.events
.iter()
.filter(|e| matches!(e, MenuBarEvent::MenuOpened { index: 0, .. }))
.count()
>= 2
});
assert!(switched_back, "hovering the first title switches back");
}
#[gpui::test]
async fn runtime_set_item_checked_flips_the_checkmark(cx: &mut TestAppContext) {
let (cx, host) = make_bar(cx);
+4 -4
View File
@@ -229,7 +229,7 @@ impl<D: ProjectDataSource> Render for ProjectExplorer<D> {
.bg(colors.container)
.child(toggle_button(
"gpui-widgets-explorer-tree",
"",
crate::i18n::tr("explorer.tree", ""),
self.view == ExplorerView::Tree,
&colors,
cx.listener(|this, _event: &ClickEvent, _window, cx| {
@@ -238,7 +238,7 @@ impl<D: ProjectDataSource> Render for ProjectExplorer<D> {
))
.child(toggle_button(
"gpui-widgets-explorer-icons",
"图标",
crate::i18n::tr("explorer.icons", "图标"),
self.view == ExplorerView::Icons,
&colors,
cx.listener(|this, _event: &ClickEvent, _window, cx| {
@@ -415,7 +415,7 @@ fn this_expanded(expanded: &HashSet<u64>, id: u64) -> bool {
/// A small toggle button for the view switcher.
fn toggle_button(
id: &'static str,
label: &'static str,
label: impl Into<gpui::SharedString>,
active: bool,
colors: &gpui::colors::Colors,
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
@@ -438,7 +438,7 @@ fn toggle_button(
})
.cursor_pointer()
.on_click(on_click)
.child(label)
.child(label.into())
}
#[cfg(test)]
+5 -5
View File
@@ -266,7 +266,7 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
.items_center()
.gap_2()
.px_2()
.py_1()
.py_0p5()
.bg(colors.container)
.child(transport_button(
"gpui-widgets-viewer-in",
@@ -362,7 +362,7 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
);
}),
))
.child(div().px_2().text_color(colors.text).child(timecode))
.child(div().px_2().text_xs().text_color(colors.text).child(timecode))
.child(div().flex_1())
.child(button(
"gpui-widgets-viewer-safe",
@@ -413,8 +413,8 @@ fn transport_button(
let mut el = div()
.id(id)
.debug_selector(move || id.into())
.w(px(24.0))
.h(px(24.0))
.w(px(22.0))
.h(px(22.0))
.flex()
.items_center()
.justify_center()
@@ -471,7 +471,7 @@ fn button(
.id(id)
.debug_selector(move || id.into())
.px_2()
.py_1()
.py_0p5()
.rounded_md()
.cursor_pointer()
.hover(|style| style.bg(gpui::colors::Colors::dark().selected))