diff --git a/src/app/actions.rs b/src/app/actions.rs index a45687b480..f026f44231 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2216,7 +2216,7 @@ impl AppState { self.selection = Some(selection); self.selection_autoscroll = None; if let Some(text) = text { - self.request_clipboard_write = Some(text.into_bytes()); + self.request_clipboard_write = Some((text.into_bytes(), None)); info!("copied double-clicked token to clipboard"); } true @@ -2287,7 +2287,7 @@ impl AppState { .and_then(|rt| rt.extract_selection(&sel)); if let Some(text) = text { if !text.is_empty() { - self.request_clipboard_write = Some(text.into_bytes()); + self.request_clipboard_write = Some((text.into_bytes(), None)); info!("copied selection to clipboard"); } } diff --git a/src/app/api.rs b/src/app/api.rs index 92d9596fb7..a7e28f3f8c 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -122,12 +122,14 @@ impl App { return Vec::new(); } - if let AppEvent::ClipboardWrite { content } = ev { + if let AppEvent::ClipboardWrite { content, feedback } = ev { #[cfg(not(test))] crate::selection::write_osc52_bytes(&content); #[cfg(test)] let _ = content; - self.show_clipboard_feedback(); + self.show_clipboard_feedback( + feedback.unwrap_or_else(|| "copied to clipboard".to_string()), + ); return Vec::new(); } @@ -464,15 +466,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); } diff --git a/src/app/input/clipboard.rs b/src/app/input/clipboard.rs index 095b944d24..67a3380ac5 100644 --- a/src/app/input/clipboard.rs +++ b/src/app/input/clipboard.rs @@ -13,13 +13,13 @@ fn is_retained_selection_copy_key(key: &TerminalKey) -> bool { } impl App { - pub(super) fn dispatch_pending_clipboard_write(&mut self) -> bool { - let Some(content) = self.state.request_clipboard_write.take() else { + pub(crate) fn dispatch_pending_clipboard_write(&mut self) -> bool { + let Some((content, feedback)) = self.state.request_clipboard_write.take() else { return false; }; if self .event_tx - .try_send(crate::events::AppEvent::ClipboardWrite { content }) + .try_send(crate::events::AppEvent::ClipboardWrite { content, feedback }) .is_err() { tracing::warn!("failed to queue clipboard write event"); @@ -117,7 +117,10 @@ mod tests { fn clipboard_write_content(app: &mut App) -> Vec { match app.event_rx.try_recv().expect("clipboard write event") { - AppEvent::ClipboardWrite { content } => content, + AppEvent::ClipboardWrite { + content, + feedback: None, + } => content, event => panic!("unexpected event: {event:?}"), } } @@ -159,7 +162,10 @@ mod tests { assert!(app.state.selection.is_none()); assert!(input_rx.try_recv().is_err()); - app.handle_internal_event(AppEvent::ClipboardWrite { content }); + app.handle_internal_event(AppEvent::ClipboardWrite { + content, + feedback: None, + }); assert_eq!( app.state .copy_feedback diff --git a/src/app/input/copy_mode.rs b/src/app/input/copy_mode.rs index 8bdbfd387a..0c9f717f7c 100644 --- a/src/app/input/copy_mode.rs +++ b/src/app/input/copy_mode.rs @@ -1120,9 +1120,10 @@ mod tests { fn copy_mode_clipboard_text(app: &mut App) -> String { match app.event_rx.try_recv().expect("clipboard event") { - AppEvent::ClipboardWrite { content } => { - String::from_utf8(content).expect("utf8 clipboard") - } + AppEvent::ClipboardWrite { + content, + feedback: None, + } => String::from_utf8(content).expect("utf8 clipboard"), other => panic!("unexpected event: {other:?}"), } } @@ -2124,7 +2125,10 @@ mod tests { app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); match app.event_rx.try_recv().expect("clipboard event") { - AppEvent::ClipboardWrite { content } => assert_eq!(content, b"alp"), + AppEvent::ClipboardWrite { + content, + feedback: None, + } => assert_eq!(content, b"alp"), other => panic!("unexpected event: {other:?}"), } assert_eq!(app.state.mode, Mode::Terminal); diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 597d929673..d933c9a439 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -352,6 +352,20 @@ impl App { _ => {} } + match mouse.kind { + MouseEventKind::Drag(MouseButton::Left) + if self.state.pane_copy_presses.contains(&source_id) => + { + return; + } + MouseEventKind::Up(MouseButton::Left) + if self.state.pane_copy_presses.remove(&source_id) => + { + return; + } + _ => {} + } + if self.state.popup_pane.is_some() { self.handle_popup_mouse(mouse); return; @@ -384,8 +398,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 { self.focus_pane_before_mouse_press(mouse); } diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 3c6c86f4fc..9f4a82dba4 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -9,7 +9,7 @@ use crate::{ MenuListState, Mode, RightClickPassthroughGesture, TabPressState, ViewLayout, WorkspacePressState, }, - layout::{PaneInfo, SplitBorder}, + layout::{PaneId, PaneInfo, SplitBorder}, selection::Selection, terminal::TerminalRuntimeRegistry, }; @@ -70,14 +70,76 @@ enum MobileMouseResult { } impl AppState { + fn pane_copy_button_at(&self, column: u16, row: u16) -> Option { + 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(), + Some(format!("copied {public_id}")), + )); + 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, + source_id: crate::app::InputSourceId, mouse: MouseEvent, ) { if self.mode != Mode::Terminal { return; } + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + self.pane_copy_presses.remove(&source_id); + if self.copy_pane_location_at(mouse.column, mouse.row) { + self.pane_copy_presses.insert(source_id); + return; + } + } + MouseEventKind::Drag(MouseButton::Left) + if self.pane_copy_presses.contains(&source_id) => + { + return; + } + MouseEventKind::Up(MouseButton::Left) if self.pane_copy_presses.remove(&source_id) => { + return; + } + MouseEventKind::Moved => self.update_pane_hover(mouse.column, mouse.row), + _ => {} + } let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else { return; }; @@ -223,6 +285,14 @@ impl AppState { match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { + self.pane_copy_presses.remove(&source_id); + if self.mode == Mode::Terminal + && self.copy_pane_location_at(mouse.column, mouse.row) + { + self.pane_copy_presses.insert(source_id); + return None; + } + self.selection = None; self.selection_autoscroll = None; self.clear_chrome_press(source_id); @@ -1029,9 +1099,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); + } } } @@ -2002,11 +2079,89 @@ 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!(app.state.drag.is_none()); + assert!(matches!( + app.event_rx.try_recv(), + Ok(crate::events::AppEvent::ClipboardWrite { content, feedback }) + if content == public_id.as_bytes() + && feedback.as_deref() == Some(format!("copied {public_id}").as_str()) + )); + + 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(); @@ -2720,6 +2875,56 @@ mod tests { assert!(input_rx.try_recv().is_err()); } + #[tokio::test] + async fn captured_copy_control_consumes_drag_and_up_then_allows_fresh_click() { + let mut app = app_with_bordered_panes(); + let target = app.state.view.pane_infos[0].clone(); + let (x_start, _, y) = crate::ui::pane_copy_button_span(&target).unwrap(); + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + target.inner_rect.width, + target.inner_rect.height, + 0, + b"\x1b[?1002h\x1b[?1006h", + 4, + ); + app.state.insert_test_runtime(target.id, runtime); + + app.handle_mouse_from_input_source( + 41, + mouse(MouseEventKind::Down(MouseButton::Left), x_start, y), + ); + app.handle_mouse_from_input_source( + 41, + mouse( + MouseEventKind::Drag(MouseButton::Left), + target.inner_rect.x, + target.inner_rect.y, + ), + ); + app.handle_mouse_from_input_source( + 41, + mouse( + MouseEventKind::Up(MouseButton::Left), + target.inner_rect.x, + target.inner_rect.y, + ), + ); + + assert!(input_rx.try_recv().is_err()); + assert!(!app.state.pane_copy_presses.contains(&41)); + + app.handle_mouse_from_input_source( + 41, + mouse( + MouseEventKind::Down(MouseButton::Left), + target.inner_rect.x, + target.inner_rect.y, + ), + ); + assert!(input_rx.try_recv().is_ok()); + } + #[tokio::test] async fn captured_left_press_focuses_target_before_forwarding() { let mut app = app_for_mouse_test(); @@ -2784,6 +2989,7 @@ mod tests { app.state.handle_pane_mouse_only( &app.terminal_runtimes, + 0, mouse( MouseEventKind::Moved, info.inner_rect.x + 2, @@ -2798,6 +3004,108 @@ 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, + 0, + 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, + 0, + mouse(MouseEventKind::Down(MouseButton::Left), x_start, y), + ); + + assert_eq!(app.state.workspaces[0].focused_pane_id(), initial_focus); + let (content, feedback) = app + .state + .request_clipboard_write + .as_ref() + .expect("pending clipboard write"); + assert_eq!(content, public_id.as_bytes()); + assert_eq!( + feedback.as_deref(), + Some(format!("copied {public_id}").as_str()) + ); + assert!(app.state.drag.is_none()); + } + + #[tokio::test] + async fn pane_mouse_only_copy_control_consumes_drag_and_up_then_allows_fresh_click() { + let mut app = app_with_bordered_panes(); + let target = app.state.view.pane_infos[0].clone(); + let (x_start, _, y) = crate::ui::pane_copy_button_span(&target).unwrap(); + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + target.inner_rect.width, + target.inner_rect.height, + 0, + b"\x1b[?1002h\x1b[?1006h", + 4, + ); + app.state.insert_test_runtime(target.id, runtime); + app.state.pane_hover = Some((target.id, true)); + + app.state.handle_pane_mouse_only( + &app.terminal_runtimes, + 41, + mouse(MouseEventKind::Down(MouseButton::Left), x_start, y), + ); + app.state.handle_pane_mouse_only( + &app.terminal_runtimes, + 41, + mouse( + MouseEventKind::Drag(MouseButton::Left), + target.inner_rect.x, + target.inner_rect.y, + ), + ); + assert_eq!(app.state.pane_hover, Some((target.id, true))); + app.state.handle_pane_mouse_only( + &app.terminal_runtimes, + 41, + mouse( + MouseEventKind::Up(MouseButton::Left), + target.inner_rect.x, + target.inner_rect.y, + ), + ); + + assert!(input_rx.try_recv().is_err()); + assert!(!app.state.pane_copy_presses.contains(&41)); + + app.state.handle_pane_mouse_only( + &app.terminal_runtimes, + 41, + mouse( + MouseEventKind::Down(MouseButton::Left), + target.inner_rect.x, + target.inner_rect.y, + ), + ); + assert!(input_rx.try_recv().is_ok()); + } + #[tokio::test] async fn pane_mouse_motion_uses_computed_inner_rect_offsets() { let mut app = app_for_mouse_test(); @@ -2824,6 +3132,7 @@ mod tests { app.state.handle_pane_mouse_only( &app.terminal_runtimes, + 0, mouse( MouseEventKind::Moved, info.inner_rect.x + 2, diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 6b3d353717..f8d188bcc0 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -497,7 +497,10 @@ mod tests { fn clipboard_write_content(app: &mut App) -> Vec { match app.event_rx.try_recv().expect("clipboard write event") { - AppEvent::ClipboardWrite { content } => content, + AppEvent::ClipboardWrite { + content, + feedback: None, + } => content, event => panic!("unexpected event: {event:?}"), } } diff --git a/src/app/mod.rs b/src/app/mod.rs index ff286c3f18..bd122934af 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -625,6 +625,8 @@ impl App { workspace_presses: HashMap::new(), tab_presses: HashMap::new(), selection: None, + pane_hover: None, + pane_copy_presses: HashSet::new(), selection_autoscroll: None, context_menu: None, update_available, @@ -1833,8 +1835,11 @@ impl App { if self.state.popup_pane.is_some() || self.state.mouse_capture { self.handle_mouse_event_headless(source_id, mouse); } else { - self.state - .handle_pane_mouse_only(&self.terminal_runtimes, mouse); + self.state.handle_pane_mouse_only( + &self.terminal_runtimes, + source_id, + mouse, + ); } } crate::raw_input::RawInputEvent::Paste(text) => { @@ -2479,6 +2484,7 @@ mod tests { app.handle_internal_event(AppEvent::ClipboardWrite { content: b"copied".to_vec(), + feedback: None, }); assert!(app.state.toast.is_none()); @@ -2494,6 +2500,7 @@ mod tests { app.handle_internal_event(AppEvent::ClipboardWrite { content: b"copied".to_vec(), + feedback: None, }); assert!(app.state.copy_feedback.is_none()); @@ -2514,6 +2521,7 @@ mod tests { app.handle_internal_event(AppEvent::ClipboardWrite { content: b"copied".to_vec(), + feedback: None, }); assert_eq!(app.state.toast, original_toast); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 8367a22350..246353afe6 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -230,15 +230,20 @@ 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 { self.handle_mouse(mouse); } else { - self.state - .handle_pane_mouse_only(&self.terminal_runtimes, mouse); + self.state.handle_pane_mouse_only( + &self.terminal_runtimes, + crate::app::LOCAL_INPUT_SOURCE, + mouse, + ); + self.dispatch_pending_clipboard_write(); } - changes_view + changes_view || self.state.pane_hover != previous_pane_hover } crate::raw_input::RawInputEvent::OuterFocusGained => { #[cfg(not(windows))] @@ -873,6 +878,45 @@ mod tests { assert!(app.selection_autoscroll_deadline.is_none()); } + #[tokio::test] + async fn pane_only_copy_click_emits_clipboard_write_event() { + let (mut app, pane_id) = test_app_with_pane(); + app.state.mode = crate::app::Mode::Terminal; + app.state.view.pane_infos[0] = crate::layout::PaneInfo { + id: pane_id, + rect: ratatui::layout::Rect::new(0, 0, 80, 24), + inner_rect: ratatui::layout::Rect::new(1, 1, 78, 22), + scrollbar_rect: None, + borders: ratatui::widgets::Borders::ALL, + is_focused: true, + }; + let public_id = crate::workspace::public_pane_id_for_number( + &app.state.workspaces[0].id, + app.state.workspaces[0] + .public_pane_number(pane_id) + .expect("public pane number"), + ); + let (column, _, row) = crate::ui::pane_copy_button_span(&app.state.view.pane_infos[0]) + .expect("copy button span"); + + app.handle_raw_input_event(crate::raw_input::RawInputEvent::Mouse( + crossterm::event::MouseEvent { + kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left), + column, + row, + modifiers: crossterm::event::KeyModifiers::empty(), + }, + )) + .await; + + assert!(matches!( + app.event_rx.try_recv(), + Ok(crate::events::AppEvent::ClipboardWrite { content, feedback }) + if content == public_id.as_bytes() + && feedback.as_deref() == Some(format!("copied {public_id}").as_str()) + )); + } + #[tokio::test] async fn passive_mouse_motion_does_not_request_monolithic_render() { let (mut app, _) = test_app_with_pane(); @@ -886,6 +930,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); diff --git a/src/app/state.rs b/src/app/state.rs index 21ee889902..3bc961296e 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1466,7 +1466,7 @@ pub struct AppState { pub request_client_config_reload: bool, /// 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>, + pub request_clipboard_write: Option<(Vec, Option)>, pub creating_new_tab: bool, pub requested_new_tab_name: Option, pub pending_workspace_create_cwd: Option, @@ -1496,6 +1496,9 @@ pub struct AppState { std::collections::HashMap, pub(crate) tab_presses: std::collections::HashMap, pub selection: Option, + /// Hovered pane ID and whether the copy button itself is hovered. + pub(crate) pane_hover: Option<(PaneId, bool)>, + pub(crate) pane_copy_presses: std::collections::HashSet, pub selection_autoscroll: Option, pub context_menu: Option, // Notifications @@ -1893,6 +1896,8 @@ impl AppState { workspace_presses: std::collections::HashMap::new(), tab_presses: std::collections::HashMap::new(), selection: None, + pane_hover: None, + pane_copy_presses: std::collections::HashSet::new(), selection_autoscroll: None, context_menu: None, update_available: None, @@ -2098,6 +2103,14 @@ impl AppState { self.tab_presses.is_empty(), "empty app state must not keep tab press state" ); + assert!( + self.pane_copy_presses.is_empty(), + "empty app state must not keep pane copy press state" + ); + assert!( + self.pane_hover.is_none(), + "empty app state must not keep pane hover state" + ); assert!( self.context_menu.is_none(), "empty app state must not keep context menu" @@ -2298,6 +2311,16 @@ impl AppState { for press in self.tab_presses.values() { assert_tab_index(press.ws_idx, press.tab_idx, "tab press"); } + for source_id in &self.pane_copy_presses { + assert!( + !self.workspace_presses.contains_key(source_id) + && !self.tab_presses.contains_key(source_id), + "pane copy press source {source_id} must not also own a chrome press" + ); + } + if let Some((pane_id, _)) = self.pane_hover { + assert_live_pane(pane_id, "pane hover"); + } if let Some(menu) = &self.context_menu { match menu.kind { ContextMenuKind::Workspace { ws_idx } @@ -2382,6 +2405,33 @@ mod tests { assert!(state.pane_exposes_host_cursor(0, pane_id)); } + #[test] + #[should_panic(expected = "pane hover references missing pane")] + fn invariants_reject_hover_for_missing_pane() { + let mut state = AppState::test_with_adversarial_identity_state(); + state.pane_hover = Some((PaneId::alloc(), true)); + + state.assert_invariants_for_test(); + } + + #[test] + #[should_panic(expected = "must not also own a chrome press")] + fn invariants_reject_copy_and_chrome_press_for_same_input_source() { + let mut state = AppState::test_with_adversarial_identity_state(); + state.pane_copy_presses.insert(41); + state.tab_presses.insert( + 41, + TabPressState { + ws_idx: 0, + tab_idx: 0, + start_col: 0, + start_row: 0, + }, + ); + + state.assert_invariants_for_test(); + } + #[test] fn adversarial_identity_state_satisfies_app_invariants_after_mutation() { let mut state = AppState::test_with_adversarial_identity_state(); diff --git a/src/events.rs b/src/events.rs index cc638e6018..80a31940f5 100644 --- a/src/events.rs +++ b/src/events.rs @@ -135,7 +135,10 @@ pub enum AppEvent { TerminalBell { pane_id: PaneId, count: u16 }, /// A pane child emitted a valid OSC 52 clipboard write. The main loop /// re-emits it through herdr's own clipboard writer. - ClipboardWrite { content: Vec }, + ClipboardWrite { + content: Vec, + feedback: Option, + }, /// Prefix-mode ASCII input-source request, emitted on entering/leaving the ASCII input /// realm. The foreground process applies the host-local TIS switch (`active = true`) / /// restore (`active = false`): the client in server mode (via server forwarding), the diff --git a/src/pane.rs b/src/pane.rs index 9f2416ab18..3d987e11a6 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1958,7 +1958,10 @@ impl PaneRuntime { publish_reported_cwd(pane_id, cwd, &reported_cwd, &read_events); } for content in result.clipboard_writes { - if let Err(err) = read_events.try_send(AppEvent::ClipboardWrite { content }) { + if let Err(err) = read_events.try_send(AppEvent::ClipboardWrite { + content, + feedback: None, + }) { warn!( pane = pane_id.raw(), err = %err, @@ -2129,7 +2132,10 @@ impl PaneRuntime { publish_reported_cwd(pane_id, cwd, &reported_cwd, &events); } for content in result.clipboard_writes { - if let Err(err) = events.try_send(AppEvent::ClipboardWrite { content }) { + if let Err(err) = events.try_send(AppEvent::ClipboardWrite { + content, + feedback: None, + }) { warn!( pane = pane_id.raw(), err = %err, diff --git a/src/server/headless.rs b/src/server/headless.rs index f72b58eaf5..1cbb558b86 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -2323,12 +2323,16 @@ impl HeadlessServer { } false } - AppEvent::ClipboardWrite { content } => { + AppEvent::ClipboardWrite { content, feedback } => { // Clipboard writes are client-local side effects. Forward them only to // the foreground client instead of broadcasting to every attached client. let data = base64::engine::general_purpose::STANDARD.encode(content.as_slice()); if self.send_to_foreground_client(ServerMessage::Clipboard { data }) { - self.app.show_clipboard_feedback(); + self.app.show_clipboard_feedback( + feedback + .clone() + .unwrap_or_else(|| "copied to clipboard".to_string()), + ); } true } @@ -2927,6 +2931,7 @@ impl HeadlessServer { &events, self.app.state.redraw_on_focus_gained, ); + let previous_pane_hover = self.app.state.pane_hover; let render_neutral_mouse_motion = events_are_render_neutral_mouse_motion(&events, self.app.state.mode); if let Some(client) = self.clients.get_mut(&client_id) { @@ -2989,7 +2994,10 @@ impl HeadlessServer { false } else { - foreground_changed || theme_changed || (interaction && !render_neutral_mouse_motion) + foreground_changed + || theme_changed + || self.app.state.pane_hover != previous_pane_hover + || (interaction && !render_neutral_mouse_motion) } } @@ -8656,12 +8664,22 @@ next_tab = "" let row = pane.inner_rect.y + 3; let input = format!("\x1b[<35;{};{}M", column + 1, row + 1).into_bytes(); + assert!(server.handle_server_event(ServerEvent::ClientInput { + client_id: 1, + data: input.clone(), + })); + assert_eq!( + input_rx.try_recv().expect("forwarded mouse motion"), + Bytes::from_static(b"\x1b[<35;3;4M") + ); assert!(!server.handle_server_event(ServerEvent::ClientInput { client_id: 1, data: input, })); assert_eq!( - input_rx.try_recv().expect("forwarded mouse motion"), + input_rx + .try_recv() + .expect("forwarded repeated mouse motion"), Bytes::from_static(b"\x1b[<35;3;4M") ); assert_eq!( @@ -10623,6 +10641,7 @@ next_tab = "" let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { content: b"test".to_vec(), + feedback: None, }); assert!(changed); @@ -10658,6 +10677,7 @@ next_tab = "" let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { content: b"test".to_vec(), + feedback: None, }); assert!(changed); @@ -10667,6 +10687,86 @@ next_tab = "" ); } + #[test] + fn custom_clipboard_feedback_surfaces_after_successful_delivery() { + let mut server = test_headless_server(); + let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::SemanticFrame, + Some(foreground_tx), + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { + content: b"test".to_vec(), + feedback: Some("copied custom pane".to_string()), + }); + + assert_eq!( + server + .app + .state + .copy_feedback + .as_ref() + .map(|feedback| feedback.message.as_str()), + Some("copied custom pane") + ); + assert!(foreground_control_rx + .recv_timeout(Duration::from_millis(100)) + .is_ok()); + } + + #[test] + fn dropped_clipboard_feedback_does_not_surface_on_later_copy() { + let mut server = test_headless_server(); + + server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { + content: b"first".to_vec(), + feedback: Some("copied w1:p1".to_string()), + }); + + let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::SemanticFrame, + Some(foreground_tx), + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { + content: b"second".to_vec(), + feedback: None, + }); + + assert_eq!( + server + .app + .state + .copy_feedback + .as_ref() + .map(|feedback| feedback.message.as_str()), + Some("copied to clipboard") + ); + assert!(foreground_control_rx + .recv_timeout(Duration::from_millis(100)) + .is_ok()); + } + #[test] fn clipboard_write_failed_foreground_send_does_not_show_feedback() { let mut server = test_headless_server(); @@ -10690,6 +10790,7 @@ next_tab = "" let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite { content: b"test".to_vec(), + feedback: None, }); assert!(changed); diff --git a/src/ui.rs b/src/ui.rs index 8adc31ef3b..3d6b4f1ab9 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -94,7 +94,7 @@ pub(crate) use self::{ mobile_switcher_areas, mobile_switcher_max_scroll, mobile_switcher_target_at, mobile_switcher_workspace_doc_range, MobileSwitcherTarget, }, - panes::{apply_pane_chrome, pane_inner_rect, pane_is_scrolled_back}, + panes::{apply_pane_chrome, pane_copy_button_span, pane_inner_rect, pane_is_scrolled_back}, tab_surface::{tab_surface_cursor, tab_surface_hyperlinks, TabSurfaceView}, tabs::{compute_tab_bar_view, tab_bar_content_area}, widgets::{centered_popup_rect, modal_stack_areas}, @@ -219,6 +219,17 @@ fn compute_view_internal( resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, ) { + if app.popup_pane.is_some() + || app.mode != Mode::Terminal + || app.pane_hover.is_some_and(|(pane_id, _)| { + app.active + .and_then(|ws_idx| app.workspaces.get(ws_idx)) + .and_then(|workspace| workspace.active_tab()) + .is_none_or(|tab| !tab.layout.pane_ids().contains(&pane_id)) + }) + { + app.pane_hover = None; + } if is_mobile_width(area, app.mobile_width_threshold) { compute_mobile_view(app, terminal_runtimes, area, resize_panes, cell_size); return; @@ -959,6 +970,29 @@ mod tests { ); } + #[test] + fn workspace_switch_clears_stale_pane_hover_before_switching_back() { + let mut app = crate::app::state::AppState::test_new(); + app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; + app.active = Some(0); + app.selected = 0; + app.pane_borders = true; + app.pane_outer_borders = true; + app.mode = Mode::Terminal; + let area = Rect::new(0, 0, 120, 30); + compute_view(&mut app, area); + let hovered = app.view.pane_infos[0].id; + app.pane_hover = Some((hovered, true)); + + app.switch_workspace(1); + compute_view(&mut app, area); + assert_eq!(app.pane_hover, None); + + app.switch_workspace(0); + compute_view(&mut app, area); + assert_eq!(app.pane_hover, None); + } + #[test] fn product_announcement_renders_above_config_diagnostic() { let mut app = crate::app::state::AppState::test_new(); diff --git a/src/ui/panes.rs b/src/ui/panes.rs index f20b55b588..8eb7655da7 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -54,6 +54,18 @@ pub(crate) fn pane_inner_rect(area: Rect, borders: Borders) -> Rect { } } +pub(crate) fn pane_copy_button_span(info: &PaneInfo) -> Option<(u16, u16, u16)> { + if !info.borders.contains(Borders::TOP) || info.rect.width < 8 { + return None; + } + let x_end = info + .rect + .x + .saturating_add(info.rect.width) + .saturating_sub(1); + Some((x_end.saturating_sub(3), x_end, info.rect.y)) +} + fn ranges_overlap(a_start: u16, a_len: u16, b_start: u16, b_len: u16) -> bool { a_start < b_start.saturating_add(b_len) && b_start < a_start.saturating_add(a_len) } @@ -526,6 +538,44 @@ fn render_pane_borders( } render_pane_border_titles(app, ws, pane_infos, frame); + render_pane_copy_control(app, pane_infos, frame); +} + +fn render_pane_copy_control(app: &AppState, pane_infos: &[PaneInfo], frame: &mut Frame) { + if app.mode != Mode::Terminal || app.popup_pane.is_some() { + return; + } + let Some((pane_id, is_button_hovered)) = app.pane_hover else { + return; + }; + let Some((x_start, x_end, y)) = pane_infos + .iter() + .find(|info| info.id == pane_id) + .and_then(pane_copy_button_span) + else { + return; + }; + + let buf = frame.buffer_mut(); + let area = buf.area; + if y < area.y + || y >= area.y.saturating_add(area.height) + || x_start >= area.x.saturating_add(area.width) + { + return; + } + let start = x_start.max(area.x); + let width = x_end + .min(area.x.saturating_add(area.width)) + .saturating_sub(start) as usize; + let style = if is_button_hovered { + Style::default() + .fg(app.palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(app.palette.overlay0) + }; + buf.set_stringn(start, y, " ⧉ ", width, style); } fn add_split_border_cells( @@ -1019,6 +1069,27 @@ mod tests { ); } + #[test] + fn pane_copy_button_requires_a_wide_top_border() { + let mut info = PaneInfo { + id: PaneId::from_raw(1), + rect: Rect::new(10, 4, 20, 8), + inner_rect: Rect::default(), + scrollbar_rect: None, + borders: Borders::ALL, + is_focused: false, + }; + + assert_eq!(pane_copy_button_span(&info), Some((26, 29, 4))); + + info.borders.remove(Borders::TOP); + assert_eq!(pane_copy_button_span(&info), None); + + info.borders.insert(Borders::TOP); + info.rect.width = 7; + assert_eq!(pane_copy_button_span(&info), None); + } + #[test] fn pane_border_title_trims_and_truncates() { assert_eq!(