input: Add context menu for cut, copy, paste. (#1279)

Close #27

<img width="756" height="674" alt="image"
src="https://github.com/user-attachments/assets/07884a7e-366b-47b8-8bd7-a88f3cae52b8"
/>
This commit is contained in:
Jason Lee 2025-09-23 22:06:58 +08:00 committed by GitHub
parent 993ce34bd8
commit bf5650b5db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 342 additions and 71 deletions

View file

@ -160,9 +160,31 @@ Input:
en: Replace
zh-CN: 替换
zh-HK: 替換
it: Sostituisci
Replace All:
en: Replace All
zh-CN: 全部替换
zh-HK: 全部替換
it: Sostituisci tutto
Cut:
en: Cut
zh-CN: 剪切
zh-HK: 剪切
Copy:
en: Copy
zh-CN: 复制
zh-HK: 複製
Paste:
en: Paste
zh-CN: 粘贴
zh-HK: 貼上
Select All:
en: Select All
zh-CN: 全选
zh-HK: 全選
Go to Definition:
en: Go to Definition
zh-CN: 跳转到定义
zh-HK: 跳轉到定義
Show Code Actions:
en: Show Code Actions
zh-CN: 显示代码操作
zh-HK: 顯示代碼操作

View file

@ -25,6 +25,11 @@ impl Selection {
self.start = 0;
self.end = 0;
}
/// Checks if the given offset is within the selection range.
pub fn contains(&self, offset: usize) -> bool {
offset >= self.start && offset < self.end
}
}
impl From<Range<usize>> for Selection {

View file

@ -5,7 +5,7 @@ use std::ops::Range;
use crate::input::{
popovers::{CodeActionItem, CodeActionMenu, ContextMenu},
InputState,
InputState, ToggleCodeActions,
};
pub trait CodeActionProvider {
@ -37,6 +37,15 @@ pub trait CodeActionProvider {
}
impl InputState {
pub(crate) fn on_action_toggle_code_actions(
&mut self,
_: &ToggleCodeActions,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.handle_code_action_trigger(window, cx)
}
/// Show code actions for the cursor.
pub(crate) fn handle_code_action_trigger(
&mut self,

View file

@ -6,7 +6,7 @@ use rope::Rope;
use std::{ops::Range, rc::Rc};
use crate::{
input::{element::TextElement, InputState, RopeExt},
input::{element::TextElement, GoToDefinition, InputState, RopeExt},
ActiveTheme,
};
@ -31,14 +31,31 @@ pub(crate) struct HoverDefinition {
/// The range of the symbol that triggered the hover.
symbol_range: Range<usize>,
pub(crate) locations: Rc<Vec<lsp_types::LocationLink>>,
last_location: Option<(Range<usize>, Rc<Vec<lsp_types::LocationLink>>)>,
}
impl HoverDefinition {
pub(crate) fn new(symbol_range: Range<usize>, locations: Vec<lsp_types::LocationLink>) -> Self {
Self {
symbol_range,
locations: Rc::new(locations),
pub(crate) fn update(
&mut self,
symbol_range: Range<usize>,
locations: Vec<lsp_types::LocationLink>,
) {
self.clear();
self.symbol_range = symbol_range;
self.locations = Rc::new(locations);
}
pub(crate) fn is_empty(&self) -> bool {
self.locations.is_empty()
}
pub(crate) fn clear(&mut self) {
if !self.locations.is_empty() {
self.last_location = Some((self.symbol_range.clone(), self.locations.clone()));
}
self.symbol_range = 0..0;
self.locations = Rc::new(vec![]);
}
pub(crate) fn is_same(&self, offset: usize) -> bool {
@ -47,19 +64,18 @@ impl HoverDefinition {
}
impl InputState {
pub(super) fn handle_hover_definition(
pub(crate) fn handle_hover_definition(
&mut self,
offset: usize,
window: &mut Window,
cx: &mut Context<InputState>,
cx: &mut Context<Self>,
) {
let Some(provider) = self.lsp.definition_provider.clone() else {
return;
};
if let Some(hover_definition) = self.hover_definition.as_ref() {
if hover_definition.is_same(offset) {
return;
}
if self.hover_definition.is_same(offset) {
return;
}
// Currently not implemented.
@ -71,7 +87,7 @@ impl InputState {
_ = editor.update(cx, |editor, cx| {
if locations.is_empty() {
editor.hover_definition = None;
editor.hover_definition.clear();
} else {
if let Some(location) = locations.first() {
if let Some(range) = location.origin_selection_range {
@ -81,7 +97,9 @@ impl InputState {
}
}
editor.hover_definition = Some(HoverDefinition::new(symbol_range, locations));
editor
.hover_definition
.update(symbol_range.clone(), locations.clone());
}
cx.notify();
});
@ -90,6 +108,24 @@ impl InputState {
});
}
pub(crate) fn on_action_go_to_definition(
&mut self,
_: &GoToDefinition,
_: &mut Window,
cx: &mut Context<Self>,
) {
let offset = self.cursor();
if let Some((symbol_range, locations)) = self.hover_definition.last_location.clone() {
if !(symbol_range.start..=symbol_range.end).contains(&offset) {
return;
}
if let Some(location) = locations.first().cloned() {
self.go_to_definition(&location, cx);
}
}
}
/// Return true if handled.
pub(crate) fn handle_click_hover_definition(
&mut self,
@ -102,17 +138,27 @@ impl InputState {
return false;
}
let Some(hover_definition) = self.hover_definition.as_ref() else {
if self.hover_definition.is_empty() {
return false;
};
if !hover_definition.is_same(offset) {
if !self.hover_definition.is_same(offset) {
return false;
}
let Some(location) = hover_definition.locations.first().cloned() else {
let Some(location) = self.hover_definition.locations.first().cloned() else {
return false;
};
self.go_to_definition(&location, cx);
true
}
pub(crate) fn go_to_definition(
&mut self,
location: &lsp_types::LocationLink,
cx: &mut Context<Self>,
) {
if location
.target_uri
.scheme()
@ -129,8 +175,6 @@ impl InputState {
self.move_to(start, cx);
self.select_to(end, cx);
}
true
}
}
@ -144,7 +188,7 @@ impl TextElement {
return None;
}
let Some(hover_definition) = editor.hover_definition.as_ref() else {
if editor.hover_definition.is_empty() {
return None;
};
@ -160,7 +204,10 @@ impl TextElement {
..UnderlineStyle::default()
});
Some((hover_definition.symbol_range.clone(), highlight_style))
Some((
editor.hover_definition.symbol_range.clone(),
highlight_style,
))
}
pub(crate) fn layout_hover_definition_hitbox(
@ -173,11 +220,11 @@ impl TextElement {
return None;
}
let Some(hover_definition) = editor.hover_definition.as_ref() else {
if editor.hover_definition.is_empty() {
return None;
};
let Some(bounds) = editor.range_to_bounds(&hover_definition.symbol_range) else {
let Some(bounds) = editor.range_to_bounds(&editor.hover_definition.symbol_range) else {
return None;
};

View file

@ -82,6 +82,7 @@ impl InputState {
handled = menu.handle_action(action, window, cx)
});
}
ContextMenu::MouseContext(..) => {}
};
handled
@ -114,7 +115,7 @@ impl InputState {
self.hover_popover = None;
self.handle_hover_definition(offset, window, cx);
} else {
self.hover_definition = None;
self.hover_definition.clear();
self.handle_hover_popover(offset, window, cx);
}
}

View file

@ -8,7 +8,7 @@ mod mask_pattern;
mod mode;
mod number_input;
mod otp_input;
mod popovers;
pub(crate) mod popovers;
mod rope_ext;
mod search;
mod state;

View file

@ -0,0 +1,143 @@
use gpui::{
anchored, deferred, div, prelude::FluentBuilder as _, px, App, AppContext as _, Context,
DismissEvent, Entity, IntoElement, MouseDownEvent, ParentElement as _, Pixels, Point, Render,
Styled, Subscription, Window,
};
use rust_i18n::t;
use crate::{
input::{self, popovers::ContextMenu, InputState},
popup_menu::PopupMenu,
};
/// Context menu for mouse right clicks.
pub(crate) struct MouseContextMenu {
editor: Entity<InputState>,
menu: Entity<PopupMenu>,
mouse_position: Point<Pixels>,
open: bool,
_subscriptions: Vec<Subscription>,
}
impl InputState {
pub(crate) fn handle_right_click_menu(
&mut self,
event: &MouseDownEvent,
offset: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Show Mouse context menu
if !self.selected_range.contains(offset) {
self.move_to(offset, cx);
}
self.context_menu = Some(ContextMenu::MouseContext(self.mouse_context_menu.clone()));
let is_code_editor = self.mode.is_code_editor();
if is_code_editor {
self.handle_hover_definition(offset, window, cx);
}
let has_goto_definition = self.lsp.definition_provider.is_some();
let has_code_action = !self.lsp.code_action_providers.is_empty();
let is_selected = !self.selected_range.is_empty();
let has_paste = cx.read_from_clipboard().is_some();
self.mouse_context_menu.update(cx, |this, cx| {
this.mouse_position = event.position;
this.menu.update(cx, |menu, cx| {
let new_menu = 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_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;
cx.notify();
});
this.open = true;
cx.notify();
});
}
}
impl MouseContextMenu {
pub(crate) fn new(
editor: Entity<InputState>,
window: &mut Window,
cx: &mut App,
) -> Entity<Self> {
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>) {
self.open = false;
self.editor.update(cx, |this, cx| {
this.focus(window, cx);
});
}
}
impl Render for MouseContextMenu {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
if !self.open {
return div().into_any_element();
}
let pos = self.mouse_position;
deferred(
anchored()
.snap_to_window_with_margin(px(8.))
.anchor(gpui::Corner::TopLeft)
.position(pos)
.child(
div()
.font_family(".SystemUIFont")
.text_size(px(14.))
.cursor_default()
.child(self.menu.clone()),
),
)
.into_any_element()
}
}

View file

@ -1,10 +1,12 @@
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::*;
@ -21,6 +23,7 @@ use crate::{
pub(crate) enum ContextMenu {
Completion(Entity<CompletionMenu>),
CodeAction(Entity<CodeActionMenu>),
MouseContext(Entity<MouseContextMenu>),
}
impl ContextMenu {
@ -28,6 +31,7 @@ impl ContextMenu {
match self {
ContextMenu::Completion(menu) => menu.read(cx).is_open(),
ContextMenu::CodeAction(menu) => menu.read(cx).is_open(),
ContextMenu::MouseContext(menu) => menu.read(cx).is_open(),
}
}
@ -35,6 +39,7 @@ impl ContextMenu {
match self {
ContextMenu::Completion(menu) => menu.clone().into_any_element(),
ContextMenu::CodeAction(menu) => menu.clone().into_any_element(),
ContextMenu::MouseContext(menu) => menu.clone().into_any_element(),
}
}
}

View file

@ -30,7 +30,7 @@ use super::{
text_wrapper::TextWrapper,
};
use crate::input::{
popovers::{ContextMenu, DiagnosticPopover, HoverPopover},
popovers::{ContextMenu, DiagnosticPopover, HoverPopover, MouseContextMenu},
search::{self, SearchPanel},
HoverDefinition, Lsp, Position,
};
@ -92,6 +92,7 @@ actions!(
Escape,
ToggleCodeActions,
Search,
GoToDefinition,
]
);
@ -298,11 +299,12 @@ pub struct InputState {
diagnostic_popover: Option<Entity<DiagnosticPopover>>,
/// Completion/CodeAction context menu
pub(super) context_menu: Option<ContextMenu>,
pub(super) mouse_context_menu: Entity<MouseContextMenu>,
/// A flag to indicate if we are currently inserting a completion item.
pub(super) completion_inserting: bool,
pub(super) hover_popover: Option<Entity<HoverPopover>>,
/// The LSP definitions locations for "Go to Definition" feature.
pub(super) hover_definition: Option<HoverDefinition>,
pub(super) hover_definition: HoverDefinition,
pub lsp: Lsp,
@ -349,6 +351,7 @@ impl InputState {
];
let text_style = window.text_style();
let mouse_context_menu = MouseContextMenu::new(cx.entity(), window, cx);
Self {
focus_handle: focus_handle.clone(),
@ -390,9 +393,10 @@ impl InputState {
lsp: Lsp::default(),
diagnostic_popover: None,
context_menu: None,
mouse_context_menu,
completion_inserting: false,
hover_popover: None,
hover_definition: None,
hover_definition: HoverDefinition::default(),
silent_replace_text: false,
_subscriptions,
_context_menu_task: Task::ready(Ok(())),
@ -1530,15 +1534,6 @@ impl InputState {
cx.propagate();
}
pub(super) fn toggle_code_actions(
&mut self,
_: &ToggleCodeActions,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.handle_code_action_trigger(window, cx)
}
pub(super) fn on_mouse_down(
&mut self,
event: &MouseDownEvent,
@ -1566,6 +1561,12 @@ impl InputState {
return;
}
// Show Mouse context menu
if event.button == MouseButton::Right {
self.handle_right_click_menu(event, offset, window, cx);
return;
}
if event.modifiers.shift {
self.select_to(offset, cx);
} else {

View file

@ -278,7 +278,9 @@ impl RenderOnce for TextInput {
.on_action(window.listener_for(&self.state, InputState::indent_block))
.on_action(window.listener_for(&self.state, InputState::outdent_block))
})
.on_action(window.listener_for(&self.state, InputState::toggle_code_actions))
.on_action(
window.listener_for(&self.state, InputState::on_action_toggle_code_actions),
)
})
.on_action(window.listener_for(&self.state, InputState::left))
.on_action(window.listener_for(&self.state, InputState::right))
@ -291,6 +293,9 @@ impl RenderOnce for TextInput {
.on_action(window.listener_for(&self.state, InputState::select_down))
.on_action(window.listener_for(&self.state, InputState::page_up))
.on_action(window.listener_for(&self.state, InputState::page_down))
.on_action(
window.listener_for(&self.state, InputState::on_action_go_to_definition),
)
})
.on_action(window.listener_for(&self.state, InputState::select_all))
.on_action(window.listener_for(&self.state, InputState::select_to_start_of_line))
@ -313,10 +318,18 @@ impl RenderOnce for TextInput {
MouseButton::Left,
window.listener_for(&self.state, InputState::on_mouse_down),
)
.on_mouse_down(
MouseButton::Right,
window.listener_for(&self.state, InputState::on_mouse_down),
)
.on_mouse_up(
MouseButton::Left,
window.listener_for(&self.state, InputState::on_mouse_up),
)
.on_mouse_up(
MouseButton::Right,
window.listener_for(&self.state, InputState::on_mouse_up),
)
.on_mouse_move(window.listener_for(&self.state, InputState::on_mouse_move))
.on_scroll_wheel(window.listener_for(&self.state, InputState::on_scroll_wheel))
.size_full()

View file

@ -2,7 +2,7 @@ use std::{cell::RefCell, rc::Rc};
use gpui::{
anchored, deferred, div, prelude::FluentBuilder, px, relative, AnyElement, App, Context,
Corner, DismissEvent, DispatchPhase, Element, ElementId, Entity, Focusable, GlobalElementId,
Corner, DismissEvent, Element, ElementId, Entity, Focusable, GlobalElementId,
InspectorElementId, InteractiveElement, IntoElement, MouseButton, MouseDownEvent,
ParentElement, Pixels, Point, Position, Stateful, Style, Window,
};
@ -224,7 +224,7 @@ impl Element for ContextMenu {
// 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 == DispatchPhase::Bubble
if phase.bubble()
&& event.button == MouseButton::Right
&& bounds.contains(&event.position)
{

View file

@ -6,18 +6,17 @@ use crate::{
button::Button, h_flex, popover::Popover, v_flex, ActiveTheme, Icon, IconName, Selectable,
Sizable as _,
};
use crate::{Kbd, Side, StyledExt};
use crate::{Kbd, Side, Size, StyledExt};
use gpui::{
anchored, canvas, div, prelude::FluentBuilder, px, rems, Action, AnyElement, App, AppContext,
Bounds, Context, Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, KeyBinding, ParentElement, Pixels, Render, ScrollHandle,
SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window,
};
use gpui::{AsKeystroke, MouseDownEvent, Subscription};
use gpui::{AsKeystroke, Half, MouseDownEvent, Subscription};
use std::ops::Deref;
use std::rc::Rc;
const ITEM_HEIGHT: Pixels = px(26.);
const CONTEXT: &str = "PopupMenu";
pub fn init(cx: &mut App) {
@ -61,7 +60,7 @@ pub trait PopupMenuExt: Styled + Selectable + InteractiveElement + IntoElement +
}
impl PopupMenuExt for Button {}
enum PopupMenuItem {
pub(crate) enum PopupMenuItem {
Separator,
Label(SharedString),
Item {
@ -113,13 +112,14 @@ pub struct PopupMenu {
/// The parent menu of this menu, if this is a submenu
parent_menu: Option<WeakEntity<Self>>,
focus_handle: FocusHandle,
menu_items: Vec<PopupMenuItem>,
pub(crate) menu_items: Vec<PopupMenuItem>,
has_icon: bool,
selected_index: Option<usize>,
min_width: Option<Pixels>,
max_width: Option<Pixels>,
max_height: Option<Pixels>,
bounds: Bounds<Pixels>,
size: Size,
scrollable: bool,
external_link_icon: bool,
@ -131,32 +131,35 @@ pub struct PopupMenu {
}
impl PopupMenu {
pub(crate) fn new(cx: &mut App) -> Self {
Self {
focus_handle: cx.focus_handle(),
previous_focus_handle: None,
parent_menu: None,
menu_items: Vec::new(),
selected_index: None,
min_width: None,
max_width: None,
max_height: None,
has_icon: false,
bounds: Bounds::default(),
scrollable: false,
scroll_handle: ScrollHandle::default(),
scroll_state: ScrollbarState::default(),
external_link_icon: true,
size: Size::default(),
_subscriptions: vec![],
}
}
pub fn build(
window: &mut Window,
cx: &mut App,
f: impl FnOnce(Self, &mut Window, &mut Context<PopupMenu>) -> Self,
) -> Entity<Self> {
cx.new(|cx| {
let focus_handle = cx.focus_handle();
let _subscriptions = vec![];
let menu = Self {
focus_handle,
previous_focus_handle: window.focused(cx),
parent_menu: None,
menu_items: Vec::new(),
selected_index: None,
min_width: None,
max_width: None,
max_height: None,
has_icon: false,
bounds: Bounds::default(),
scrollable: false,
scroll_handle: ScrollHandle::default(),
scroll_state: ScrollbarState::default(),
external_link_icon: true,
_subscriptions,
};
let mut menu = Self::new(cx);
menu.previous_focus_handle = window.focused(cx);
f(menu, window, cx)
})
}
@ -198,6 +201,17 @@ impl PopupMenu {
self.menu_with_disabled(label, action, false)
}
/// Add Menu Item with enable state
pub fn menu_with_enable(
mut self,
label: impl Into<SharedString>,
action: Box<dyn Action>,
enable: bool,
) -> Self {
self.add_menu_item(label, None, action, !enable);
self
}
/// Add Menu Item with disabled state
pub fn menu_with_disabled(
mut self,
@ -428,6 +442,12 @@ impl PopupMenu {
})
}
/// Use small size, the menu item will have smaller height.
pub(crate) fn small(mut self) -> Self {
self.size = Size::Small;
self
}
/// Add a separator Menu Item
pub fn separator(mut self) -> Self {
if self.menu_items.is_empty() {
@ -805,12 +825,17 @@ impl PopupMenu {
const EDGE_PADDING: Pixels = px(8.);
const INNER_PADDING: Pixels = px(4.);
let (item_height, radius) = match self.size {
Size::Small => (px(20.), state.radius.half()),
_ => (px(26.), state.radius),
};
let this = MenuItem::new(ix)
.relative()
.text_sm()
.py_0()
.px(INNER_PADDING)
.rounded(state.radius)
.rounded(radius)
.items_center()
.hovered(selected)
.on_mouse_enter(cx.listener(move |this, _, _, cx| {
@ -853,7 +878,7 @@ impl PopupMenu {
.disabled(*disabled)
.child(
h_flex()
.min_h(ITEM_HEIGHT)
.min_h(item_height)
.items_center()
.gap_x_1()
.children(Self::render_icon(has_icon, icon.clone(), window, cx))
@ -877,7 +902,7 @@ impl PopupMenu {
)
})
.disabled(*disabled)
.h(ITEM_HEIGHT)
.h(item_height)
.children(Self::render_icon(has_icon, icon.clone(), window, cx))
.child(
h_flex()
@ -914,7 +939,7 @@ impl PopupMenu {
.items_start()
.child(
h_flex()
.min_h(ITEM_HEIGHT)
.min_h(item_height)
.size_full()
.items_center()
.gap_x_1()