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/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); 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(); +}