Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions crates/base/src/text_selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2308,6 +2308,23 @@ fn with_text_selection_scope<T>(
pub struct TextSelection;

impl TextSelection {
/// Returns whether `position` hits selectable text in the active scope.
///
/// Uses the current frame's registered text geometry and hitboxes without
/// starting or changing a selection. Blank space and text outside the active
/// selection scope do not match. A containing control can yield a long press
/// to the window's text selection before claiming the gesture itself.
/// Call during pointer dispatch with the event's position, so the hitboxes
/// reflect the point being queried. Touch-selection controls do not match.
pub fn is_selectable_at(position: Point<Pixels>, window: &Window, cx: &mut App) -> bool {
live_text_selection_state(window, cx).is_some_and(|state| {
state.update(cx, |state, cx| {
!state.touch.covers(position)
&& state.endpoint(position, Some(window), cx).inside_text
})
})
}

/// Returns the currently selected text in logical document order.
pub fn selected_text(window: &mut Window, cx: &mut App) -> String {
let Some(state) = live_text_selection_state(window, cx) else {
Expand Down
289 changes: 152 additions & 137 deletions crates/component/src/menu/context_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ use std::{
use gpui::{
Anchor, AnyElement, App, Bounds, Context, DismissEvent, Element, ElementId, Entity,
FocusHandle, Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId,
InteractiveElement, IntoElement, LayoutId, MouseButton, MouseDownEvent, ParentElement, Pixels,
Point, Position, Style, StyleRefinement, Styled, Subscription, Window, anchored, deferred, div,
px,
InteractiveElement, IntoElement, LayoutId, LongPressEvent, MouseButton, MouseDownEvent,
ParentElement, Pixels, Point, Position, Style, StyleRefinement, Styled, Subscription,
TouchPhase, Window, anchored, deferred, div, px,
};

use crate::{input::AnyInputState, menu::PopupMenu, root::WindowState};
Expand Down Expand Up @@ -46,7 +46,7 @@ impl<E: InteractiveElement + ParentElement + Styled> ContextMenuExt for E {}
pub struct ContextMenu<E: ParentElement + Styled + Sized> {
id: ElementId,
element: Option<E>,
menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>>,
menu: Option<Rc<MenuBuilder>>,
// This is not in use, just for style refinement forwarding.
_ignore_style: StyleRefinement,
anchor: Anchor,
Expand Down Expand Up @@ -370,152 +370,167 @@ impl<E: ParentElement + Styled + IntoElement + 'static> Element for ContextMenu<

fn paint(
&mut self,
id: Option<&gpui::GlobalElementId>,
_: Option<&gpui::GlobalElementId>,
_: Option<&InspectorElementId>,
_: gpui::Bounds<gpui::Pixels>,
request_layout: &mut Self::RequestLayoutState,
hitbox: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
let builder = self.menu.clone();
let shared_state = request_layout.shared_state.clone();

// A finger has no right button, so a long press opens the menu. This
// listener goes in before the children paint, so an input inside the
// trigger claims its long press first. Rich-text selection is handled
// by the window layer, which runs later; yield over its text geometry.
window.on_mouse_event({
let shared_state = shared_state.clone();
let builder = builder.clone();
let hitbox = hitbox.clone();
move |event: &LongPressEvent, phase, window, cx| {
if phase.bubble()
&& event.phase == TouchPhase::Started
&& !window.default_prevented()
&& hitbox.is_hovered(window)
&& !gpui_base::TextSelection::is_selectable_at(event.start_position, window, cx)
{
window.prevent_default();
open_menu(&shared_state, &builder, event.start_position, window, cx);
}
}
});

if let Some(element) = &mut request_layout.element {
element.paint(window, cx);
}

// Take the builder before setting up element state to avoid borrow issues
let builder = self.menu.clone();
// When right mouse click, to build content menu, and show it at the mouse position.
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) {
open_menu(&shared_state, &builder, event.position, window, cx);
}
});
}
}

self.with_element_state(
id.unwrap(),
window,
cx,
|_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
&& hitbox.is_hovered(window)
{
// Capture the focused element to restore focus to on dismiss.
// If focus is still on the previous menu, keep its captured focus.
let previous_focus_handle = window.focused(cx).and_then(|focused| {
let shared_state = shared_state.borrow();
match shared_state.menu_view.as_ref() {
Some(menu) if menu.read(cx).focus_handle == focused => {
menu.read(cx).previous_focus_handle.clone()
}
_ => Some(focused),
}
});

// Retain the old menu's owner before clearing replacement state.
let previous_selection_input = {
let state = shared_state.borrow();
let menu_focused = state.menu_view.as_ref().is_some_and(|menu| {
let focus = menu.focus_handle(cx);
focus.is_focused(window) || focus.contains_focused(window, cx)
});
state
.selection_input
.as_ref()
.filter(|input| {
menu_focused || input.focus_handle(cx).is_focused(window)
})
.cloned()
};
let position = event.position;

{
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.selection_input = 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();
move |window, cx| {
// Resolve after pointer dispatch: the inner input has
// now taken focus and published its state, even if it
// was unfocused in the last rendered frame.
let selection_input =
WindowState::try_update(window, cx, |state, _, _| {
state.focused_input.clone()
})
.flatten()
.filter(|input| input.focus_handle(cx).is_focused(window))
.or(previous_selection_input);
let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
let Some(build) = &builder else {
return menu;
};
build(menu, window, cx)
});
let trigger_focus_handle =
shared_state.borrow().trigger_focus_handle.clone();
menu.update(cx, |menu, cx| {
menu.set_trigger_focus(trigger_focus_handle, cx);
menu.set_previous_focus(previous_focus_handle, cx);
});

// A menu on another element must not keep the last
// focused input highlighted. An explicit action target
// takes precedence over where the press landed.
let selection_input = selection_input.filter(|input| {
let Some(bounds) = input.input_bounds(cx) else {
return false;
};
match menu.read(cx).action_context.as_ref() {
Some(target) => target == &input.focus_handle(cx),
None => bounds.contains(&position),
}
});
if let Some(input) = selection_input.as_ref() {
input.set_selection_focus(Some(menu.focus_handle(cx)), cx);
}

// Set up the subscription for dismiss handling.
// Hold a Weak here, not a strong clone: the closure
// would otherwise close the cycle
// `shared_state -> _subscription -> closure ->
// shared_state`, so a menu left open when the window
// closes leaks its PopupMenu entity.
let _subscription = window.subscribe(&menu, cx, {
let shared_state = Rc::downgrade(&shared_state);
move |_, _: &DismissEvent, window, _cx| {
if let Some(shared_state) = shared_state.upgrade() {
let mut state = shared_state.borrow_mut();
state.open = false;
state.selection_input = None;
window.refresh();
}
}
});

// Update the shared state with the built menu and subscription
{
let mut state = shared_state.borrow_mut();
state.menu_view = Some(menu.clone());
state.selection_input = selection_input;
state._subscription = Some(_subscription);
window.refresh();
}
}
});
}
});
},
);
type MenuBuilder = dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu;

