diff --git a/crates/gpui/src/dock/tab_bar.rs b/crates/gpui/src/dock/tab_bar.rs index 8a4602ecb8..d04cf423f5 100644 --- a/crates/gpui/src/dock/tab_bar.rs +++ b/crates/gpui/src/dock/tab_bar.rs @@ -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, 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 { diff --git a/crates/gpui/src/timeline/timeline_view.rs b/crates/gpui/src/timeline/timeline_view.rs index 3dfe228e7d..ded6cf5861 100644 --- a/crates/gpui/src/timeline/timeline_view.rs +++ b/crates/gpui/src/timeline/timeline_view.rs @@ -227,6 +227,8 @@ pub struct TimelineView { pub state: TimelineState, /// The set of tracks selected via their headers. selected_tracks: BTreeSet, + /// Rich clip content (thumbnails / waveforms), replaced by the host. + decorator: std::sync::Arc>, focus_handle: FocusHandle, } @@ -253,10 +255,27 @@ impl TimelineView { 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>, + ) -> 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>) { + self.decorator = decorator; + } + /// The set of tracks selected via header clicks. pub fn selected_tracks(&self) -> &BTreeSet { &self.selected_tracks @@ -731,7 +750,7 @@ impl Render for TimelineView { // (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> = Arc::new(RwLock::new(NoopClipDecorator)); + let decorator = self.decorator.clone(); let ruler = div() .flex_row() diff --git a/crates/gpui_widgets/src/menu/mod.rs b/crates/gpui_widgets/src/menu/mod.rs index 7289d6882c..f8a6da38cc 100644 --- a/crates/gpui_widgets/src/menu/mod.rs +++ b/crates/gpui_widgets/src/menu/mod.rs @@ -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 { + 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, events: Vec, @@ -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); diff --git a/crates/gpui_widgets/src/project_explorer.rs b/crates/gpui_widgets/src/project_explorer.rs index bf27fb1c92..0670b187e0 100644 --- a/crates/gpui_widgets/src/project_explorer.rs +++ b/crates/gpui_widgets/src/project_explorer.rs @@ -229,7 +229,7 @@ impl Render for ProjectExplorer { .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 Render for ProjectExplorer { )) .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, id: u64) -> bool { /// A small toggle button for the view switcher. fn toggle_button( id: &'static str, - label: &'static str, + label: impl Into, 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)] diff --git a/crates/gpui_widgets/src/viewer/mod.rs b/crates/gpui_widgets/src/viewer/mod.rs index 8d11ed47b3..92d99f33e6 100644 --- a/crates/gpui_widgets/src/viewer/mod.rs +++ b/crates/gpui_widgets/src/viewer/mod.rs @@ -266,7 +266,7 @@ impl Render for ViewerWidget { .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 Render for ViewerWidget { ); }), )) - .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))