aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-26 18:26:12 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-26 18:26:12 +0300
commitaf399094f5d24f157e35aaa657ed5ad5c44e5325 (patch)
tree2f44cc902312fbe8c405a972b9a438ca6bead27c /src/app/input
parent3750e0bae21c54da3947d337584f180a21d0bc67 (diff)
feat: open pane links with ctrl-click
refs #307
Diffstat (limited to 'src/app/input')
-rw-r--r--src/app/input/mod.rs38
-rw-r--r--src/app/input/terminal.rs72
2 files changed, 109 insertions, 1 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index e145e628..8ba8e4bd 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -1,6 +1,6 @@
//! Input handling — translates crossterm key/mouse events into state mutations.
-use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use crate::app::PaneClickState;
use crate::input::TerminalKey;
@@ -195,6 +195,10 @@ impl App {
}
}
+ if self.handle_modified_url_click(mouse) {
+ return;
+ }
+
let handled_pane_double_click = self.handle_pane_double_click(mouse);
let previous_agent_panel_scope = self.state.agent_panel_scope;
@@ -248,6 +252,33 @@ impl App {
}
}
+ fn handle_modified_url_click(&mut self, mouse: MouseEvent) -> bool {
+ if self.state.mode != Mode::Terminal
+ || !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
+ || !mouse.modifiers.contains(KeyModifiers::CONTROL)
+ {
+ return false;
+ }
+
+ let Some(info) = self.state.pane_at(mouse.column, mouse.row).cloned() else {
+ return false;
+ };
+ let viewport_row = mouse.row.saturating_sub(info.inner_rect.y);
+ let col = mouse.column.saturating_sub(info.inner_rect.x);
+ let Some(url) =
+ self.state
+ .url_at_pane_cell(&self.terminal_runtimes, info.id, viewport_row, col)
+ else {
+ return false;
+ };
+
+ self.last_pane_click = None;
+ if let Err(err) = crate::platform::open_url(&url) {
+ tracing::warn!(err = %err, url = %url, "failed to open pane URL");
+ }
+ true
+ }
+
fn handle_pane_double_click(&mut self, mouse: MouseEvent) -> bool {
// A pane press stops being a double-click candidate once it becomes
// a drag or completes as a real text selection.
@@ -291,6 +322,11 @@ impl App {
return None;
}
+ if !mouse.modifiers.is_empty() {
+ self.last_pane_click = None;
+ return None;
+ }
+
if self.state.mode != Mode::Terminal {
self.last_pane_click = None;
return None;
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index 71728be4..0a35c3be 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -232,6 +232,20 @@ mod tests {
app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row));
}
+ fn modified_mouse(
+ kind: MouseEventKind,
+ col: u16,
+ row: u16,
+ modifiers: KeyModifiers,
+ ) -> crossterm::event::MouseEvent {
+ crossterm::event::MouseEvent {
+ kind,
+ column: col,
+ row,
+ modifiers,
+ }
+ }
+
fn clipboard_write_content(app: &mut App) -> Vec<u8> {
match app.event_rx.try_recv().expect("clipboard write event") {
AppEvent::ClipboardWrite { content } => content,
@@ -433,6 +447,64 @@ mod tests {
}
#[tokio::test]
+ async fn modified_pane_click_does_not_seed_double_click_copy() {
+ let (mut app, info) = app_with_screen_bytes(b"alpha beta");
+ let col = info.inner_rect.x + 7;
+ let row = info.inner_rect.y;
+
+ app.handle_mouse(modified_mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ col,
+ row,
+ KeyModifiers::CONTROL,
+ ));
+ app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row));
+ app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row));
+
+ assert!(app.event_rx.try_recv().is_err());
+ assert!(app.selection_highlight_clear_deadline.is_none());
+ }
+
+ #[tokio::test]
+ async fn pane_cell_url_resolver_finds_visible_url() {
+ let line = "see https://example.com/pr/307.";
+ let (app, info) = app_with_screen_bytes(line.as_bytes());
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let col = line.find("example").expect("url host") as u16;
+
+ assert_eq!(
+ app.state
+ .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, col)
+ .as_deref(),
+ Some("https://example.com/pr/307")
+ );
+ assert_eq!(
+ app.state.url_at_pane_cell(
+ &app.terminal_runtimes,
+ pane_id,
+ 0,
+ info.inner_rect.width - 1
+ ),
+ None
+ );
+ }
+
+ #[tokio::test]
+ async fn pane_cell_url_resolver_prefers_osc8_hyperlink() {
+ let (app, _info) = app_with_screen_bytes(
+ b"\x1b]8;;https://example.com/hidden-target\x1b\\label\x1b]8;;\x1b\\",
+ );
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+
+ assert_eq!(
+ app.state
+ .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, 1)
+ .as_deref(),
+ Some("https://example.com/hidden-target")
+ );
+ }
+
+ #[tokio::test]
async fn double_click_highlight_clears_after_short_delay() {
let (mut app, info) = app_with_screen_bytes(b"copied");
let col = info.inner_rect.x + 2;