From 333d7e3c46aa799efe7342d061c2509bb308da6f Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 9 Jun 2026 21:54:56 +0800 Subject: [PATCH 1/4] native_menu: fix macOS re-popup and drop input RightClick menu - macos: refresh the window after the AppKit tracking loop returns so a dismissed native menu does not leave the window idle and unresponsive to a second right-click (covers the dismiss-without-selection path) - input: remove the RightClick variant from the popover ContextMenu enum Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ui/src/input/popovers/mod.rs | 5 ----- crates/ui/src/native_menu/macos.rs | 16 +++++++++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/crates/ui/src/input/popovers/mod.rs b/crates/ui/src/input/popovers/mod.rs index 07360b8774..2d9013c0ff 100644 --- a/crates/ui/src/input/popovers/mod.rs +++ b/crates/ui/src/input/popovers/mod.rs @@ -1,12 +1,10 @@ mod code_action_menu; mod completion_menu; -mod context_menu; mod diagnostic_popover; mod hover_popover; pub(crate) use code_action_menu::*; pub(crate) use completion_menu::*; -pub(crate) use context_menu::*; pub(crate) use diagnostic_popover::*; pub(crate) use hover_popover::*; @@ -23,7 +21,6 @@ use crate::{ pub(crate) enum ContextMenu { Completion(Entity), CodeAction(Entity), - RightClick(Entity), } impl ContextMenu { @@ -31,7 +28,6 @@ impl ContextMenu { match self { ContextMenu::Completion(menu) => menu.read(cx).is_open(), ContextMenu::CodeAction(menu) => menu.read(cx).is_open(), - ContextMenu::RightClick(menu) => menu.read(cx).is_open(), } } @@ -39,7 +35,6 @@ impl ContextMenu { match self { ContextMenu::Completion(menu) => menu.clone().into_any_element(), ContextMenu::CodeAction(menu) => menu.clone().into_any_element(), - ContextMenu::RightClick(menu) => menu.clone().into_any_element(), } } } diff --git a/crates/ui/src/native_menu/macos.rs b/crates/ui/src/native_menu/macos.rs index 0c22758dd5..93fe86da1b 100644 --- a/crates/ui/src/native_menu/macos.rs +++ b/crates/ui/src/native_menu/macos.rs @@ -60,12 +60,18 @@ pub(super) fn show( let handle = Window::window_handle(window); cx.spawn(async move |cx| { - let Some(action) = run_menu(view_ptr, &items, position) else { - return; - }; - cx.update(move |app| { + let action = run_menu(view_ptr, &items, position); + let _ = cx.update(move |app| { let _ = handle.update(app, move |_, window, app| { - window.dispatch_action(action, app); + if let Some(action) = action { + window.dispatch_action(action, app); + } + // Wake GPUI after the AppKit tracking loop returns so the window + // resumes painting and re-registers its mouse handlers. Without + // this, a dismissed menu (especially when nothing is selected) + // leaves the window idle and unresponsive to a second + // right-click. + window.refresh(); }); }); }) From 6dbe453ad7900917180c0a84f1b31e37831f42e1 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 10 Jun 2026 10:40:59 +0800 Subject: [PATCH 2/4] native_menu: fix build for context_menu -> NativeMenu migration Migrate remaining PopupMenu-based callers to popup_context_menu, drop the removed Input custom context menu API and the orphaned InputContextMenu, and update stories accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/story/src/stories/input_story.rs | 17 +- crates/story/src/stories/menu_story.rs | 6 +- crates/ui/src/input/input.rs | 22 +- crates/ui/src/input/lsp/mod.rs | 1 - crates/ui/src/input/popovers/context_menu.rs | 160 --------------- crates/ui/src/input/state.rs | 101 +++++++--- crates/ui/src/menu/context_menu.rs | 202 ++++++++++++++++--- crates/ui/src/menu/mod.rs | 2 +- crates/ui/src/menu/popup_menu.rs | 6 - crates/ui/src/sidebar/menu.rs | 2 +- crates/ui/src/table/state.rs | 2 +- crates/ui/src/tree.rs | 2 +- 12 files changed, 250 insertions(+), 273 deletions(-) delete mode 100644 crates/ui/src/input/popovers/context_menu.rs diff --git a/crates/story/src/stories/input_story.rs b/crates/story/src/stories/input_story.rs index 8b0403bb67..015704d9cd 100644 --- a/crates/story/src/stories/input_story.rs +++ b/crates/story/src/stories/input_story.rs @@ -27,7 +27,6 @@ pub struct InputStory { mask_input2: Entity, currency_input: Entity, custom_input: Entity, - custom_menu_input: Entity, code_input: Entity, color_input: Entity, @@ -93,14 +92,9 @@ impl InputStory { }) }); let custom_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Custom Input use monospace, 0123456789.") - .context_menu(false) + InputState::new(window, cx).placeholder("Custom Input use monospace, 0123456789.") }); - let custom_menu_input = cx - .new(|cx| InputState::new(window, cx).placeholder("Input with custom context menu...")); - let color_input = cx.new(|cx| { InputState::new(window, cx) .placeholder("Type something...") @@ -153,7 +147,6 @@ impl InputStory { mask_input2, currency_input, custom_input, - custom_menu_input, code_input, color_input, input_text_centered, @@ -314,14 +307,6 @@ impl Render for InputStory { .child(Input::new(&self.custom_input).appearance(false)), ), ) - .child(section("Custom Context Menu").max_w_md().child( - Input::new(&self.custom_menu_input).context_menu(|menu, _, _| { - menu.menu("Custom Action", Box::new(input::SelectAll)) - .separator() - .menu("Copy", Box::new(input::Copy)) - .menu("Paste", Box::new(input::Paste)) - }), - )) .child( section("Custom Text Color") .max_w_md() diff --git a/crates/story/src/stories/menu_story.rs b/crates/story/src/stories/menu_story.rs index c77d270a63..ac14472ace 100644 --- a/crates/story/src/stories/menu_story.rs +++ b/crates/story/src/stories/menu_story.rs @@ -238,7 +238,7 @@ impl Render for MenuStory { .border_dashed() .border_color(cx.theme().border) .child("Right click to open ContextMenu") - .context_menu({ + .popup_context_menu({ move |this, window, cx| { this.check_side(check_side.unwrap_or(Side::Left)) .external_link_icon(false) @@ -293,7 +293,7 @@ impl Render for MenuStory { .border_dashed() .border_color(cx.theme().border) .child("Here is another area with context menu.") - .context_menu({ + .popup_context_menu({ move |this, _, _| { this.link( "About", @@ -318,7 +318,7 @@ impl Render for MenuStory { .border_dashed() .border_color(cx.theme().border) .child("ContextMenu area 1") - .context_menu({ + .popup_context_menu({ move |this, _, _| { this.link( "About", diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index 032c56e06b..0e894fe41e 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -1,15 +1,12 @@ -use std::rc::Rc; - use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, Context, DefiniteLength, Edges, EdgesRefinement, Entity, Hsla, + AnyElement, App, DefiniteLength, Edges, EdgesRefinement, Entity, Hsla, InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement, Styled, TextAlign, Window, div, px, relative, }; use crate::button::{Button, ButtonVariants as _}; use crate::input::clear_button; -use crate::menu::PopupMenu; use crate::spinner::Spinner; use crate::{ActiveTheme, Colorize, v_flex}; use crate::{IconName, Size}; @@ -47,12 +44,6 @@ pub struct Input { focus_bordered: bool, tab_index: isize, selected: bool, - - /// An optional context menu builder to allow a custom context menu on the input. - /// - /// If set, this will override the built-in context menu. - context_menu_builder: - Option) -> PopupMenu>>, } impl Sizable for Input { @@ -91,7 +82,6 @@ impl Input { focus_bordered: true, tab_index: 0, selected: false, - context_menu_builder: None, } } @@ -159,15 +149,6 @@ impl Input { self } - /// Sets the context menu for the input. - pub fn context_menu( - mut self, - f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> Self { - self.context_menu_builder = Some(Rc::new(f)); - self - } - fn render_toggle_mask_button(state: &Entity, cx: &App) -> impl IntoElement { let masked = state.read(cx).masked; Button::new("toggle-mask") @@ -246,7 +227,6 @@ impl RenderOnce for Input { let text_align = self.style.text.text_align.unwrap_or(TextAlign::Left); self.state.update(cx, |state, _| { - state.context_menu_builder = self.context_menu_builder.clone(); state.disabled = self.disabled; state.size = self.size; diff --git a/crates/ui/src/input/lsp/mod.rs b/crates/ui/src/input/lsp/mod.rs index e77e273c15..b7ce1b54ba 100644 --- a/crates/ui/src/input/lsp/mod.rs +++ b/crates/ui/src/input/lsp/mod.rs @@ -127,7 +127,6 @@ impl InputState { handled = menu.handle_action(action, window, cx) }); } - ContextMenu::RightClick(..) => {} }; handled diff --git a/crates/ui/src/input/popovers/context_menu.rs b/crates/ui/src/input/popovers/context_menu.rs deleted file mode 100644 index 565f61f1d2..0000000000 --- a/crates/ui/src/input/popovers/context_menu.rs +++ /dev/null @@ -1,160 +0,0 @@ -use gpui::{ - Anchor, App, AppContext as _, Context, DismissEvent, Entity, IntoElement, MouseDownEvent, - ParentElement as _, Pixels, Point, Render, Styled, Subscription, Window, anchored, deferred, - div, prelude::FluentBuilder as _, px, -}; -use rust_i18n::t; - -use crate::{ - ActiveTheme as _, - global_state::GlobalState, - input::{self, InputState, popovers::ContextMenu}, - menu::PopupMenu, -}; - -/// Context menu for mouse right clicks. -pub(crate) struct InputContextMenu { - editor: Entity, - menu: Entity, - mouse_position: Point, - open: bool, - - _subscriptions: Vec, -} - -impl InputState { - pub(crate) fn handle_right_click_menu( - &mut self, - event: &MouseDownEvent, - offset: usize, - window: &mut Window, - cx: &mut Context, - ) { - // Check if we are already in a deferred context (e.g., inside a Popover) - // If so, don't show the context menu to prevent double-deferred panic - if GlobalState::global(cx).is_in_deferred_context() { - return; - } - - // Show Mouse context menu - if !self.selected_range.contains(offset) { - self.move_to(offset, None, cx); - } - - self.context_menu_content = Some(ContextMenu::RightClick(self.context_menu.clone())); - - let is_code_editor = self.mode.is_code_editor(); - if is_code_editor { - self.handle_hover_definition(offset, window, cx); - } - - let is_enable = !self.disabled; - let has_goto_definition = is_enable && self.lsp.definition_provider.is_some(); - let has_code_action = is_enable && !self.lsp.code_action_providers.is_empty(); - let is_selected = !self.selected_range.is_empty(); - let has_paste = is_enable && cx.read_from_clipboard().is_some(); - - let action_context = self.focus_handle.clone(); - self.context_menu.update(cx, |this, cx| { - this.mouse_position = event.position; - this.menu.update(cx, |menu, cx| { - let new_menu = if let Some(builder) = &self.context_menu_builder { - builder(PopupMenu::new(cx), window, cx) - } else { - PopupMenu::new(cx) - .when(is_code_editor, |m| { - m.menu_with_enable( - t!("Input.Go to Definition"), - Box::new(input::GoToDefinition), - has_goto_definition, - ) - .menu_with_enable( - t!("Input.Show Code Actions"), - Box::new(input::ToggleCodeActions), - has_code_action, - ) - .separator() - }) - .menu_with_enable( - t!("Input.Cut"), - Box::new(input::Cut), - is_enable && is_selected, - ) - .menu_with_enable(t!("Input.Copy"), Box::new(input::Copy), is_selected) - .menu_with_enable(t!("Input.Paste"), Box::new(input::Paste), has_paste) - .separator() - .menu(t!("Input.Select All"), Box::new(input::SelectAll)) - }; - - menu.menu_items = new_menu.menu_items; - menu.action_context = Some(action_context); - cx.notify(); - }); - cx.defer_in(window, |this, _, cx| { - this.open = true; - cx.notify(); - }); - }); - } -} - -impl InputContextMenu { - pub(crate) fn new( - editor: Entity, - window: &mut Window, - cx: &mut App, - ) -> Entity { - cx.new(|cx| { - let menu = cx.new(|cx| PopupMenu::new(cx).small()); - - let _subscriptions = vec![cx.subscribe_in(&menu, window, { - move |this: &mut Self, _, _: &DismissEvent, window, cx| { - this.close(window, cx); - } - })]; - - Self { - editor, - menu, - mouse_position: Point::default(), - open: false, - _subscriptions, - } - }) - } - - #[inline] - pub(crate) fn is_open(&self) -> bool { - self.open - } - - #[inline] - pub(crate) fn close(&mut self, window: &mut Window, cx: &mut Context) { - self.open = false; - self.editor.update(cx, |this, cx| { - this.focus(window, cx); - }); - } -} - -impl Render for InputContextMenu { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - if !self.open { - return div().into_any_element(); - } - - deferred( - anchored() - .snap_to_window_with_margin(px(8.)) - .anchor(Anchor::TopLeft) - .position(self.mouse_position) - .child( - div() - .font_family(cx.theme().font_family.clone()) - .cursor_default() - .child(self.menu.clone()), - ), - ) - .into_any_element() - } -} diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index 64bd1accda..00218d9c20 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -40,10 +40,10 @@ use crate::input::{ HoverDefinition, InlineCompletion, Lsp, Position, RopeExt as _, Selection, display_map::LineLayout, element::RIGHT_MARGIN, - popovers::{ContextMenu, DiagnosticPopover, HoverPopover, InputContextMenu}, + popovers::{ContextMenu, DiagnosticPopover, HoverPopover}, search::SearchPanel, }; -use crate::menu::PopupMenu; +use crate::native_menu::NativeMenu; use crate::scroll::AutoScroll; use crate::{Root, history::History}; @@ -396,18 +396,6 @@ pub struct InputState { diagnostic_popover: Option>, /// Completion/CodeAction context menu pub(super) context_menu_content: Option, - pub(super) context_menu: Entity, - - /// An optional context menu builder to allow a custom context menu on the input. - /// - /// If set, this will override the built-in context menu and ignore the value set in [`Self::enable_context_menu`]. - pub(super) context_menu_builder: - Option) -> PopupMenu>>, - - /// Whether the context menu that shows on right-click is enabled. - /// - /// This value will be ignored if a context menu builder is defined in [`Self::context_menu_builder`]. - pub(super) enable_context_menu: bool, /// A flag to indicate if we are currently inserting a completion item. pub(super) completion_inserting: bool, @@ -469,7 +457,6 @@ impl InputState { ]; let text_style = window.text_style(); - let mouse_context_menu = InputContextMenu::new(cx.entity(), window, cx); Self { focus_handle: focus_handle.clone(), @@ -519,9 +506,6 @@ impl InputState { lsp: Lsp::default(), diagnostic_popover: None, context_menu_content: None, - context_menu: mouse_context_menu, - context_menu_builder: None, - enable_context_menu: true, completion_inserting: false, hover_popover: None, hover_definition: HoverDefinition::default(), @@ -579,15 +563,6 @@ impl InputState { self } - /// Sets whether the context menu that shows on right-click is enabled. - /// - /// The context menu is enabled by default. - /// This value will be ignored if a custom context menu is defined on the input. - pub fn context_menu(mut self, enable: bool) -> Self { - self.enable_context_menu = enable; - self - } - /// Set this input is searchable, default is false (Default true for Code Editor). pub fn searchable(mut self, searchable: bool) -> Self { debug_assert!(self.mode.is_multi_line()); @@ -1503,6 +1478,74 @@ impl InputState { cx.propagate(); } + /// Show the right-click context menu as a native OS menu. + pub(crate) fn handle_right_click_menu( + &mut self, + event: &MouseDownEvent, + offset: usize, + window: &mut Window, + cx: &mut Context, + ) { + if crate::global_state::GlobalState::global(cx).is_in_deferred_context() { + return; + } + + if !self.selected_range.contains(offset) { + self.move_to(offset, None, cx); + } + + let is_code_editor = self.mode.is_code_editor(); + if is_code_editor { + self.handle_hover_definition(offset, window, cx); + } + + let is_enable = !self.disabled; + let has_goto_definition = is_enable && self.lsp.definition_provider.is_some(); + let has_code_action = is_enable && !self.lsp.code_action_providers.is_empty(); + let is_selected = !self.selected_range.is_empty(); + let has_paste = is_enable && cx.read_from_clipboard().is_some(); + + let mut menu = NativeMenu::new(); + if is_code_editor { + menu = menu + .menu_with_disabled( + rust_i18n::t!("Input.Go to Definition"), + !has_goto_definition, + Box::new(crate::input::GoToDefinition), + ) + .menu_with_disabled( + rust_i18n::t!("Input.Show Code Actions"), + !has_code_action, + Box::new(crate::input::ToggleCodeActions), + ) + .separator(); + } + + menu = menu + .menu_with_disabled( + rust_i18n::t!("Input.Cut"), + !(is_enable && is_selected), + Box::new(crate::input::Cut), + ) + .menu_with_disabled( + rust_i18n::t!("Input.Copy"), + !is_selected, + Box::new(crate::input::Copy), + ) + .menu_with_disabled( + rust_i18n::t!("Input.Paste"), + !has_paste, + Box::new(crate::input::Paste), + ) + .separator() + .menu( + rust_i18n::t!("Input.Select All"), + Box::new(crate::input::SelectAll), + ); + + menu.show(event.position, window, cx); + } + pub(super) fn on_mouse_down( &mut self, event: &MouseDownEvent, @@ -1545,9 +1588,7 @@ impl InputState { // Show Mouse context menu if event.button == MouseButton::Right { - if self.enable_context_menu || self.context_menu_builder.is_some() { - self.handle_right_click_menu(event, offset, window, cx); - } + self.handle_right_click_menu(event, offset, window, cx); return; } diff --git a/crates/ui/src/menu/context_menu.rs b/crates/ui/src/menu/context_menu.rs index 73b5a553b1..256c371d58 100644 --- a/crates/ui/src/menu/context_menu.rs +++ b/crates/ui/src/menu/context_menu.rs @@ -8,22 +8,21 @@ use gpui::{ }; use crate::menu::PopupMenu; +use crate::native_menu::NativeMenu; /// A extension trait for adding a context menu to an element. pub trait ContextMenuExt: InteractiveElement + ParentElement + Styled { /// Add a context menu to the element. /// - /// This will changed the element to be `relative` positioned, and add a child `ContextMenu` element. - /// Because the `ContextMenu` element is positioned `absolute`, it will not affect the layout of the parent element. + /// The menu is built lazily on right-click and shown as a native OS menu + /// via [`NativeMenu`], so it is not clipped to the window bounds. fn context_menu( mut self, - f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, + f: impl Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu + 'static, ) -> ContextMenu where Self: Sized, { - // Generate a unique ID based on the element's memory address to ensure - // each context menu has its own state and doesn't share with others let id = self .interactivity() .element_id @@ -32,21 +31,169 @@ pub trait ContextMenuExt: InteractiveElement + ParentElement + Styled { .unwrap_or_else(|| format!("context-menu-{:p}", &self as *const _)); ContextMenu::new(id, self).menu(f) } + + /// Add a context menu to the element, rendered as a GPUI [`PopupMenu`]. + /// + /// Unlike [`Self::context_menu`] (which uses a native OS menu), this draws + /// the menu with GPUI so it supports icons, links, custom elements, etc., + /// at the cost of being clipped to the window bounds. + fn popup_context_menu( + mut self, + f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, + ) -> PopupContextMenu + where + Self: Sized, + { + let id = self + .interactivity() + .element_id + .clone() + .map(|id| format!("context-menu-{:?}", id)) + .unwrap_or_else(|| format!("context-menu-{:p}", &self as *const _)); + PopupContextMenu::new(id, self).menu(f) + } } impl ContextMenuExt for E {} -/// A context menu that can be shown on right-click. +/// A context menu that can be shown on right-click, rendered as a native OS menu. pub struct ContextMenu { + id: ElementId, + element: Option, + menu: Option NativeMenu>>, + _ignore_style: StyleRefinement, +} + +impl ContextMenu { + /// Create a new context menu with the given ID. + pub fn new(id: impl Into, element: E) -> Self { + Self { + id: id.into(), + element: Some(element), + menu: None, + _ignore_style: StyleRefinement::default(), + } + } + + #[must_use] + fn menu(mut self, builder: F) -> Self + where + F: Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu + 'static, + { + self.menu = Some(Rc::new(builder)); + self + } +} + +impl ParentElement for ContextMenu { + fn extend(&mut self, elements: impl IntoIterator) { + if let Some(element) = &mut self.element { + element.extend(elements); + } + } +} + +impl Styled for ContextMenu { + fn style(&mut self) -> &mut StyleRefinement { + if let Some(element) = &mut self.element { + element.style() + } else { + &mut self._ignore_style + } + } +} + +impl IntoElement for ContextMenu { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for ContextMenu { + type RequestLayoutState = AnyElement; + type PrepaintState = Hitbox; + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let mut element = self + .element + .take() + .expect("Element should exists.") + .into_any_element(); + + let layout_id = element.request_layout(window, cx); + (layout_id, element) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + bounds: gpui::Bounds, + element: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + element.prepaint(window, cx); + window.insert_hitbox(bounds, HitboxBehavior::Normal) + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: gpui::Bounds, + element: &mut Self::RequestLayoutState, + hitbox: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + element.paint(window, cx); + + let Some(builder) = self.menu.clone() else { + return; + }; + let hitbox = hitbox.clone(); + + window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { + if phase.bubble() && event.button == MouseButton::Right && hitbox.is_hovered(window) { + let position = event.position; + let builder = builder.clone(); + + window.defer(cx, move |window, cx| { + let menu = builder(NativeMenu::new(), window, cx); + menu.show(position, window, cx); + }); + } + }); + } +} + +/// A context menu that can be shown on right-click, drawn by GPUI as a [`PopupMenu`]. +pub struct PopupContextMenu { id: ElementId, element: Option, menu: Option) -> PopupMenu>>, - // This is not in use, just for style refinement forwarding. _ignore_style: StyleRefinement, anchor: Anchor, } -impl ContextMenu { +impl PopupContextMenu { /// Create a new context menu with the given ID. pub fn new(id: impl Into, element: E) -> Self { Self { @@ -58,7 +205,6 @@ impl ContextMenu { } } - /// Build the context menu using the given builder function. #[must_use] fn menu(mut self, builder: F) -> Self where @@ -73,9 +219,9 @@ impl ContextMenu { id: &GlobalElementId, window: &mut Window, cx: &mut App, - f: impl FnOnce(&mut Self, &mut ContextMenuState, &mut Window, &mut App) -> R, + f: impl FnOnce(&mut Self, &mut PopupContextMenuState, &mut Window, &mut App) -> R, ) -> R { - window.with_optional_element_state::( + window.with_optional_element_state::( Some(id), |element_state, window| { let mut element_state = element_state.unwrap().unwrap_or_default(); @@ -86,7 +232,7 @@ impl ContextMenu { } } -impl ParentElement for ContextMenu { +impl ParentElement for PopupContextMenu { fn extend(&mut self, elements: impl IntoIterator) { if let Some(element) = &mut self.element { element.extend(elements); @@ -94,7 +240,7 @@ impl ParentElement for ContextMenu { } } -impl Styled for ContextMenu { +impl Styled for PopupContextMenu { fn style(&mut self) -> &mut StyleRefinement { if let Some(element) = &mut self.element { element.style() @@ -104,7 +250,7 @@ impl Styled for ContextMenu { } } -impl IntoElement for ContextMenu { +impl IntoElement for PopupContextMenu { type Element = Self; fn into_element(self) -> Self::Element { @@ -112,23 +258,23 @@ impl IntoElement for ContextM } } -struct ContextMenuSharedState { +struct PopupContextMenuSharedState { menu_view: Option>, open: bool, position: Point, _subscription: Option, } -pub struct ContextMenuState { +pub struct PopupContextMenuState { element: Option, - shared_state: Rc>, + shared_state: Rc>, } -impl Default for ContextMenuState { +impl Default for PopupContextMenuState { fn default() -> Self { Self { element: None, - shared_state: Rc::new(RefCell::new(ContextMenuSharedState { + shared_state: Rc::new(RefCell::new(PopupContextMenuSharedState { menu_view: None, open: false, position: Default::default(), @@ -138,8 +284,8 @@ impl Default for ContextMenuState { } } -impl Element for ContextMenu { - type RequestLayoutState = ContextMenuState; +impl Element for PopupContextMenu { + type RequestLayoutState = PopupContextMenuState; type PrepaintState = Hitbox; fn id(&self) -> Option { @@ -163,7 +309,7 @@ impl Element for ContextMenu< id.unwrap(), window, cx, - |this, state: &mut ContextMenuState, window, cx| { + |this, state: &mut PopupContextMenuState, window, cx| { let (position, open) = { let shared_state = state.shared_state.borrow(); (shared_state.position, shared_state.open) @@ -192,7 +338,6 @@ impl Element for ContextMenu< .snap_to_window_with_margin(px(8.)) .anchor(anchor) .when_some(menu_view, |this, menu| { - // Focus the menu, so that can be handle the action. if !menu .focus_handle(cx) .contains_focused(window, cx) @@ -222,7 +367,7 @@ impl Element for ContextMenu< ( layout_id, - ContextMenuState { + PopupContextMenuState { element: Some(element), ..Default::default() }, @@ -260,18 +405,16 @@ impl Element for ContextMenu< element.paint(window, cx); } - // Take the builder before setting up element state to avoid borrow issues let builder = self.menu.clone(); self.with_element_state( id.unwrap(), window, cx, - |_view, state: &mut ContextMenuState, window, _| { + |_view, state: &mut PopupContextMenuState, window, _| { let shared_state = state.shared_state.clone(); let hitbox = hitbox.clone(); - // When right mouse click, to build content menu, and show it at the mouse position. window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { if phase.bubble() && event.button == MouseButton::Right @@ -279,15 +422,12 @@ impl Element for ContextMenu< { { let mut shared_state = shared_state.borrow_mut(); - // Clear any existing menu view to allow immediate replacement - // Set the new position and open the menu shared_state.menu_view = None; shared_state._subscription = None; shared_state.position = event.position; shared_state.open = true; } - // Use defer to build the menu in the next frame, avoiding race conditions window.defer(cx, { let shared_state = shared_state.clone(); let builder = builder.clone(); @@ -299,7 +439,6 @@ impl Element for ContextMenu< build(menu, window, cx) }); - // Set up the subscription for dismiss handling let _subscription = window.subscribe(&menu, cx, { let shared_state = shared_state.clone(); move |_, _: &DismissEvent, window, _cx| { @@ -308,7 +447,6 @@ impl Element for ContextMenu< } }); - // Update the shared state with the built menu and subscription { let mut state = shared_state.borrow_mut(); state.menu_view = Some(menu.clone()); diff --git a/crates/ui/src/menu/mod.rs b/crates/ui/src/menu/mod.rs index 3152a15cb0..adbd742fae 100644 --- a/crates/ui/src/menu/mod.rs +++ b/crates/ui/src/menu/mod.rs @@ -7,7 +7,7 @@ mod menu_item; mod popup_menu; pub use app_menu_bar::AppMenuBar; -pub use context_menu::{ContextMenu, ContextMenuExt, ContextMenuState}; +pub use context_menu::{ContextMenu, ContextMenuExt, PopupContextMenu}; pub use dropdown_menu::DropdownMenu; pub use popup_menu::{PopupMenu, PopupMenuItem}; diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index bba2100e62..78d32b8903 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -656,12 +656,6 @@ impl PopupMenu { self } - /// Use small size, the menu item will have smaller height. - pub(crate) fn small(mut self) -> Self { - self.size = Size::Small; - self - } - fn add_menu_item( &mut self, label: impl Into, diff --git a/crates/ui/src/sidebar/menu.rs b/crates/ui/src/sidebar/menu.rs index 8fd72b0ab6..07f8468763 100644 --- a/crates/ui/src/sidebar/menu.rs +++ b/crates/ui/src/sidebar/menu.rs @@ -356,7 +356,7 @@ impl SidebarItem for SidebarMenuItem { }) .map(|this| { if let Some(context_menu) = self.context_menu { - this.context_menu(move |menu, window, cx| { + this.popup_context_menu(move |menu, window, cx| { context_menu(menu, window, cx) }) .into_any_element() diff --git a/crates/ui/src/table/state.rs b/crates/ui/src/table/state.rs index c68a06bb17..ddaa9125c3 100644 --- a/crates/ui/src/table/state.rs +++ b/crates/ui/src/table/state.rs @@ -2198,7 +2198,7 @@ where .size_full() .overflow_hidden() .child(self.render_table_header(left_columns_count, window, cx)) - .context_menu({ + .popup_context_menu({ let view = cx.entity().clone(); move |this, window: &mut Window, cx: &mut Context| { if let Some(row_ix) = view.read(cx).right_clicked_row { diff --git a/crates/ui/src/tree.rs b/crates/ui/src/tree.rs index 5671baf776..d8dbbb4535 100644 --- a/crates/ui/src/tree.rs +++ b/crates/ui/src/tree.rs @@ -429,7 +429,7 @@ impl Render for TreeState { .id("tree-state") .size_full() .relative() - .context_menu({ + .popup_context_menu({ let state = state.clone(); move |menu, window, cx: &mut Context| { if state.read(cx).context_menu_builder.is_none() { From db5df51b2cc80ff8189b45e1dc487a9629cfced9 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 10 Jun 2026 10:54:59 +0800 Subject: [PATCH 3/4] native_menu: keep ContextMenuExt as PopupMenu, expose native only via NativeMenu Revert the ContextMenuExt::context_menu -> NativeMenu change. The trait keeps its original PopupMenu semantics (so sidebar/table/tree/stories stay unchanged and keep closure callbacks, custom suffix/label/link), and the native menu is reached only through the standalone NativeMenu component (the input right-click menu already calls NativeMenu::show directly). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/story/src/stories/menu_story.rs | 6 +- crates/ui/src/menu/context_menu.rs | 202 ++++--------------------- crates/ui/src/menu/mod.rs | 2 +- crates/ui/src/sidebar/menu.rs | 2 +- crates/ui/src/table/state.rs | 2 +- crates/ui/src/tree.rs | 2 +- 6 files changed, 39 insertions(+), 177 deletions(-) diff --git a/crates/story/src/stories/menu_story.rs b/crates/story/src/stories/menu_story.rs index ac14472ace..c77d270a63 100644 --- a/crates/story/src/stories/menu_story.rs +++ b/crates/story/src/stories/menu_story.rs @@ -238,7 +238,7 @@ impl Render for MenuStory { .border_dashed() .border_color(cx.theme().border) .child("Right click to open ContextMenu") - .popup_context_menu({ + .context_menu({ move |this, window, cx| { this.check_side(check_side.unwrap_or(Side::Left)) .external_link_icon(false) @@ -293,7 +293,7 @@ impl Render for MenuStory { .border_dashed() .border_color(cx.theme().border) .child("Here is another area with context menu.") - .popup_context_menu({ + .context_menu({ move |this, _, _| { this.link( "About", @@ -318,7 +318,7 @@ impl Render for MenuStory { .border_dashed() .border_color(cx.theme().border) .child("ContextMenu area 1") - .popup_context_menu({ + .context_menu({ move |this, _, _| { this.link( "About", diff --git a/crates/ui/src/menu/context_menu.rs b/crates/ui/src/menu/context_menu.rs index 256c371d58..73b5a553b1 100644 --- a/crates/ui/src/menu/context_menu.rs +++ b/crates/ui/src/menu/context_menu.rs @@ -8,21 +8,22 @@ use gpui::{ }; use crate::menu::PopupMenu; -use crate::native_menu::NativeMenu; /// A extension trait for adding a context menu to an element. pub trait ContextMenuExt: InteractiveElement + ParentElement + Styled { /// Add a context menu to the element. /// - /// The menu is built lazily on right-click and shown as a native OS menu - /// via [`NativeMenu`], so it is not clipped to the window bounds. + /// This will changed the element to be `relative` positioned, and add a child `ContextMenu` element. + /// Because the `ContextMenu` element is positioned `absolute`, it will not affect the layout of the parent element. fn context_menu( mut self, - f: impl Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu + 'static, + f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, ) -> ContextMenu where Self: Sized, { + // Generate a unique ID based on the element's memory address to ensure + // each context menu has its own state and doesn't share with others let id = self .interactivity() .element_id @@ -31,169 +32,21 @@ pub trait ContextMenuExt: InteractiveElement + ParentElement + Styled { .unwrap_or_else(|| format!("context-menu-{:p}", &self as *const _)); ContextMenu::new(id, self).menu(f) } - - /// Add a context menu to the element, rendered as a GPUI [`PopupMenu`]. - /// - /// Unlike [`Self::context_menu`] (which uses a native OS menu), this draws - /// the menu with GPUI so it supports icons, links, custom elements, etc., - /// at the cost of being clipped to the window bounds. - fn popup_context_menu( - mut self, - f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> PopupContextMenu - where - Self: Sized, - { - let id = self - .interactivity() - .element_id - .clone() - .map(|id| format!("context-menu-{:?}", id)) - .unwrap_or_else(|| format!("context-menu-{:p}", &self as *const _)); - PopupContextMenu::new(id, self).menu(f) - } } impl ContextMenuExt for E {} -/// A context menu that can be shown on right-click, rendered as a native OS menu. +/// A context menu that can be shown on right-click. pub struct ContextMenu { - id: ElementId, - element: Option, - menu: Option NativeMenu>>, - _ignore_style: StyleRefinement, -} - -impl ContextMenu { - /// Create a new context menu with the given ID. - pub fn new(id: impl Into, element: E) -> Self { - Self { - id: id.into(), - element: Some(element), - menu: None, - _ignore_style: StyleRefinement::default(), - } - } - - #[must_use] - fn menu(mut self, builder: F) -> Self - where - F: Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu + 'static, - { - self.menu = Some(Rc::new(builder)); - self - } -} - -impl ParentElement for ContextMenu { - fn extend(&mut self, elements: impl IntoIterator) { - if let Some(element) = &mut self.element { - element.extend(elements); - } - } -} - -impl Styled for ContextMenu { - fn style(&mut self) -> &mut StyleRefinement { - if let Some(element) = &mut self.element { - element.style() - } else { - &mut self._ignore_style - } - } -} - -impl IntoElement for ContextMenu { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - -impl Element for ContextMenu { - type RequestLayoutState = AnyElement; - type PrepaintState = Hitbox; - - fn id(&self) -> Option { - Some(self.id.clone()) - } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (gpui::LayoutId, Self::RequestLayoutState) { - let mut element = self - .element - .take() - .expect("Element should exists.") - .into_any_element(); - - let layout_id = element.request_layout(window, cx); - (layout_id, element) - } - - fn prepaint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - bounds: gpui::Bounds, - element: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - element.prepaint(window, cx); - window.insert_hitbox(bounds, HitboxBehavior::Normal) - } - - fn paint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _: gpui::Bounds, - element: &mut Self::RequestLayoutState, - hitbox: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - element.paint(window, cx); - - let Some(builder) = self.menu.clone() else { - return; - }; - let hitbox = hitbox.clone(); - - window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { - if phase.bubble() && event.button == MouseButton::Right && hitbox.is_hovered(window) { - let position = event.position; - let builder = builder.clone(); - - window.defer(cx, move |window, cx| { - let menu = builder(NativeMenu::new(), window, cx); - menu.show(position, window, cx); - }); - } - }); - } -} - -/// A context menu that can be shown on right-click, drawn by GPUI as a [`PopupMenu`]. -pub struct PopupContextMenu { id: ElementId, element: Option, menu: Option) -> PopupMenu>>, + // This is not in use, just for style refinement forwarding. _ignore_style: StyleRefinement, anchor: Anchor, } -impl PopupContextMenu { +impl ContextMenu { /// Create a new context menu with the given ID. pub fn new(id: impl Into, element: E) -> Self { Self { @@ -205,6 +58,7 @@ impl PopupContextMenu { } } + /// Build the context menu using the given builder function. #[must_use] fn menu(mut self, builder: F) -> Self where @@ -219,9 +73,9 @@ impl PopupContextMenu { id: &GlobalElementId, window: &mut Window, cx: &mut App, - f: impl FnOnce(&mut Self, &mut PopupContextMenuState, &mut Window, &mut App) -> R, + f: impl FnOnce(&mut Self, &mut ContextMenuState, &mut Window, &mut App) -> R, ) -> R { - window.with_optional_element_state::( + window.with_optional_element_state::( Some(id), |element_state, window| { let mut element_state = element_state.unwrap().unwrap_or_default(); @@ -232,7 +86,7 @@ impl PopupContextMenu { } } -impl ParentElement for PopupContextMenu { +impl ParentElement for ContextMenu { fn extend(&mut self, elements: impl IntoIterator) { if let Some(element) = &mut self.element { element.extend(elements); @@ -240,7 +94,7 @@ impl ParentElement for PopupContextMenu { } } -impl Styled for PopupContextMenu { +impl Styled for ContextMenu { fn style(&mut self) -> &mut StyleRefinement { if let Some(element) = &mut self.element { element.style() @@ -250,7 +104,7 @@ impl Styled for PopupContextMenu { } } -impl IntoElement for PopupContextMenu { +impl IntoElement for ContextMenu { type Element = Self; fn into_element(self) -> Self::Element { @@ -258,23 +112,23 @@ impl IntoElement for PopupCon } } -struct PopupContextMenuSharedState { +struct ContextMenuSharedState { menu_view: Option>, open: bool, position: Point, _subscription: Option, } -pub struct PopupContextMenuState { +pub struct ContextMenuState { element: Option, - shared_state: Rc>, + shared_state: Rc>, } -impl Default for PopupContextMenuState { +impl Default for ContextMenuState { fn default() -> Self { Self { element: None, - shared_state: Rc::new(RefCell::new(PopupContextMenuSharedState { + shared_state: Rc::new(RefCell::new(ContextMenuSharedState { menu_view: None, open: false, position: Default::default(), @@ -284,8 +138,8 @@ impl Default for PopupContextMenuState { } } -impl Element for PopupContextMenu { - type RequestLayoutState = PopupContextMenuState; +impl Element for ContextMenu { + type RequestLayoutState = ContextMenuState; type PrepaintState = Hitbox; fn id(&self) -> Option { @@ -309,7 +163,7 @@ impl Element for PopupContext id.unwrap(), window, cx, - |this, state: &mut PopupContextMenuState, window, cx| { + |this, state: &mut ContextMenuState, window, cx| { let (position, open) = { let shared_state = state.shared_state.borrow(); (shared_state.position, shared_state.open) @@ -338,6 +192,7 @@ impl Element for PopupContext .snap_to_window_with_margin(px(8.)) .anchor(anchor) .when_some(menu_view, |this, menu| { + // Focus the menu, so that can be handle the action. if !menu .focus_handle(cx) .contains_focused(window, cx) @@ -367,7 +222,7 @@ impl Element for PopupContext ( layout_id, - PopupContextMenuState { + ContextMenuState { element: Some(element), ..Default::default() }, @@ -405,16 +260,18 @@ impl Element for PopupContext element.paint(window, cx); } + // Take the builder before setting up element state to avoid borrow issues let builder = self.menu.clone(); self.with_element_state( id.unwrap(), window, cx, - |_view, state: &mut PopupContextMenuState, window, _| { + |_view, state: &mut ContextMenuState, window, _| { let shared_state = state.shared_state.clone(); let hitbox = hitbox.clone(); + // When right mouse click, to build content menu, and show it at the mouse position. window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { if phase.bubble() && event.button == MouseButton::Right @@ -422,12 +279,15 @@ impl Element for PopupContext { { let mut shared_state = shared_state.borrow_mut(); + // Clear any existing menu view to allow immediate replacement + // Set the new position and open the menu shared_state.menu_view = None; shared_state._subscription = None; shared_state.position = event.position; shared_state.open = true; } + // Use defer to build the menu in the next frame, avoiding race conditions window.defer(cx, { let shared_state = shared_state.clone(); let builder = builder.clone(); @@ -439,6 +299,7 @@ impl Element for PopupContext build(menu, window, cx) }); + // Set up the subscription for dismiss handling let _subscription = window.subscribe(&menu, cx, { let shared_state = shared_state.clone(); move |_, _: &DismissEvent, window, _cx| { @@ -447,6 +308,7 @@ impl Element for PopupContext } }); + // Update the shared state with the built menu and subscription { let mut state = shared_state.borrow_mut(); state.menu_view = Some(menu.clone()); diff --git a/crates/ui/src/menu/mod.rs b/crates/ui/src/menu/mod.rs index adbd742fae..3152a15cb0 100644 --- a/crates/ui/src/menu/mod.rs +++ b/crates/ui/src/menu/mod.rs @@ -7,7 +7,7 @@ mod menu_item; mod popup_menu; pub use app_menu_bar::AppMenuBar; -pub use context_menu::{ContextMenu, ContextMenuExt, PopupContextMenu}; +pub use context_menu::{ContextMenu, ContextMenuExt, ContextMenuState}; pub use dropdown_menu::DropdownMenu; pub use popup_menu::{PopupMenu, PopupMenuItem}; diff --git a/crates/ui/src/sidebar/menu.rs b/crates/ui/src/sidebar/menu.rs index 07f8468763..8fd72b0ab6 100644 --- a/crates/ui/src/sidebar/menu.rs +++ b/crates/ui/src/sidebar/menu.rs @@ -356,7 +356,7 @@ impl SidebarItem for SidebarMenuItem { }) .map(|this| { if let Some(context_menu) = self.context_menu { - this.popup_context_menu(move |menu, window, cx| { + this.context_menu(move |menu, window, cx| { context_menu(menu, window, cx) }) .into_any_element() diff --git a/crates/ui/src/table/state.rs b/crates/ui/src/table/state.rs index ddaa9125c3..c68a06bb17 100644 --- a/crates/ui/src/table/state.rs +++ b/crates/ui/src/table/state.rs @@ -2198,7 +2198,7 @@ where .size_full() .overflow_hidden() .child(self.render_table_header(left_columns_count, window, cx)) - .popup_context_menu({ + .context_menu({ let view = cx.entity().clone(); move |this, window: &mut Window, cx: &mut Context| { if let Some(row_ix) = view.read(cx).right_clicked_row { diff --git a/crates/ui/src/tree.rs b/crates/ui/src/tree.rs index d8dbbb4535..5671baf776 100644 --- a/crates/ui/src/tree.rs +++ b/crates/ui/src/tree.rs @@ -429,7 +429,7 @@ impl Render for TreeState { .id("tree-state") .size_full() .relative() - .popup_context_menu({ + .context_menu({ let state = state.clone(); move |menu, window, cx: &mut Context| { if state.read(cx).context_menu_builder.is_none() { From 7615da09ad51f1b54aa225f4aa62f03690d560bd Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 10 Jun 2026 13:34:03 +0800 Subject: [PATCH 4/4] input: restore custom context menu API on top of NativeMenu Bring back Input::context_menu (custom builder) and InputState::context_menu (enable/disable), now backed by NativeMenu instead of PopupMenu. A custom builder fully replaces the built-in right-click menu; otherwise the default cut/copy/paste/select-all (plus code-editor actions) is shown. Restore the story demos accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/story/src/stories/input_story.rs | 17 ++++- crates/ui/src/input/input.rs | 21 ++++++ crates/ui/src/input/state.rs | 86 ++++++++++++++++--------- 3 files changed, 94 insertions(+), 30 deletions(-) diff --git a/crates/story/src/stories/input_story.rs b/crates/story/src/stories/input_story.rs index 015704d9cd..8b0403bb67 100644 --- a/crates/story/src/stories/input_story.rs +++ b/crates/story/src/stories/input_story.rs @@ -27,6 +27,7 @@ pub struct InputStory { mask_input2: Entity, currency_input: Entity, custom_input: Entity, + custom_menu_input: Entity, code_input: Entity, color_input: Entity, @@ -92,9 +93,14 @@ impl InputStory { }) }); let custom_input = cx.new(|cx| { - InputState::new(window, cx).placeholder("Custom Input use monospace, 0123456789.") + InputState::new(window, cx) + .placeholder("Custom Input use monospace, 0123456789.") + .context_menu(false) }); + let custom_menu_input = cx + .new(|cx| InputState::new(window, cx).placeholder("Input with custom context menu...")); + let color_input = cx.new(|cx| { InputState::new(window, cx) .placeholder("Type something...") @@ -147,6 +153,7 @@ impl InputStory { mask_input2, currency_input, custom_input, + custom_menu_input, code_input, color_input, input_text_centered, @@ -307,6 +314,14 @@ impl Render for InputStory { .child(Input::new(&self.custom_input).appearance(false)), ), ) + .child(section("Custom Context Menu").max_w_md().child( + Input::new(&self.custom_menu_input).context_menu(|menu, _, _| { + menu.menu("Custom Action", Box::new(input::SelectAll)) + .separator() + .menu("Copy", Box::new(input::Copy)) + .menu("Paste", Box::new(input::Paste)) + }), + )) .child( section("Custom Text Color") .max_w_md() diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index 0e894fe41e..fe0b4bdca7 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -1,3 +1,5 @@ +use std::rc::Rc; + use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, App, DefiniteLength, Edges, EdgesRefinement, Entity, Hsla, @@ -7,6 +9,7 @@ use gpui::{ use crate::button::{Button, ButtonVariants as _}; use crate::input::clear_button; +use crate::native_menu::NativeMenu; use crate::spinner::Spinner; use crate::{ActiveTheme, Colorize, v_flex}; use crate::{IconName, Size}; @@ -44,6 +47,11 @@ pub struct Input { focus_bordered: bool, tab_index: isize, selected: bool, + + /// An optional context menu builder to allow a custom context menu on the input. + /// + /// If set, this overrides the built-in context menu. + context_menu_builder: Option NativeMenu>>, } impl Sizable for Input { @@ -82,6 +90,7 @@ impl Input { focus_bordered: true, tab_index: 0, selected: false, + context_menu_builder: None, } } @@ -149,6 +158,17 @@ impl Input { self } + /// Sets a custom context menu builder for the input, shown as a native OS menu. + /// + /// If set, this overrides the built-in right-click context menu. + pub fn context_menu( + mut self, + f: impl Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu + 'static, + ) -> Self { + self.context_menu_builder = Some(Rc::new(f)); + self + } + fn render_toggle_mask_button(state: &Entity, cx: &App) -> impl IntoElement { let masked = state.read(cx).masked; Button::new("toggle-mask") @@ -227,6 +247,7 @@ impl RenderOnce for Input { let text_align = self.style.text.text_align.unwrap_or(TextAlign::Left); self.state.update(cx, |state, _| { + state.context_menu_builder = self.context_menu_builder.clone(); state.disabled = self.disabled; state.size = self.size; diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index 00218d9c20..6f09fb5b51 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -397,6 +397,17 @@ pub struct InputState { /// Completion/CodeAction context menu pub(super) context_menu_content: Option, + /// An optional context menu builder to allow a custom right-click context menu on the input. + /// + /// If set, this overrides the built-in context menu (and ignores [`Self::enable_context_menu`]). + pub(super) context_menu_builder: + Option NativeMenu>>, + + /// Whether the context menu that shows on right-click is enabled. + /// + /// This value is ignored if a context menu builder is defined in [`Self::context_menu_builder`]. + pub(super) enable_context_menu: bool, + /// A flag to indicate if we are currently inserting a completion item. pub(super) completion_inserting: bool, pub(super) hover_popover: Option>, @@ -506,6 +517,8 @@ impl InputState { lsp: Lsp::default(), diagnostic_popover: None, context_menu_content: None, + context_menu_builder: None, + enable_context_menu: true, completion_inserting: false, hover_popover: None, hover_definition: HoverDefinition::default(), @@ -563,6 +576,15 @@ impl InputState { self } + /// Sets whether the context menu that shows on right-click is enabled. + /// + /// The context menu is enabled by default. + /// This value is ignored if a custom context menu builder is defined on the input. + pub fn context_menu(mut self, enable: bool) -> Self { + self.enable_context_menu = enable; + self + } + /// Set this input is searchable, default is false (Default true for Code Editor). pub fn searchable(mut self, searchable: bool) -> Self { debug_assert!(self.mode.is_multi_line()); @@ -1494,35 +1516,38 @@ impl InputState { self.move_to(offset, None, cx); } - let is_code_editor = self.mode.is_code_editor(); - if is_code_editor { - self.handle_hover_definition(offset, window, cx); - } - - let is_enable = !self.disabled; - let has_goto_definition = is_enable && self.lsp.definition_provider.is_some(); - let has_code_action = is_enable && !self.lsp.code_action_providers.is_empty(); - let is_selected = !self.selected_range.is_empty(); - let has_paste = is_enable && cx.read_from_clipboard().is_some(); + // A custom builder fully replaces the built-in context menu. + let menu = if let Some(builder) = self.context_menu_builder.clone() { + builder(NativeMenu::new(), window, cx) + } else { + let is_code_editor = self.mode.is_code_editor(); + if is_code_editor { + self.handle_hover_definition(offset, window, cx); + } - let mut menu = NativeMenu::new(); - if is_code_editor { - menu = menu - .menu_with_disabled( - rust_i18n::t!("Input.Go to Definition"), - !has_goto_definition, - Box::new(crate::input::GoToDefinition), - ) - .menu_with_disabled( - rust_i18n::t!("Input.Show Code Actions"), - !has_code_action, - Box::new(crate::input::ToggleCodeActions), - ) - .separator(); - } + let is_enable = !self.disabled; + let has_goto_definition = is_enable && self.lsp.definition_provider.is_some(); + let has_code_action = is_enable && !self.lsp.code_action_providers.is_empty(); + let is_selected = !self.selected_range.is_empty(); + let has_paste = is_enable && cx.read_from_clipboard().is_some(); + + let mut menu = NativeMenu::new(); + if is_code_editor { + menu = menu + .menu_with_disabled( + rust_i18n::t!("Input.Go to Definition"), + !has_goto_definition, + Box::new(crate::input::GoToDefinition), + ) + .menu_with_disabled( + rust_i18n::t!("Input.Show Code Actions"), + !has_code_action, + Box::new(crate::input::ToggleCodeActions), + ) + .separator(); + } - menu = menu - .menu_with_disabled( + menu.menu_with_disabled( rust_i18n::t!("Input.Cut"), !(is_enable && is_selected), Box::new(crate::input::Cut), @@ -1541,7 +1566,8 @@ impl InputState { .menu( rust_i18n::t!("Input.Select All"), Box::new(crate::input::SelectAll), - ); + ) + }; menu.show(event.position, window, cx); } @@ -1588,7 +1614,9 @@ impl InputState { // Show Mouse context menu if event.button == MouseButton::Right { - self.handle_right_click_menu(event, offset, window, cx); + if self.enable_context_menu || self.context_menu_builder.is_some() { + self.handle_right_click_menu(event, offset, window, cx); + } return; }