Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions src/app/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2216,6 +2216,7 @@ impl AppState {
self.selection = Some(selection);
self.selection_autoscroll = None;
if let Some(text) = text {
self.request_clipboard_feedback = None;
self.request_clipboard_write = Some(text.into_bytes());
info!("copied double-clicked token to clipboard");
}
Expand Down Expand Up @@ -2287,6 +2288,7 @@ impl AppState {
.and_then(|rt| rt.extract_selection(&sel));
if let Some(text) = text {
if !text.is_empty() {
self.request_clipboard_feedback = None;
self.request_clipboard_write = Some(text.into_bytes());
info!("copied selection to clipboard");
}
Expand Down
13 changes: 8 additions & 5 deletions src/app/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,12 @@ impl App {
crate::selection::write_osc52_bytes(&content);
#[cfg(test)]
let _ = content;
self.show_clipboard_feedback();
let message = self
.state
.request_clipboard_feedback
.take()
.unwrap_or_else(|| "copied to clipboard".to_string());
self.show_clipboard_feedback(message);
return Vec::new();
}

Expand Down Expand Up @@ -464,15 +469,13 @@ impl App {
}
}

pub(crate) fn show_clipboard_feedback(&mut self) {
pub(crate) fn show_clipboard_feedback(&mut self, message: String) {
if !self.state.toast_config.clipboard.enabled {
self.state.copy_feedback = None;
self.copy_feedback_deadline = None;
return;
}
self.state.copy_feedback = Some(crate::app::state::CopyFeedback {
message: "copied to clipboard".to_string(),
});
self.state.copy_feedback = Some(crate::app::state::CopyFeedback { message });
self.copy_feedback_deadline = Some(Instant::now() + super::COPY_FEEDBACK_DURATION);
}

Expand Down
1 change: 1 addition & 0 deletions src/app/input/clipboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ impl App {
.try_send(crate::events::AppEvent::ClipboardWrite { content })
.is_err()
{
self.state.request_clipboard_feedback = None;
tracing::warn!("failed to queue clipboard write event");
}
true
Expand Down
14 changes: 12 additions & 2 deletions src/app/input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,18 @@ impl App {
return;
}

let handled_pane_double_click = self.handle_pane_double_click(mouse);
if !handled_pane_double_click {
let on_pane_copy_button = self.state.mode == Mode::Terminal
&& matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
&& self
.state
.pane_frame_at(mouse.column, mouse.row)
.and_then(crate::ui::pane_copy_button_span)
.is_some_and(|(x_start, x_end, y)| {
mouse.row == y && mouse.column >= x_start && mouse.column < x_end
});
let handled_pane_double_click =
!on_pane_copy_button && self.handle_pane_double_click(mouse);
if !handled_pane_double_click && !on_pane_copy_button {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
self.focus_pane_before_mouse_press(mouse);
}

Expand Down
193 changes: 188 additions & 5 deletions src/app/input/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::{
MenuListState, Mode, RightClickPassthroughGesture, TabPressState, ViewLayout,
WorkspacePressState,
},
layout::{PaneInfo, SplitBorder},
layout::{PaneId, PaneInfo, SplitBorder},
selection::Selection,
terminal::TerminalRuntimeRegistry,
};
Expand Down Expand Up @@ -70,6 +70,46 @@ enum MobileMouseResult {
}

impl AppState {
fn pane_copy_button_at(&self, column: u16, row: u16) -> Option<PaneId> {
self.pane_frame_at(column, row)
.filter(|info| {
crate::ui::pane_copy_button_span(info).is_some_and(|(x_start, x_end, y)| {
row == y && column >= x_start && column < x_end
})
})
.map(|info| info.id)
}

fn copy_pane_location_at(&mut self, column: u16, row: u16) -> bool {
let Some(pane_id) = self.pane_copy_button_at(column, row) else {
return false;
};
let Some(public_id) = self.active.and_then(|ws_idx| {
let workspace = self.workspaces.get(ws_idx)?;
let pane_number = workspace.public_pane_number(pane_id)?;
Some(crate::workspace::public_pane_id_for_number(
&workspace.id,
pane_number,
))
}) else {
return false;
};

self.request_clipboard_write = Some(public_id.clone().into_bytes());
self.request_clipboard_feedback = Some(format!("copied {public_id}"));
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
true
}

fn update_pane_hover(&mut self, column: u16, row: u16) {
let hover = self.pane_mouse_target(column, row).map(|info| {
let on_button = self.pane_copy_button_at(column, row) == Some(info.id);
(info.id, on_button)
});
if self.pane_hover != hover {
self.pane_hover = hover;
}
}

pub(crate) fn handle_pane_mouse_only(
&mut self,
terminal_runtimes: &TerminalRuntimeRegistry,
Expand All @@ -78,6 +118,14 @@ impl AppState {
if self.mode != Mode::Terminal {
return;
}
if matches!(mouse.kind, MouseEventKind::Moved) {
self.update_pane_hover(mouse.column, mouse.row);
}
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
&& self.copy_pane_location_at(mouse.column, mouse.row)
{
return;
}
let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else {
return;
};
Expand Down Expand Up @@ -223,6 +271,12 @@ impl AppState {

match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
if self.mode == Mode::Terminal
&& self.copy_pane_location_at(mouse.column, mouse.row)
{
return None;
}

self.selection = None;
self.selection_autoscroll = None;
self.clear_chrome_press(source_id);
Expand Down Expand Up @@ -1029,9 +1083,16 @@ impl AppState {
}
}

MouseEventKind::Moved if self.mode == Mode::Terminal && !in_sidebar => {
if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
let _ = self.forward_pane_mouse_motion(terminal_runtimes, &info, mouse);
MouseEventKind::Moved if self.mode == Mode::Terminal => {
if in_sidebar {
self.pane_hover = None;
} else {
self.update_pane_hover(mouse.column, mouse.row);
}
if !in_sidebar {
if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
let _ = self.forward_pane_mouse_motion(terminal_runtimes, &info, mouse);
}
}
}

Expand Down Expand Up @@ -2002,11 +2063,91 @@ mod tests {
use super::*;
use crate::app::input::modal::handle_context_menu_key;
use crate::{
app::state::{ContextMenuKind, ContextMenuState, MenuListState, Mode, ViewLayout},
app::{
state::{ContextMenuKind, ContextMenuState, MenuListState, Mode, ViewLayout},
App,
},
detect::{Agent, AgentState},
workspace::Workspace,
};

fn app_with_bordered_panes() -> App {
let mut app = app_for_mouse_test();
let mut workspace = Workspace::test_new("test");
workspace.test_split(Direction::Horizontal);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.selected = 0;
app.state.pane_borders = true;
app.state.pane_outer_borders = true;
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 120, 30));
app
}

#[test]
fn pane_motion_tracks_copy_control_hover_and_clears_outside_panes() {
let mut app = app_with_bordered_panes();
let info = app.state.view.pane_infos[0].clone();
let (x_start, _, y) = crate::ui::pane_copy_button_span(&info).unwrap();

app.state.handle_mouse(
&mut app.terminal_runtimes,
0,
mouse(MouseEventKind::Moved, info.inner_rect.x, info.inner_rect.y),
);
assert_eq!(app.state.pane_hover, Some((info.id, false)));

app.state.handle_mouse(
&mut app.terminal_runtimes,
0,
mouse(MouseEventKind::Moved, x_start, y),
);
assert_eq!(app.state.pane_hover, Some((info.id, true)));

app.state.handle_mouse(
&mut app.terminal_runtimes,
0,
mouse(MouseEventKind::Moved, 0, 0),
);
assert_eq!(app.state.pane_hover, None);
}

#[test]
fn pane_copy_control_click_copies_public_id_without_changing_focus() {
let mut app = app_with_bordered_panes();
let initial_focus = app.state.workspaces[0].focused_pane_id();
let other = app
.state
.view
.pane_infos
.iter()
.find(|info| Some(info.id) != initial_focus)
.cloned()
.unwrap();
let (x_start, x_end, y) = crate::ui::pane_copy_button_span(&other).unwrap();
let pane_number = app.state.workspaces[0]
.public_pane_number(other.id)
.unwrap();
let public_id =
crate::workspace::public_pane_id_for_number(&app.state.workspaces[0].id, pane_number);

app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), x_start, y));

assert_eq!(app.state.workspaces[0].focused_pane_id(), initial_focus);
assert_eq!(
app.state.request_clipboard_feedback.as_deref(),
Some(format!("copied {public_id}").as_str())
);
assert!(app.state.drag.is_none());
assert!(matches!(
app.event_rx.try_recv(),
Ok(crate::events::AppEvent::ClipboardWrite { content }) if content == public_id.as_bytes()
));

app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), x_end, y));
assert!(app.event_rx.try_recv().is_err());
}

