aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-24 02:09:22 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-24 02:10:47 +0300
commit58e812b238844fa05233eadc537081a1f0d06f36 (patch)
tree61d071cf4a6c33b58cf8a35f56aa8174fe084252 /src
parent36de78dd4fa4e2c203ee955f18caf82a6bca7e80 (diff)
fix: consume handled url click gestures
refs #1761
Diffstat (limited to 'src')
-rw-r--r--src/app/input/mod.rs34
-rw-r--r--src/app/input/terminal.rs50
-rw-r--r--src/app/mod.rs12
3 files changed, 91 insertions, 5 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index 738b7963..16713222 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -256,6 +256,31 @@ impl App {
}
pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) {
+ self.handle_mouse_from_input_source(super::LOCAL_INPUT_SOURCE, mouse);
+ }
+
+ pub(super) fn handle_mouse_from_input_source(
+ &mut self,
+ source_id: super::InputSourceId,
+ mouse: MouseEvent,
+ ) {
+ match mouse.kind {
+ MouseEventKind::Down(MouseButton::Left) => {
+ self.pending_url_click_sources.remove(&source_id);
+ }
+ MouseEventKind::Drag(MouseButton::Left)
+ if self.pending_url_click_sources.contains(&source_id) =>
+ {
+ return;
+ }
+ MouseEventKind::Up(MouseButton::Left)
+ if self.pending_url_click_sources.remove(&source_id) =>
+ {
+ return;
+ }
+ _ => {}
+ }
+
if self.state.popup_pane.is_some() {
self.handle_popup_mouse(mouse);
return;
@@ -284,7 +309,7 @@ impl App {
}
}
- if self.handle_modified_url_click(mouse) {
+ if self.handle_modified_url_click(source_id, mouse) {
return;
}
@@ -469,7 +494,11 @@ impl App {
self.focus_pane_internal_via_api(ws_idx, pane_id);
}
- fn handle_modified_url_click(&mut self, mouse: MouseEvent) -> bool {
+ fn handle_modified_url_click(
+ &mut self,
+ source_id: super::InputSourceId,
+ mouse: MouseEvent,
+ ) -> bool {
if self.state.mode != Mode::Terminal
|| !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
|| !mouse.modifiers.contains(modified_url_click_modifier())
@@ -490,6 +519,7 @@ impl App {
};
self.last_pane_click = None;
+ self.pending_url_click_sources.insert(source_id);
match self.invoke_plugin_link_handler_for_url(&url, info.id) {
Ok(true) => return true,
Ok(false) => {}
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index 61c6bbb3..371803da 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -328,6 +328,7 @@ impl App {
}
pub(crate) fn release_input_source_headless(&mut self, source_id: crate::app::InputSourceId) {
+ self.pending_url_click_sources.remove(&source_id);
for pressed in self.take_pressed_keys_for_source(source_id) {
let release = pressed
.key
@@ -337,6 +338,7 @@ impl App {
}
pub(crate) async fn release_input_source(&mut self, source_id: crate::app::InputSourceId) {
+ self.pending_url_click_sources.remove(&source_id);
for pressed in self.take_pressed_keys_for_source(source_id) {
let release = pressed
.key
@@ -846,6 +848,54 @@ mod tests {
#[cfg(unix)]
#[tokio::test]
+ async fn ctrl_click_url_does_not_forward_release_to_mouse_reporting_pane() {
+ let line = "see https://github.com/ogulcancelik/herdr/issues/1761";
+ let col = line.find("github").expect("url host") as u16;
+ let (mut app, info) = app_with_screen_bytes(b"");
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let screen = format!("\x1b[?1049h\x1b[?1000h\x1b[?1006h{line}");
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ info.inner_rect.width,
+ info.inner_rect.height,
+ 0,
+ screen.as_bytes(),
+ 4,
+ );
+ app.state.insert_test_runtime(pane_id, runtime);
+ install_test_link_handler(&mut app);
+ let mut send_mouse = |source_id, kind, column, modifiers| {
+ app.handle_mouse_from_input_source(
+ source_id,
+ modified_mouse(kind, column, info.inner_rect.y, modifiers),
+ );
+ };
+ let down = MouseEventKind::Down(MouseButton::Left);
+ let up = MouseEventKind::Up(MouseButton::Left);
+ let url_x = info.inner_rect.x + col;
+
+ send_mouse(41, down, url_x, KeyModifiers::CONTROL);
+ send_mouse(42, down, info.inner_rect.x, KeyModifiers::empty());
+ send_mouse(42, up, info.inner_rect.x, KeyModifiers::empty());
+ send_mouse(41, up, url_x, KeyModifiers::empty());
+
+ assert_eq!(app.state.plugin_command_logs.len(), 1);
+ assert_eq!(
+ input_rx.try_recv().expect("other source mouse down"),
+ Bytes::from_static(b"\x1b[<0;1;1M")
+ );
+ assert_eq!(
+ input_rx.try_recv().expect("other source mouse up"),
+ Bytes::from_static(b"\x1b[<0;1;1m")
+ );
+ assert!(
+ input_rx.try_recv().is_err(),
+ "handled URL click must not leave an unmatched release for the pane"
+ );
+ }
+
+ #[cfg(unix)]
+ #[tokio::test]
async fn ctrl_click_url_invokes_plugin_link_handler_but_super_click_does_not() {
let line = "see https://github.com/ogulcancelik/herdr/issues/398";
let col = line.find("github").expect("url host") as u16;
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 0c0a2688..30e1c949 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -119,6 +119,7 @@ pub struct App {
pub(crate) next_api_worktree_operation_id: u64,
pub(crate) last_sidebar_divider_click: Option<Instant>,
pub(crate) last_pane_click: Option<PaneClickState>,
+ pub(crate) pending_url_click_sources: HashSet<InputSourceId>,
pub(crate) next_resize_poll: Instant,
pub(crate) next_animation_tick: Option<Instant>,
pub(crate) next_auto_update_check: Option<Instant>,
@@ -744,6 +745,7 @@ impl App {
next_api_worktree_operation_id: 1,
last_sidebar_divider_click: None,
last_pane_click: None,
+ pending_url_click_sources: HashSet::new(),
next_resize_poll: Instant::now() + RESIZE_POLL_INTERVAL,
next_animation_tick: None,
next_auto_update_check: version_check_enabled
@@ -1664,7 +1666,7 @@ impl App {
}
crate::raw_input::RawInputEvent::Mouse(mouse) => {
if self.state.popup_pane.is_some() || self.state.mouse_capture {
- self.handle_mouse_event_headless(mouse);
+ self.handle_mouse_event_headless(source_id, mouse);
} else {
self.state
.handle_pane_mouse_only(&self.terminal_runtimes, mouse);
@@ -1800,8 +1802,12 @@ impl App {
/// Delegates to the same mouse handling logic used in the monolithic
/// mode (hit-testing against the rendered UI), which works because
/// the server's AppState maintains view geometry from virtual rendering.
- fn handle_mouse_event_headless(&mut self, mouse: crossterm::event::MouseEvent) {
- self.handle_mouse(mouse);
+ fn handle_mouse_event_headless(
+ &mut self,
+ source_id: InputSourceId,
+ mouse: crossterm::event::MouseEvent,
+ ) {
+ self.handle_mouse_from_input_source(source_id, mouse);
}
}