diff --git a/apps/desktop-gpui/src/ui/menu.rs b/apps/desktop-gpui/src/ui/menu.rs index 01bbd8af44c..e57dab93e20 100644 --- a/apps/desktop-gpui/src/ui/menu.rs +++ b/apps/desktop-gpui/src/ui/menu.rs @@ -13,13 +13,19 @@ use std::{cell::Cell, rc::Rc}; use gpui::{ - Anchor, App, Bounds, ClickEvent, ElementId, Hsla, InteractiveElement, IntoElement, - ParentElement, Pixels, Point, RenderOnce, SharedString, Size, StatefulInteractiveElement, - Styled, Window, div, point, prelude::FluentBuilder, px, svg, + Anchor, App, Bounds, ClickEvent, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton, + MouseUpEvent, ParentElement, Pixels, Point, RenderOnce, ScrollHandle, SharedString, Size, + StatefulInteractiveElement, Styled, TextStyle, WhiteSpace, Window, div, point, + prelude::FluentBuilder, px, svg, }; use crate::theme::Theme; +const MENU_ROW_HEIGHT: f32 = 24.; +const MENU_PADDING: f32 = 4.; +const MENU_BORDER: f32 = 1.; +const MENU_CHROME_HEIGHT: f32 = 2. * (MENU_PADDING + MENU_BORDER); + pub(crate) type OpenHandler = Box, &mut Window, &mut App) + 'static>; /// One row: a label and whether it is the value currently in force. @@ -294,7 +300,7 @@ fn anchored_placement( let margin = px(12.); let below = (viewport.height - margin - trigger.bottom() - gap).max(px(0.)); let above = (trigger.top() - gap - margin).max(px(0.)); - let height = (px(item_count as f32 * 24. + 10.)).min(max_height); + let height = px(item_count as f32 * MENU_ROW_HEIGHT + MENU_CHROME_HEIGHT).min(max_height); if height <= below || below >= above { ( trigger.bottom_left() + point(px(0.), gap), @@ -310,6 +316,54 @@ fn anchored_placement( } } +// Offscreen labels still determine the menu's intrinsic width. +#[derive(Default)] +struct MenuLayoutState { + scroll: ScrollHandle, + pressed: Rc>, + labels: Vec, + style: Option, + widest: usize, +} + +impl MenuLayoutState { + fn widest(&mut self, items: &[MenuItem], style: TextStyle, window: &Window) -> usize { + if self.style.as_ref() != Some(&style) + || self.labels.len() != items.len() + || self + .labels + .iter() + .zip(items) + .any(|(label, item)| label != &item.label) + { + let mut widest = 0; + let mut width = px(0.); + for (index, item) in items.iter().enumerate() { + let runs = [style.to_run(item.label.len())]; + if let Ok(lines) = window.text_system().shape_text( + item.label.clone(), + px(12.), + &runs, + None, + style.line_clamp, + ) { + for line in lines { + let line_width = line.size(px(MENU_ROW_HEIGHT)).width.ceil(); + if line_width > width { + width = line_width; + widest = index; + } + } + } + } + self.labels = items.iter().map(|item| item.label.clone()).collect(); + self.style = Some(style); + self.widest = widest; + } + self.widest + } +} + impl RenderOnce for Menu { fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { let Menu { @@ -343,6 +397,124 @@ impl RenderOnce for Menu { .map_or((origin, Anchor::TopLeft, max_height), |bounds| { anchored_placement(bounds, items.len(), max_height, viewport) }); + let mut style = window.text_style(); + style.font_size = px(12.).into(); + style.color = text; + style.white_space = WhiteSpace::Nowrap; + let line_height = window.pixel_snap( + style + .line_height + .to_pixels(px(12.).into(), window.rem_size()), + ); + // Tall labels can overlap adjacent rows, so row-box culling would hide visible text. + let virtualized = !items.is_empty() + && max_height > px(MENU_CHROME_HEIGHT) + && px(items.len() as f32 * MENU_ROW_HEIGHT + MENU_CHROME_HEIGHT) > max_height + && line_height > px(0.) + && line_height <= px(MENU_ROW_HEIGHT) + && items.iter().all(|item| !item.label.contains('\n')); + let (widest, scroll, pressed) = window.with_element_namespace(id.clone(), |window| { + window.with_global_id("menu-layout".into(), |global_id, window| { + window.with_element_state(global_id, |state: Option, window| { + let mut state = state.unwrap_or_default(); + // Keep GPUI's pending click state alive if a pressed row scrolls offscreen. + let widest = (virtualized && !state.pressed.get()) + .then(|| state.widest(&items, style, window)); + ((widest, state.scroll.clone(), state.pressed.clone()), state) + }) + }) + }); + let row = move |row_prefix: &SharedString, index: usize, item: MenuItem| { + let handler = handler.clone(); + div() + .id(SharedString::from(format!("{row_prefix}-item-{index}"))) + .flex() + .flex_row() + .flex_shrink_0() + .items_center() + .gap(px(6.)) + .h(px(MENU_ROW_HEIGHT)) + .px(px(6.)) + .rounded(px(4.)) + .when(highlighted == Some(index), |this| this.bg(hover)) + .hover(move |style| style.bg(hover)) + .child( + div().w(px(12.)).flex_shrink_0().children( + item.checked + .then(|| svg().path("icons/check.svg").size(px(12.)).text_color(text)), + ), + ) + .child(div().flex_1().min_w_0().truncate().child(item.label)) + .when_some(handler, |this, handler| { + this.on_click(move |_, window, cx| handler(&index, window, cx)) + }) + }; + let pressed_on_down = pressed.clone(); + let end_press = move |_: &MouseUpEvent, window: &mut Window, _: &mut App| { + if pressed.replace(false) { + window.refresh(); + } + }; + let panel = div() + .id(id.clone()) + .occlude() + .flex() + .flex_col() + .min_w(min_width.min(max_width)) + .max_w(max_width) + .max_h(max_height) + .overflow_y_scroll() + .track_scroll(&scroll) + .on_mouse_down(MouseButton::Left, move |_, window, _| { + pressed_on_down.set(true); + window.refresh(); + }) + .on_mouse_up(MouseButton::Left, end_press.clone()) + .on_mouse_up_out(MouseButton::Left, end_press) + .p(px(MENU_PADDING)) + .rounded(radius) + .shadow(shadow) + .border(px(MENU_BORDER)) + .border_color(border) + .bg(bg) + .text_color(text) + .text_size(px(12.)); + let panel = if let Some(widest) = widest { + let scroll_top = (-scroll.offset().y).clamp( + px(0.), + (px(items.len() as f32 * MENU_ROW_HEIGHT) - (max_height - px(MENU_CHROME_HEIGHT))) + .max(px(0.)), + ); + let start = + ((f32::from(scroll_top) / MENU_ROW_HEIGHT).floor() as usize).saturating_sub(1); + let end = ((f32::from(scroll_top + max_height) / MENU_ROW_HEIGHT).ceil() as usize) + .saturating_add(1) + .min(items.len()); + let width_prefix: SharedString = format!("{prefix}-width-row").into(); + panel + .child( + div() + .id(SharedString::from(format!("{prefix}-intrinsic"))) + .h(px(0.)) + .flex_shrink_0() + .overflow_hidden() + .child(row(&width_prefix, widest, items[widest].clone())), + ) + .child(div().h(px(start as f32 * MENU_ROW_HEIGHT)).flex_shrink_0()) + .children((start..end).map(|index| row(&prefix, index, items[index].clone()))) + .child( + div() + .h(px((items.len() - end) as f32 * MENU_ROW_HEIGHT)) + .flex_shrink_0(), + ) + } else { + panel.children( + items + .into_iter() + .enumerate() + .map(|(index, item)| row(&prefix, index, item)), + ) + }; div() .absolute() @@ -350,7 +522,6 @@ impl RenderOnce for Menu { .left_0() .size_full() .child( - // Click-away dismiss, the way a native menu closes. div() .id(SharedString::from(format!("{prefix}-backdrop"))) .occlude() @@ -367,54 +538,7 @@ impl RenderOnce for Menu { .position(origin) .anchor(anchor) .snap_to_window_with_margin(px(12.)) - .child( - div() - .id(id) - .occlude() - .flex() - .flex_col() - .min_w(min_width.min(max_width)) - .max_w(max_width) - .max_h(max_height) - .overflow_y_scroll() - .p(px(4.)) - .rounded(radius) - .shadow(shadow) - .border_1() - .border_color(border) - .bg(bg) - .text_color(text) - .text_size(px(12.)) - .children(items.into_iter().enumerate().map(|(index, item)| { - let handler = handler.clone(); - div() - .id(SharedString::from(format!("{prefix}-item-{index}"))) - .flex() - .flex_row() - .flex_shrink_0() - .items_center() - .gap(px(6.)) - .h(px(24.)) - .px(px(6.)) - .rounded(px(4.)) - .when(highlighted == Some(index), |this| this.bg(hover)) - .hover(move |style| style.bg(hover)) - .child(div().w(px(12.)).flex_shrink_0().children( - item.checked.then(|| { - svg() - .path("icons/check.svg") - .size(px(12.)) - .text_color(text) - }), - )) - .child(div().flex_1().min_w_0().truncate().child(item.label)) - .when_some(handler, |this, handler| { - this.on_click(move |_, window, cx| { - handler(&index, window, cx) - }) - }) - })), - ), + .child(panel), ) } } @@ -562,3 +686,196 @@ mod tests { assert_eq!(menu.highlighted, Some(0)); } } + +#[cfg(test)] +mod virtual_menu_tests { + use super::*; + use gpui::{ + AvailableSpace, FontFallbacks, FontFeatures, FontStyle, FontWeight, Modifiers, ScrollDelta, + ScrollWheelEvent, TestAppContext, TouchPhase, VisualTestContext, WindowHandle, size, + }; + + fn row_width(item: &MenuItem, style: &TextStyle, window: &mut Window, cx: &mut App) -> Pixels { + let mut label = div() + .flex_1() + .min_w_0() + .truncate() + .child(item.label.clone()); + label.text_style().line_clamp = style.line_clamp; + let mut row = div() + .flex() + .flex_row() + .flex_shrink_0() + .items_center() + .gap(px(6.)) + .h(px(24.)) + .px(px(6.)) + .font(style.font()) + .line_height(style.line_height) + .text_size(px(12.)) + .text_color(style.color) + .child(div().w(px(12.)).flex_shrink_0()) + .child(label) + .into_any_element(); + row.layout_as_root( + size(AvailableSpace::MaxContent, AvailableSpace::MinContent), + window, + cx, + ) + .width + } + + #[gpui::test] + fn menu_width_uses_the_widest_actual_row(cx: &mut TestAppContext) { + let window = cx.open_window(size(px(800.), px(600.)), |_, _| gpui::Empty); + let mut visual = VisualTestContext::from_window(window.into(), cx); + visual.draw( + point(px(0.), px(0.)), + size(AvailableSpace::MaxContent, AvailableSpace::MaxContent), + |window, cx| { + let items = [ + "", + "Small", + "Device\tTab\tName", + "日本語のフォント", + "اسم جهاز בעברית", + "e\u{301} and ligatures ffi", + "short\nA much wider second line of the label", + ] + .into_iter() + .map(|label| MenuItem::new(label, false)) + .collect::>(); + let mut cache = MenuLayoutState::default(); + for line_clamp in [None, Some(1), Some(2)] { + let style = TextStyle { + font_family: "Menu test family".into(), + font_features: FontFeatures::disable_ligatures(), + font_fallbacks: Some(FontFallbacks::from_fonts(vec![ + "CJK fallback".into(), + "RTL fallback".into(), + ])), + font_weight: FontWeight::BOLD, + font_style: FontStyle::Italic, + font_size: px(12.).into(), + white_space: WhiteSpace::Nowrap, + line_clamp, + ..TextStyle::default() + }; + let widest = cache.widest(&items, style.clone(), window); + let widths = items + .iter() + .map(|item| row_width(item, &style, window, cx)) + .collect::>(); + assert_eq!(widths[widest], widths.iter().copied().max().unwrap()); + } + div() + }, + ); + } + + #[derive(Default)] + struct HeldClickMenu { + selected: Option, + } + + impl gpui::Render for HeldClickMenu { + fn render(&mut self, _: &mut Window, cx: &mut gpui::Context) -> impl IntoElement { + let items = (0..200) + .map(|index| MenuItem::new(format!("Choice {index}"), false)) + .collect::>(); + let state = MenuState::new(point(px(100.), px(100.)), &items); + div().size_full().relative().child( + Menu::editor(&Theme::light(), "held-click-menu", items, &state).on_select( + cx.listener(|this, index, _, cx| { + this.selected = Some(*index); + cx.notify(); + }), + ), + ) + } + } + + fn draw_held_menu(cx: &mut TestAppContext, window: WindowHandle) { + VisualTestContext::from_window(window.into(), cx).update(|window, cx| { + window.refresh(); + window.draw(cx).clear(); + }); + } + + fn scroll_held_menu( + cx: &mut TestAppContext, + window: WindowHandle, + position: Point, + delta: f32, + ) { + VisualTestContext::from_window(window.into(), cx).simulate_event(ScrollWheelEvent { + position, + delta: ScrollDelta::Pixels(point(px(0.), px(delta))), + modifiers: Modifiers::none(), + touch_phase: TouchPhase::Moved, + }); + for _ in 0..3 { + draw_held_menu(cx, window); + } + } + + #[gpui::test] + fn a_menu_click_survives_scrolling_away_and_back_while_held(cx: &mut TestAppContext) { + let window = cx.open_window(size(px(1000.), px(700.)), |_, _| HeldClickMenu::default()); + let fourth_row_center = point(px(140.), px(189.)); + draw_held_menu(cx, window); + { + let mut visual = VisualTestContext::from_window(window.into(), cx); + visual.simulate_mouse_move(fourth_row_center, None, Modifiers::none()); + visual.simulate_mouse_down(fourth_row_center, MouseButton::Left, Modifiers::none()); + } + draw_held_menu(cx, window); + scroll_held_menu(cx, window, fourth_row_center, -100_000.); + scroll_held_menu(cx, window, fourth_row_center, 100_000.); + assert_eq!(window.read_with(cx, |menu, _| menu.selected).unwrap(), None); + VisualTestContext::from_window(window.into(), cx).simulate_mouse_up( + fourth_row_center, + MouseButton::Left, + Modifiers::none(), + ); + assert_eq!( + window.read_with(cx, |menu, _| menu.selected).unwrap(), + Some(3) + ); + } + + #[gpui::test] + fn releasing_a_held_menu_click_outside_cancels_it_without_breaking_the_next_click( + cx: &mut TestAppContext, + ) { + let window = cx.open_window(size(px(1000.), px(700.)), |_, _| HeldClickMenu::default()); + let fourth_row_center = point(px(140.), px(189.)); + draw_held_menu(cx, window); + { + let mut visual = VisualTestContext::from_window(window.into(), cx); + visual.simulate_mouse_move(fourth_row_center, None, Modifiers::none()); + visual.simulate_mouse_down(fourth_row_center, MouseButton::Left, Modifiers::none()); + } + draw_held_menu(cx, window); + scroll_held_menu(cx, window, fourth_row_center, -100_000.); + VisualTestContext::from_window(window.into(), cx).simulate_mouse_up( + point(px(20.), px(20.)), + MouseButton::Left, + Modifiers::none(), + ); + draw_held_menu(cx, window); + assert_eq!(window.read_with(cx, |menu, _| menu.selected).unwrap(), None); + VisualTestContext::from_window(window.into(), cx).simulate_mouse_move( + fourth_row_center, + None, + Modifiers::none(), + ); + scroll_held_menu(cx, window, fourth_row_center, 100_000.); + VisualTestContext::from_window(window.into(), cx) + .simulate_click(fourth_row_center, Modifiers::none()); + assert_eq!( + window.read_with(cx, |menu, _| menu.selected).unwrap(), + Some(3) + ); + } +}