/// Builds the menu and shows it at `position`.
fn open_menu(
shared_state: &Rc<RefCell<ContextMenuSharedState>>,
builder: &Option<Rc<MenuBuilder>>,
position: Point<Pixels>,
window: &mut Window,
cx: &mut App,
) {
// Capture the focused element to restore focus to on dismiss.
// If focus is still on the previous menu, keep its captured focus.
let previous_focus_handle = window.focused(cx).and_then(|focused| {
let shared_state = shared_state.borrow();
match shared_state.menu_view.as_ref() {
Some(menu) if menu.read(cx).focus_handle == focused => {
menu.read(cx).previous_focus_handle.clone()
}
_ => Some(focused),
}
});

// Retain the old menu's owner before clearing replacement state.
let previous_selection_input = {
let state = shared_state.borrow();
let menu_focused = state.menu_view.as_ref().is_some_and(|menu| {
let focus = menu.focus_handle(cx);
focus.is_focused(window) || focus.contains_focused(window, cx)
});
state
.selection_input
.as_ref()
.filter(|input| menu_focused || input.focus_handle(cx).is_focused(window))
.cloned()
};
{
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.selection_input = None;
shared_state._subscription = None;
shared_state.position = 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();
move |window, cx| {
// Resolve after pointer dispatch: the inner input has
// now taken focus and published its state, even if it
// was unfocused in the last rendered frame.
let selection_input =
WindowState::try_update(window, cx, |state, _, _| state.focused_input.clone())
.flatten()
.filter(|input| input.focus_handle(cx).is_focused(window))
.or(previous_selection_input);
let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
let Some(build) = &builder else {
return menu;
};
build(menu, window, cx)
});
let trigger_focus_handle = shared_state.borrow().trigger_focus_handle.clone();
menu.update(cx, |menu, cx| {
menu.set_trigger_focus(trigger_focus_handle, cx);
menu.set_previous_focus(previous_focus_handle, cx);
});

// A menu on another element must not keep the last
// focused input highlighted. An explicit action target
// takes precedence over where the press landed.
let selection_input = selection_input.filter(|input| {
let Some(bounds) = input.input_bounds(cx) else {
return false;
};
match menu.read(cx).action_context.as_ref() {
Some(target) => target == &input.focus_handle(cx),
None => bounds.contains(&position),
}
});
if let Some(input) = selection_input.as_ref() {
input.set_selection_focus(Some(menu.focus_handle(cx)), cx);
}

// Set up the subscription for dismiss handling.
// Hold a Weak here, not a strong clone: the closure
// would otherwise close the cycle
// `shared_state -> _subscription -> closure ->
// shared_state`, so a menu left open when the window
// closes leaks its PopupMenu entity.
let _subscription = window.subscribe(&menu, cx, {
let shared_state = Rc::downgrade(&shared_state);
move |_, _: &DismissEvent, window, _cx| {
if let Some(shared_state) = shared_state.upgrade() {
let mut state = shared_state.borrow_mut();
state.open = false;
state.selection_input = None;
window.refresh();
}
}
});

// Update the shared state with the built menu and subscription
{
let mut state = shared_state.borrow_mut();
state.menu_view = Some(menu.clone());
state.selection_input = selection_input;
state._subscription = Some(_subscription);
window.refresh();
}
}
});
}

#[cfg(test)]
Expand Down
Loading
Loading