From ba627ba258a1c8c24ec27ee16bd89ebf27b5a865 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 8 Oct 2026 05:36:26 -0700 Subject: [PATCH 1/2] fix(input): share right-click menus across the whole input frame Reuse the existing Base pending request for editor and styled padding, preserving selections and descendant-owned gestures. Recheck current handler, policy, capabilities and focus before deferred delivery, then release the entity borrow before application menu builders run. Cancel stale requests on Escape, policy changes, blur, outside gestures and deactivation. Validation: exact-path rustfmt and diff checks pass; Kit input integration target 166 passed, 0 failed, 0 ignored; Base context-menu tests 2 passed, 0 failed, 1245 filtered. Actual GPUI pointer dispatch covers selection, cancellation, focus ownership, release-time policy changes, masked/read-only clipboard protection and custom builders reading their input. Native OS popup presentation and Linux-only drawn-menu regression await hosted/platform acceptance. --- crates/base/src/input/base/state.rs | 167 +++++-- crates/component/src/input/input.rs | 40 +- crates/component/src/input/state.rs | 19 + crates/kit/tests/input.rs | 2 + crates/kit/tests/input/context_menu.rs | 598 +++++++++++++++++++++++++ 5 files changed, 798 insertions(+), 28 deletions(-) create mode 100644 crates/kit/tests/input/context_menu.rs diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index a59587f31e..e9a5de2281 100644 --- a/crates/base/src/input/base/state.rs +++ b/crates/base/src/input/base/state.rs @@ -449,7 +449,8 @@ pub struct InputBaseState { context_menu_handler: Option< Rc, &mut Window, &mut App)>, >, - pending_context_menu: Option<(Point, usize)>, + // None as the anchor means a decoration requested the menu without a text hit. + pending_context_menu: Option<(Point, Option)>, /// Whether the context menu that shows on right-click is enabled. /// @@ -670,6 +671,7 @@ impl InputBaseState { let focus_handle = cx.focus_handle().tab_stop(true); let blink_cursor = cx.new(|_| BlinkCursor::new()); let undo_manager = UndoManager::new(); + let input = cx.entity().downgrade(); let _subscriptions = vec![ // Key bindings can consume events before on_key_down. Observe input @@ -677,8 +679,13 @@ impl InputBaseState { cx.intercept_keystrokes({ let focus_handle = focus_handle.clone(); let blink_cursor = blink_cursor.downgrade(); - move |_, window, cx| { + move |event, window, cx| { if focus_handle.is_focused(window) { + // Action bindings can consume Escape before raw key listeners, + // including in a read-only field. Cancel without consuming it. + if event.keystroke.key == "escape" { + _ = input.update(cx, |input, _| input.cancel_context_menu()); + } _ = blink_cursor.update(cx, |cursor, cx| cursor.pause(cx)); } } @@ -687,7 +694,9 @@ impl InputBaseState { cx.observe(&blink_cursor, |_, _, cx| cx.notify()), // Blink the cursor when the window is active, pause when it's not. cx.observe_window_activation(window, |input, window, cx| { - if window.is_window_active() { + if !window.is_window_active() { + input.cancel_context_menu(); + } else { let focus_handle = input.focus_handle.clone(); if focus_handle.is_focused(window) { input.blink_cursor.update(cx, |blink_cursor, cx| { @@ -790,6 +799,9 @@ impl InputBaseState { pub fn set_context_menu_enabled(&mut self, enabled: bool) { self.enable_context_menu = enabled; + if !enabled { + self.cancel_context_menu(); + } } /// Set whether search UI allows replacement, default is true. @@ -1090,6 +1102,7 @@ impl InputBaseState { self.disabled = disabled; if disabled { + self.cancel_context_menu(); M::hide_context_menu(self, cx); M::clear_inline_completion(self, cx); } @@ -2113,33 +2126,105 @@ impl InputBaseState { cx.propagate(); } + /// Queue a context menu for an input decoration, preserving every selection. + /// + /// Call on an unclaimed right-button press, then [`Self::complete_context_menu`] + /// on release or [`Self::cancel_context_menu`] if the gesture is cancelled. + /// The editor and its decorations share one pending request. Disabled inputs, + /// disabled menus and deferred contexts refuse the request. + pub fn request_context_menu( + &mut self, + position: Point, + cx: &mut Context, + ) -> bool { + if !self.enable_context_menu + || self.disabled + || crate::GlobalState::is_in_deferred_context(cx) + || self.pending_context_menu.is_some() + { + return false; + } + self.undo_manager.break_transaction_coalescing(); + M::clear_inline_completion(self, cx); + self.dismiss_touch_selection(cx); + self.pending_context_menu = Some((position, None)); + true + } + + /// Complete the shared right-button request once, using the current policy. + /// + /// An unmatched release does nothing. Taking the request before delivery lets + /// a decoration and the editor both receive the release without opening twice. + pub fn complete_context_menu(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let Some((position, offset)) = self.pending_context_menu.take() else { + return false; + }; + if !self.enable_context_menu + || self.disabled + || crate::GlobalState::is_in_deferred_context(cx) + { + return false; + } + self.handle_right_click_menu(position, offset, window, cx); + true + } + + /// Cancel the shared pending right-button request without changing selection. + pub fn cancel_context_menu(&mut self) { + self.pending_context_menu = None; + } + /// Show the right-click context menu as a native OS menu. pub(crate) fn handle_right_click_menu( &mut self, position: Point, - offset: usize, + offset: Option, window: &mut Window, cx: &mut Context, ) { - if self.disabled { + if self.disabled || !self.enable_context_menu { return; } if crate::GlobalState::is_in_deferred_context(cx) { return; } - if !self.active_selection().contains(offset) { - self.move_to(offset, None, cx); - } - - if self.is_code_editor() { - M::on_hover_definition(self, offset, window, cx); + if let Some(offset) = offset { + if !self.active_selection().contains(offset) { + self.move_to(offset, None, cx); + } + if self.is_code_editor() { + M::on_hover_definition(self, offset, window, cx); + } } - if let Some(handler) = self.context_menu_handler.clone() { - let capabilities = self.context_menu_capabilities(); - cx.defer_in(window, move |_, window, cx| { - handler(NativeMenu::new(), capabilities, position, window, cx); + if self.context_menu_handler.is_some() { + let input = cx.entity().downgrade(); + window.defer(cx, move |window, cx| { + let Some(input) = input.upgrade() else { + return; + }; + // Descendant release handlers can change policy or focus after + // the frame completes the request, before deferred delivery. + let delivery = input.read_with(cx, |state, cx| { + if state.disabled + || !state.enable_context_menu + || crate::GlobalState::is_in_deferred_context(cx) + || !state.focus_handle.is_focused(window) + || !window.is_window_active() + { + return None; + } + state + .context_menu_handler + .clone() + .map(|handler| (handler, state.context_menu_capabilities())) + }); + // A custom builder may read or update its input. Release the + // entity borrow before invoking application code. + if let Some((handler, capabilities)) = delivery { + handler(NativeMenu::new(), capabilities, position, window, cx); + } }); } } @@ -2355,11 +2440,11 @@ impl InputBaseState { // Show Mouse context menu if event.button == MouseButton::Right { - if self.enable_context_menu { + if self.request_context_menu(event.position, cx) { if !self.active_selection().contains(offset) { self.move_to(offset, None, cx); } - self.pending_context_menu = Some((event.position, offset)); + self.pending_context_menu = Some((event.position, Some(offset))); } return; } @@ -2398,9 +2483,7 @@ impl InputBaseState { cx: &mut Context, ) { if event.button == MouseButton::Right { - if let Some((position, offset)) = self.pending_context_menu.take() { - self.handle_right_click_menu(position, offset, window, cx); - } + self.complete_context_menu(window, cx); } if self.active_selection().is_empty() { self.active_selection_mut().reversed = false; @@ -3378,6 +3461,7 @@ impl InputBaseState { } fn on_blur(&mut self, window: &mut Window, cx: &mut Context) { + self.cancel_context_menu(); if M::is_context_menu_open(self, cx) { return; } @@ -4461,6 +4545,16 @@ impl Render for InputBaseState { .id("input-state") .key_context(CONTEXT) .track_focus(&self.focus_handle) + .capture_any_mouse_down(window.listener_for(&entity, |state, _, _, _| { + state.cancel_context_menu(); + })) + .on_mouse_down_out(window.listener_for(&entity, |state, _, _, _| { + state.cancel_context_menu(); + })) + .on_mouse_up_out( + MouseButton::Right, + window.listener_for(&entity, |state, _, _, _| state.cancel_context_menu()), + ) .when(self.is_editable(), |this| { this.on_action(window.listener_for(&entity, InputBaseState::backspace)) .on_action(window.listener_for(&entity, InputBaseState::delete)) @@ -5423,33 +5517,56 @@ mod tests { } #[gpui::test] - fn context_menu_handler_is_deferred_and_respects_disabled(cx: &mut TestAppContext) { - use std::{cell::Cell, rc::Rc}; + fn context_menu_handler_is_deferred_and_respects_current_policy(cx: &mut TestAppContext) { + use std::{ + cell::{Cell, RefCell}, + rc::Rc, + }; cx.update(crate::init); let input_view = InputView::new(cx); let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); let input = input_view.input; let calls = Rc::new(Cell::new(0usize)); let items = Rc::new(Cell::new(0usize)); + let delivered = Rc::new(RefCell::new(Vec::new())); + cx.update(|window, _| window.activate_window()); + cx.run_until_parked(); cx.update(|window, cx| { input.update(cx, |state, cx| { + state.focus(window, cx); let calls2 = calls.clone(); let items2 = items.clone(); - state.on_context_menu(Rc::new(move |menu, _, _, _, _| { + let delivered = delivered.clone(); + state.on_context_menu(Rc::new(move |menu, capabilities, _, _, _| { calls2.set(calls2.get() + 1); items2.set(menu.items.len()); + delivered.borrow_mut().push(capabilities); })); - state.handle_right_click_menu(point(px(0.), px(0.)), 0, window, cx); + state.handle_right_click_menu(point(px(0.), px(0.)), Some(0), window, cx); + // Delivery must not use the pre-defer capability snapshot. + state.masked = true; + state.readonly = true; }) }); assert_eq!(calls.get(), 1); assert_eq!(items.get(), 0); + assert!(delivered.borrow()[0].is_masked()); + assert!(delivered.borrow()[0].is_readonly()); cx.update(|window, cx| { input.update(cx, |state, cx| { + state.handle_right_click_menu(point(px(0.), px(0.)), Some(0), window, cx); state.disabled = true; - state.handle_right_click_menu(point(px(0.), px(0.)), 0, window, cx); + }) + }); + assert_eq!(calls.get(), 1); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.disabled = false; + state.handle_right_click_menu(point(px(0.), px(0.)), Some(0), window, cx); + state.set_context_menu_enabled(false); }) }); assert_eq!(calls.get(), 1); diff --git a/crates/component/src/input/input.rs b/crates/component/src/input/input.rs index a55da24682..f9d363c3a3 100644 --- a/crates/component/src/input/input.rs +++ b/crates/component/src/input/input.rs @@ -3,9 +3,9 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ AccessibleAction, AnyElement, App, DefiniteLength, Edges, ElementId, Entity, FocusHandle, Hsla, - InteractiveElement as _, IntoElement, ParentElement as _, Rems, RenderOnce, Role, SharedString, - StatefulInteractiveElement as _, StyleRefinement, Styled, TextAlign, TouchPhase, Window, div, - px, relative, + InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, Role, + SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, TextAlign, TouchPhase, + Window, div, px, relative, }; use crate::button::{Button, ButtonRounded, ButtonVariants as _}; @@ -768,6 +768,40 @@ impl RenderOnce for Input { .focused(focused) .disabled(disabled) .track_focus(&frame_focus_handle) + .capture_any_mouse_down({ + let state = state.clone(); + move |_, _, cx| state.cancel_context_menu(cx) + }) + .on_mouse_down_out({ + let state = state.clone(); + move |_, _, cx| state.cancel_context_menu(cx) + }) + .on_mouse_down(MouseButton::Right, { + let state = state.clone(); + move |event, window, cx| { + // A descendant owns a prevented press. The editor has already + // queued its own hit request; decorations preserve selection. + if !window.default_prevented() && state.request_context_menu(event.position, cx) + { + state.focus(window, cx); + window.prevent_default(); + } + } + }) + .capture_any_mouse_up({ + let state = state.clone(); + move |event, window, cx| { + if event.button == MouseButton::Right { + // Drain before the inner editor's outside-up cancellation, + // but let its bubble handler finish selection/drag cleanup. + state.complete_context_menu(window, cx); + } + } + }) + .on_mouse_up_out(MouseButton::Right, { + let state = state.clone(); + move |_, _, cx| state.cancel_context_menu(cx) + }) .when(disabled, |this| { this.capture_any_mouse_down(|_, _, cx| cx.stop_propagation()) }) diff --git a/crates/component/src/input/state.rs b/crates/component/src/input/state.rs index a78791e4b5..1a7c38f2fa 100644 --- a/crates/component/src/input/state.rs +++ b/crates/component/src/input/state.rs @@ -144,6 +144,25 @@ impl TextInputState { .update(cx, |state, _| state.on_context_menu(handler))) } + pub(crate) fn request_context_menu( + &self, + position: gpui::Point, + cx: &mut App, + ) -> bool { + dispatch!(self, |state| state + .update(cx, |state, cx| state.request_context_menu(position, cx))) + } + + pub(crate) fn complete_context_menu(&self, window: &mut Window, cx: &mut App) { + dispatch!(self, |state| state + .update(cx, |state, cx| state.complete_context_menu(window, cx))); + } + + pub(crate) fn cancel_context_menu(&self, cx: &mut App) { + dispatch!(self, |state| state + .update(cx, |state, _| state.cancel_context_menu())) + } + /// The selection a long press made, laid out for its handles and menu. pub(crate) fn touch_selection(&self, cx: &App) -> Option { dispatch!(self, |state| state.read(cx).touch_selection()) diff --git a/crates/kit/tests/input.rs b/crates/kit/tests/input.rs index 8dcf15f8ea..3d33dc6d6f 100644 --- a/crates/kit/tests/input.rs +++ b/crates/kit/tests/input.rs @@ -5,6 +5,8 @@ mod completions; mod composition; #[path = "input/constraints.rs"] mod constraints; +#[path = "input/context_menu.rs"] +mod context_menu; #[path = "input/editing.rs"] mod editing; #[path = "input/editor.rs"] diff --git a/crates/kit/tests/input/context_menu.rs b/crates/kit/tests/input/context_menu.rs new file mode 100644 index 0000000000..c15998c176 --- /dev/null +++ b/crates/kit/tests/input/context_menu.rs @@ -0,0 +1,598 @@ +//! Right-click the real styled frame/editor; do not call their event handlers. +use std::{cell::RefCell, ops::Range, rc::Rc}; + +use gpui_kit::{ + App, AppContext, ClipboardItem, Context, Entity, Focusable, InputEvent as _, MouseButton, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, TestAppContext, VisualTestContext, + Window, WindowHandle, + base::{Root, input::InputContextMenuCapabilities}, + component::input::{Copy, Cut, Escape, Input, InputState}, + div, point, + prelude::*, + px, size, + test::{TestSupportExt, TestWindowExt}, +}; + +use crate::common; + +#[cfg(target_os = "macos")] +const SELECT_ALL: &str = "cmd-a"; +#[cfg(not(target_os = "macos"))] +const SELECT_ALL: &str = "ctrl-a"; + +#[derive(Debug)] +struct Request { + selection: Range, + cursor: usize, + capabilities: InputContextMenuCapabilities, +} + +struct Fields { + input: Entity, + other: Entity, + requests: Rc>>, + disabled: bool, + readonly: bool, + recording: bool, + release_change: Option, + delivered_capabilities: Rc>>, +} + +#[derive(Clone, Copy)] +enum ReleaseChange { + Disable, + OptOut, + Mask, + Blur, +} + +impl Render for Fields { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let input = self.input.clone(); + let requests = self.requests.clone(); + let release_input = self.input.clone(); + let release_change = self.release_change; + let delivered_capabilities = self.delivered_capabilities.clone(); + div() + .size_full() + .flex() + .flex_col() + .p_4() + .gap_4() + // An ancestor may consume Escape before raw key listeners run. + .on_action(|_: &Escape, _, _| {}) + .child( + Input::new(&self.input) + .id("field") + .w(px(320.)) + .disabled(self.disabled) + .readonly(self.readonly) + .suffix( + div() + .id("owned-suffix") + .test_support() + .w_8() + .h_6() + .on_mouse_down(MouseButton::Right, |_, window, _| { + window.prevent_default() + }) + .on_mouse_up(MouseButton::Right, move |_, window, cx| { + // A real descendant bubble handler runs after the + // frame's capture-up, before deferred delivery. + match release_change { + Some(ReleaseChange::Disable) => release_input + .update(cx, |state, cx| state.set_disabled(true, cx)), + Some(ReleaseChange::OptOut) => release_input + .update(cx, |state, _| { + state.set_context_menu_enabled(false) + }), + Some(ReleaseChange::Mask) => { + release_input.update(cx, |state, cx| { + state.set_masked(true, window, cx); + let delivered = delivered_capabilities.clone(); + state.on_context_menu(Rc::new( + move |_, capabilities, _, _, _| { + delivered.borrow_mut().push(capabilities); + }, + )); + }) + } + Some(ReleaseChange::Blur) => window.blur(cx), + None => {} + } + }), + ) + .when(self.recording, |this| { + this.context_menu(move |menu, _, cx| { + let state = input.read(cx); + requests.borrow_mut().push(Request { + selection: state.selected_range(), + cursor: state.cursor(), + capabilities: state.context_menu_capabilities(), + }); + // Empty native menus do not enter an OS tracking loop. This + // records actual pointer-to-builder delivery, not OS display. + menu + }) + }), + ) + .child(Input::new(&self.other).id("other").w(px(320.))) + } +} + +fn fields(cx: &mut TestAppContext) -> (WindowHandle, Entity) { + cx.update(gpui_kit::init); + let result = common::open_window(cx, Some(size(px(640.), px(480.))), |window, cx| { + cx.new(|cx| Fields { + input: cx.new(|cx| InputState::new(window, cx).default_value("abcdef")), + other: cx.new(|cx| InputState::new(window, cx).default_value("unrelated")), + requests: Rc::default(), + disabled: false, + readonly: false, + recording: true, + release_change: None, + delivered_capabilities: Rc::default(), + }) + }); + cx.update_window(result.0.into(), |_, window, cx| { + window.activate_window(); + window.render_frame(cx); + }) + .unwrap(); + cx.run_until_parked(); + result +} + +fn padding(window: &Window, view: &Entity, cx: &App) -> Point { + let frame = window.find("field").bounds(); + let position = point(frame.left() + px(2.), frame.center().y); + assert!( + !view + .read(cx) + .input + .read(cx) + .input_bounds() + .contains(&position), + "the regression must hit frame padding, outside the editor" + ); + position +} + +fn right(window: &mut Window, position: Point, down: bool, cx: &mut App) { + dispatch_right(window, position, down, cx); + window.render_frame(cx); +} + +fn dispatch_right(window: &mut Window, position: Point, down: bool, cx: &mut App) { + window.dispatch_event( + MouseMoveEvent { + position, + pressed_button: (!down).then_some(MouseButton::Right), + modifiers: Default::default(), + } + .to_platform_input(), + cx, + ); + if down { + window.dispatch_event( + MouseDownEvent { + button: MouseButton::Right, + position, + modifiers: Default::default(), + click_count: 1, + first_mouse: false, + } + .to_platform_input(), + cx, + ); + } else { + window.dispatch_event( + MouseUpEvent { + button: MouseButton::Right, + position, + modifiers: Default::default(), + click_count: 1, + } + .to_platform_input(), + cx, + ); + } +} + +fn request_count(view: &Entity, cx: &TestAppContext) -> usize { + view.read_with(cx, |view, _| view.requests.borrow().len()) +} + +#[gpui_kit::test] +fn padding_preserves_forward_and_reversed_selection_and_opens_once(cx: &mut TestAppContext) { + let (handle, view) = fields(cx); + for reversed in [false, true] { + cx.update_window(handle.into(), |_, window, cx| { + window.click("field", cx); + window.press(SELECT_ALL, cx); + window.press(if reversed { "right" } else { "left" }, cx); + for _ in 0..if reversed { 1 } else { 2 } { + window.press(if reversed { "left" } else { "right" }, cx); + } + for _ in 0..3 { + window.press( + if reversed { + "shift-left" + } else { + "shift-right" + }, + cx, + ); + } + let state = view.read(cx).input.read(cx); + assert_eq!(state.selected_range(), 2..5); + assert_eq!(state.cursor(), if reversed { 2 } else { 5 }); + window.click("other", cx); + }) + .unwrap(); + cx.run_until_parked(); + let before = request_count(&view, cx); + cx.update_window(handle.into(), |_, window, cx| { + let pad = padding(window, &view, cx); + right(window, pad, true, cx); + assert_eq!( + view.read(cx).requests.borrow().len(), + before, + "not on press" + ); + right(window, pad, false, cx); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(request_count(&view, cx), before + 1); + cx.update_window(handle.into(), |_, window, cx| { + let field = &view.read(cx).input; + assert!(field.focus_handle(cx).is_focused(window)); + let requests = view.read(cx).requests.borrow(); + let request = requests.last().unwrap(); + assert_eq!(request.selection, 2..5); + assert_eq!(request.cursor, if reversed { 2 } else { 5 }); + assert!(request.capabilities.is_copyable()); + assert_eq!(view.read(cx).other.read(cx).value(), "unrelated"); + }) + .unwrap(); + } + // The input's own right-down and the frame's capture-up share one request. + let before = request_count(&view, cx); + cx.update_window(handle.into(), |_, window, cx| { + window.right_click("field", cx) + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(request_count(&view, cx), before + 1); + view.read_with(cx, |view, _| { + let requests = view.requests.borrow(); + let request = requests.last().unwrap(); + assert_eq!( + request.selection, + 6..6, + "an inner hit past the text still places the caret" + ); + assert_eq!(request.cursor, 6); + }); +} + +#[gpui_kit::test] +fn aborted_and_unmatched_releases_do_not_reopen_a_stale_request(cx: &mut TestAppContext) { + let (handle, view) = fields(cx); + cx.update_window(handle.into(), |_, window, cx| { + let pad = padding(window, &view, cx); + right(window, pad, false, cx); // No preceding press. + right(window, pad, true, cx); + right(window, point(px(600.), px(440.)), false, cx); + right(window, pad, false, cx); // The cancelled press cannot be reused. + right(window, pad, true, cx); + window.click("other", cx); // Another button/owner cancels it. + right(window, pad, false, cx); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(request_count(&view, cx), 0); + // Either direction across padding/editor remains the same field gesture. + for start_in_padding in [true, false] { + cx.update_window(handle.into(), |_, window, cx| { + let pad = padding(window, &view, cx); + let editor = view.read(cx).input.read(cx).input_bounds().center(); + right( + window, + if start_in_padding { pad } else { editor }, + true, + cx, + ); + right( + window, + if start_in_padding { editor } else { pad }, + false, + cx, + ); + }) + .unwrap(); + cx.run_until_parked(); + } + assert_eq!(request_count(&view, cx), 2); + // Releasing the menu gesture must not swallow the editor's drag cleanup. + cx.update_window(handle.into(), |_, window, cx| { + window.click("field", cx); + window.press(SELECT_ALL, cx); + window.input("replacement", cx); + assert_eq!(window.find("field").value(), Some("replacement")); + assert_eq!(window.find("other").value(), Some("unrelated")); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn optout_keeps_normal_focus_and_disabled_or_prevented_presses_do_not_steal_focus( + cx: &mut TestAppContext, +) { + let (handle, view) = fields(cx); + for disabled in [false, true] { + common::update_content(handle, &view, cx, |view, _, cx| { + view.disabled = disabled; + view.input + .update(cx, |state, _| state.set_context_menu_enabled(disabled)); + cx.notify(); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("other", cx); + let pad = padding(window, &view, cx); + right(window, pad, true, cx); + right(window, pad, false, cx); + if disabled { + assert!(view.read(cx).other.focus_handle(cx).is_focused(window)); + } else { + assert!(view.read(cx).input.focus_handle(cx).is_focused(window)); + } + }) + .unwrap(); + cx.run_until_parked(); + } + common::update_content(handle, &view, cx, |view, _, cx| { + view.disabled = false; + view.input + .update(cx, |state, _| state.set_context_menu_enabled(true)); + cx.notify(); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("other", cx); + window.right_click("owned-suffix", cx); + assert!(view.read(cx).other.focus_handle(cx).is_focused(window)); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(request_count(&view, cx), 0); + // A stale press whose release never arrived is replaced, not duplicated. + cx.update_window(handle.into(), |_, window, cx| { + let pad = padding(window, &view, cx); + right(window, pad, true, cx); + right(window, pad, true, cx); + right(window, pad, false, cx); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(request_count(&view, cx), 1); +} + +#[gpui_kit::test] +fn descendant_release_changes_are_rechecked_before_menu_delivery(cx: &mut TestAppContext) { + for change in [ + ReleaseChange::Disable, + ReleaseChange::OptOut, + ReleaseChange::Mask, + ReleaseChange::Blur, + ] { + let (handle, view) = fields(cx); + common::update_content(handle, &view, cx, |view, _, cx| { + view.release_change = Some(change); + cx.notify(); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("field", cx); + window.press(SELECT_ALL, cx); + assert!( + view.read(cx) + .input + .read(cx) + .context_menu_capabilities() + .is_copyable() + ); + let pad = padding(window, &view, cx); + right(window, pad, true, cx); + let suffix = window.find("owned-suffix").bounds().center(); + // Drain deferred delivery before another render replaces the + // handler installed by the real suffix release callback. + dispatch_right(window, suffix, false, cx); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(request_count(&view, cx), 0); + view.read_with(cx, |view, _| { + let delivered = view.delivered_capabilities.borrow(); + if matches!(change, ReleaseChange::Mask) { + // Inspect the argument delivered by Base, not a builder's + // independent state read: both the handler and policy are fresh. + assert_eq!(delivered.len(), 1); + assert!(delivered[0].is_masked()); + assert!(delivered[0].is_editable()); + assert!(!delivered[0].is_copyable()); + } else { + assert!(delivered.is_empty()); + } + }); + } +} + +#[gpui_kit::test] +fn policy_changes_escape_blur_and_deactivation_cancel_pending_press(cx: &mut TestAppContext) { + let (handle, view) = fields(cx); + for mode in 0..4 { + cx.update_window(handle.into(), |_, window, cx| { + let pad = padding(window, &view, cx); + right(window, pad, true, cx); + let input = view.read(cx).input.clone(); + match mode { + 0 => input.update(cx, |state, _| state.set_context_menu_enabled(false)), + 1 => input.update(cx, |state, cx| state.set_disabled(true, cx)), + 2 => window.press("escape", cx), + _ => window.blur(cx), + } + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + let pad = padding(window, &view, cx); + right(window, pad, false, cx); + let input = view.read(cx).input.clone(); + input.update(cx, |state, cx| { + state.set_context_menu_enabled(true); + state.set_disabled(false, cx); + }); + }) + .unwrap(); + cx.run_until_parked(); + } + // A read-only Escape can be consumed by an ancestor action binding. + common::update_content(handle, &view, cx, |view, _, cx| { + view.readonly = true; + cx.notify(); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + let pad = padding(window, &view, cx); + right(window, pad, true, cx); + window.press("escape", cx); + right(window, pad, false, cx); + right(window, pad, true, cx); + }) + .unwrap(); + cx.run_until_parked(); + { + let mut visual = VisualTestContext::from_window(handle.into(), cx); + visual.deactivate_window(); + visual.run_until_parked(); + } + cx.update_window(handle.into(), |_, window, cx| { + window.activate_window(); + let pad = padding(window, &view, cx); + right(window, pad, false, cx); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(request_count(&view, cx), 0); +} + +#[gpui_kit::test] +fn readonly_and_masked_padding_requests_keep_clipboard_protections(cx: &mut TestAppContext) { + let (handle, view) = fields(cx); + common::update_content(handle, &view, cx, |view, _, cx| { + view.readonly = true; + cx.notify(); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("field", cx); + window.press(SELECT_ALL, cx); + let pad = padding(window, &view, cx); + right(window, pad, true, cx); + right(window, pad, false, cx); + window.dispatch_action(Box::new(Copy), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + let requests = view.read(cx).requests.borrow(); + let request = requests.last().unwrap(); + assert!(request.capabilities.is_readonly()); + assert!(request.capabilities.is_copyable()); + assert!(!request.capabilities.is_editable()); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text()) + .as_deref(), + Some("abcdef") + ); + drop(requests); + view.update(cx, |view, cx| { + view.readonly = false; + cx.notify(); + }); + let input = view.read(cx).input.clone(); + input.update(cx, |state, cx| state.set_masked(true, window, cx)); + cx.write_to_clipboard(ClipboardItem::new_string("canary".into())); + window.render_frame(cx); + let pad = padding(window, &view, cx); + right(window, pad, true, cx); + right(window, pad, false, cx); + window.dispatch_action(Box::new(Copy), cx); + window.dispatch_action(Box::new(Cut), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + let requests = view.read(cx).requests.borrow(); + assert_eq!(requests.len(), 2); + let capabilities = requests.last().unwrap().capabilities; + assert!(capabilities.is_editable()); + assert!(capabilities.is_masked()); + assert!(!capabilities.is_copyable()); + assert_eq!(window.find("field").value(), None); + assert_eq!(view.read(cx).input.read(cx).value(), "abcdef"); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text()) + .as_deref(), + Some("canary") + ); + }) + .unwrap(); +} + +// Exercise the actual default presenter where a headless menu is available. +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +#[gpui_kit::test] +fn default_padding_menu_pastes_through_the_real_drawn_menu(cx: &mut TestAppContext) { + let (handle, view) = fields(cx); + common::update_content(handle, &view, cx, |view, _, cx| { + view.recording = false; + cx.notify(); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("field", cx); + window.press(SELECT_ALL, cx); + cx.write_to_clipboard(ClipboardItem::new_string("pasted".into())); + let pad = padding(window, &view, cx); + right(window, pad, true, cx); + right(window, pad, false, cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + let mut menu = window.within("popup-menu"); + assert_eq!(menu.find(2usize).label(), Some("Paste")); + menu.click(2usize, cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!(window.find("field").value(), Some("pasted")); + assert_eq!(window.find("other").value(), Some("unrelated")); + }) + .unwrap(); +} From e37fa2a4f5550f705ce860436be78cd42c3d9419 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 8 Oct 2026 05:52:02 -0700 Subject: [PATCH 2/2] fix(test): observe dropdown button semantics with test support enabled Use the existing ObservedElement alias when inspecting the styled dropdown trigger. Hosted test-support wraps the native Stateful Div; downcasting it as a bare Div panicked before exercising accessibility. Preserve label, Click, expanded and disabled assertions, with no production behavior change. Validation: focused test-support accessibility regression 1 passed, 0 failed, 586 filtered; exact-file rustfmt and diff checks passed. Earlier Linux CI job113314166234 retained: Base1248 passed, Component616 passed/1 failed. Input implementation unchanged from ba627 (166 input +2 Base checks passed). Root reviewed the delegated source change; hosted qualification pending. APPS-256. --- crates/component/src/menu/dropdown_menu.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/component/src/menu/dropdown_menu.rs b/crates/component/src/menu/dropdown_menu.rs index 8c544e1595..80623d582a 100644 --- a/crates/component/src/menu/dropdown_menu.rs +++ b/crates/component/src/menu/dropdown_menu.rs @@ -378,7 +378,7 @@ mod tests { let dropdown = button.dropdown_menu(|menu, _, _| menu); let mut element = dropdown.trigger.render(window, cx).into_any_element(); // The styled Button renders an unstyled Button view, whose - // actual semantic element is its rendered Stateful
. + // semantic element uses the feature-aware observation alias. let base = element .downcast_mut::>() .unwrap(); @@ -386,7 +386,7 @@ mod tests { let child = child .as_mut() .unwrap() - .downcast_mut::>() + .downcast_mut::>>() .unwrap(); let mut node = gpui::accesskit::Node::new(gpui::Role::Button); child.write_a11y_info(&mut node);