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