#[test]
fn tab_click_survives_stray_drag_report_off_the_tab_bar() {
let mut app = app_for_mouse_test();
Expand Down Expand Up @@ -2798,6 +2939,48 @@ mod tests {
assert!(input_rx.try_recv().is_err());
}

#[test]
fn pane_mouse_only_tracks_hover_and_consumes_copy_control_click() {
let mut app = app_with_bordered_panes();
let initial_focus = app.state.workspaces[0].focused_pane_id();
let target = app
.state
.view
.pane_infos
.iter()
.find(|info| Some(info.id) != initial_focus)
.cloned()
.unwrap();
let (x_start, _, y) = crate::ui::pane_copy_button_span(&target).unwrap();
let pane_number = app.state.workspaces[0]
.public_pane_number(target.id)
.unwrap();
let public_id =
crate::workspace::public_pane_id_for_number(&app.state.workspaces[0].id, pane_number);

app.state.handle_pane_mouse_only(
&app.terminal_runtimes,
mouse(MouseEventKind::Moved, x_start, y),
);
assert_eq!(app.state.pane_hover, Some((target.id, true)));

app.state.handle_pane_mouse_only(
&app.terminal_runtimes,
mouse(MouseEventKind::Down(MouseButton::Left), x_start, y),
);

assert_eq!(app.state.workspaces[0].focused_pane_id(), initial_focus);
assert_eq!(
app.state.request_clipboard_write.as_deref(),
Some(public_id.as_bytes())
);
assert_eq!(
app.state.request_clipboard_feedback.as_deref(),
Some(format!("copied {public_id}").as_str())
);
assert!(app.state.drag.is_none());
}

#[tokio::test]
async fn pane_mouse_motion_uses_computed_inner_rect_offsets() {
let mut app = app_for_mouse_test();
Expand Down
2 changes: 2 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,7 @@ impl App {
request_reload_config: false,
request_client_config_reload: false,
request_clipboard_write: None,
request_clipboard_feedback: None,
creating_new_tab: false,
requested_new_tab_name: None,
pending_workspace_create_cwd: None,
Expand Down Expand Up @@ -625,6 +626,7 @@ impl App {
workspace_presses: HashMap::new(),
tab_presses: HashMap::new(),
selection: None,
pane_hover: None,
selection_autoscroll: None,
context_menu: None,
update_available,
Expand Down
4 changes: 3 additions & 1 deletion src/app/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ impl App {
true
}
crate::raw_input::RawInputEvent::Mouse(mouse) => {
let previous_pane_hover = self.state.pane_hover;
let changes_view = !matches!(mouse.kind, crossterm::event::MouseEventKind::Moved)
|| self.state.mode.mouse_motion_changes_view();
if self.state.popup_pane.is_some() || self.state.mouse_capture {
Expand All @@ -238,7 +239,7 @@ impl App {
self.state
.handle_pane_mouse_only(&self.terminal_runtimes, mouse);
}
changes_view
changes_view || self.state.pane_hover != previous_pane_hover
}
crate::raw_input::RawInputEvent::OuterFocusGained => {
#[cfg(not(windows))]
Expand Down Expand Up @@ -886,6 +887,7 @@ mod tests {
})
};

assert!(app.handle_raw_input_event(motion()).await);
assert!(!app.handle_raw_input_event(motion()).await);
app.state.mode = crate::app::Mode::GlobalMenu;
assert!(app.handle_raw_input_event(motion()).await);
Expand Down
4 changes: 4 additions & 0 deletions src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1467,6 +1467,7 @@ pub struct AppState {
/// Set when UI interaction requested a clipboard write that must be
/// handled by the outer App/event loop instead of directly from AppState.
pub request_clipboard_write: Option<Vec<u8>>,
pub(crate) request_clipboard_feedback: Option<String>,
pub creating_new_tab: bool,
pub requested_new_tab_name: Option<String>,
pub pending_workspace_create_cwd: Option<std::path::PathBuf>,
Expand Down Expand Up @@ -1496,6 +1497,7 @@ pub struct AppState {
std::collections::HashMap<crate::app::InputSourceId, WorkspacePressState>,
pub(crate) tab_presses: std::collections::HashMap<crate::app::InputSourceId, TabPressState>,
pub selection: Option<Selection>,
pub(crate) pane_hover: Option<(PaneId, bool)>,
pub selection_autoscroll: Option<SelectionAutoscroll>,
pub context_menu: Option<ContextMenuState>,
// Notifications
Expand Down Expand Up @@ -1851,6 +1853,7 @@ impl AppState {
request_reload_config: false,
request_client_config_reload: false,
request_clipboard_write: None,
request_clipboard_feedback: None,
creating_new_tab: false,
requested_new_tab_name: None,
pending_workspace_create_cwd: None,
Expand Down Expand Up @@ -1893,6 +1896,7 @@ impl AppState {
workspace_presses: std::collections::HashMap::new(),
tab_presses: std::collections::HashMap::new(),
selection: None,
pane_hover: None,
selection_autoscroll: None,
context_menu: None,
update_available: None,
Expand Down
Loading
Loading