1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
//! Terminal mouse event handling.
//!
//! This module handles forwarding mouse events to the terminal PTY when the terminal
//! is in alternate screen mode (used by programs like vim, less, htop, etc.).
//!
//! When in alternate screen mode, mouse events that fall within the terminal's content
//! area are converted to terminal escape sequences and sent to the PTY, allowing
//! full-screen terminal programs to receive and handle mouse input.
use crate::app::window::Window;
use crate::input::handler::{TerminalMouseButton, TerminalMouseEventKind};
use crate::model::event::BufferId;
use anyhow::Result as AnyhowResult;
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
impl Window {
/// Check if mouse event should be forwarded to the terminal.
/// Returns true if the event was forwarded (and handled).
pub(crate) fn try_forward_mouse_to_terminal(
&mut self,
col: u16,
row: u16,
mouse_event: MouseEvent,
) -> Option<AnyhowResult<bool>> {
// Only forward if in terminal mode.
if !self.terminal_mode {
return None;
}
// Find terminal buffer at this position.
let (buffer_id, content_rect) = self.get_terminal_content_area_at_position(col, row)?;
// Only forward if terminal is in alternate screen mode.
if !self.is_terminal_in_alternate_screen(buffer_id) {
return None;
}
// Forward the event.
Some(self.forward_mouse_to_terminal(col, row, content_rect, mouse_event))
}
/// Detect a clickable file-path link in the live terminal grid at the given
/// screen position.
///
/// Returns the terminal buffer, the content-area-relative grid row, the
/// detected link (path + optional line/col + column span), and the
/// terminal's OSC 7 working directory (for resolving relative paths).
///
/// Only fires in live terminal mode and *not* in alternate-screen mode
/// (where mouse events are forwarded to the running full-screen program).
/// The returned link is textual only — the caller resolves and checks it.
pub(crate) fn detect_terminal_link_at(
&self,
col: u16,
row: u16,
) -> Option<(
BufferId,
u16,
crate::services::terminal::path_link::DetectedLink,
Option<std::path::PathBuf>,
)> {
if !self.terminal_mode {
return None;
}
let (buffer_id, content_rect) = self.get_terminal_content_area_at_position(col, row)?;
// Alternate-screen programs own the mouse; don't shadow their clicks.
if self.is_terminal_in_alternate_screen(buffer_id) {
return None;
}
let term_col = col.saturating_sub(content_rect.x) as usize;
let term_row = row.saturating_sub(content_rect.y);
let &terminal_id = self.terminal_buffers.get(&buffer_id)?;
let handle = self.terminal_manager.get(terminal_id)?;
let (line, cwd) = {
let state = handle.state.lock().ok()?;
let line: String = state.get_line(term_row).iter().map(|c| c.c).collect();
let cwd = state.cwd().map(|p| p.to_path_buf());
(line, cwd)
};
let link = crate::services::terminal::path_link::detect_link_at(&line, term_col)?;
Some((buffer_id, term_row, link, cwd))
}
/// Detect a clickable file-path link in the terminal *scrollback* view at
/// the given screen position.
///
/// The scrollback view is a normal read-only buffer (the synced terminal
/// history) shown only for the active terminal buffer when not in live
/// terminal mode. Clicks map through the standard screen→buffer-position
/// machinery; we then read the buffer line under the cursor and detect a
/// path link in it.
///
/// Returns the terminal buffer, the detected link, and the terminal's
/// OSC 7 working directory (for resolving relative paths).
pub(crate) fn detect_terminal_scrollback_link_at(
&self,
col: u16,
row: u16,
) -> Option<(
BufferId,
crate::services::terminal::path_link::DetectedLink,
Option<std::path::PathBuf>,
)> {
// Scrollback is rendered only for the active terminal buffer while not
// in live terminal mode (every other terminal view shows the grid).
if self.terminal_mode {
return None;
}
let active = self.active_buffer();
if !self.is_terminal_buffer(active) {
return None;
}
let (split_id, content_rect) =
self.layout_cache
.split_areas
.iter()
.find_map(|(sid, bid, rect, _, _, _)| {
(*bid == active
&& col >= rect.x
&& col < rect.x + rect.width
&& row >= rect.y
&& row < rect.y + rect.height)
.then_some((*sid, *rect))
})?;
let state = self.buffers.get(&active)?;
let gutter_width = state.margins.left_total_width() as u16;
let cached_mappings = self.layout_cache.view_line_mappings.get(&split_id).cloned();
let (fallback, compose_width) = self
.buffers
.splits()
.and_then(|(_, vs)| vs.get(&split_id))
.map(|vs| (vs.viewport.top_byte, vs.compose_width))
.unwrap_or((0, None));
// `allow_gutter_click = false`: a click in the gutter isn't on a path.
let byte_pos = crate::app::click_geometry::screen_to_buffer_position(
col,
row,
content_rect,
gutter_width,
&cached_mappings,
fallback,
false,
compose_width,
)?;
let pos = crate::model::buffer_position::byte_to_2d(&state.buffer, byte_pos);
let line_bytes = state.buffer.get_line(pos.line)?;
let line = String::from_utf8_lossy(&line_bytes);
let line = line.strip_suffix('\n').unwrap_or(&line);
// `pos.column` is a byte offset within the line; convert to a char
// column for the (char-indexed) detector.
let char_col = line
.char_indices()
.take_while(|(b, _)| *b < pos.column)
.count();
let link = crate::services::terminal::path_link::detect_link_at(line, char_col)?;
let cwd = self
.terminal_buffers
.get(&active)
.and_then(|tid| self.terminal_manager.get(*tid))
.and_then(|h| {
h.state
.lock()
.ok()
.and_then(|s| s.cwd().map(|p| p.to_path_buf()))
});
Some((active, link, cwd))
}
/// Get the terminal buffer and its content area if the mouse position is over a terminal buffer.
/// Returns the buffer ID and content rect if found.
fn get_terminal_content_area_at_position(
&self,
col: u16,
row: u16,
) -> Option<(BufferId, Rect)> {
for (_, buffer_id, content_rect, _, _, _) in &self.layout_cache.split_areas {
// Check if position is within content area.
if col >= content_rect.x
&& col < content_rect.x + content_rect.width
&& row >= content_rect.y
&& row < content_rect.y + content_rect.height
&& self.is_terminal_buffer(*buffer_id)
{
return Some((*buffer_id, *content_rect));
}
}
None
}
/// Forward a mouse event to the terminal PTY.
/// Converts screen coordinates to terminal-relative coordinates and sends the event.
fn forward_mouse_to_terminal(
&mut self,
col: u16,
row: u16,
content_rect: Rect,
mouse_event: MouseEvent,
) -> AnyhowResult<bool> {
// Convert to terminal-relative coordinates (0-based from content area).
let term_col = col.saturating_sub(content_rect.x);
let term_row = row.saturating_sub(content_rect.y);
// Convert crossterm MouseEventKind to our TerminalMouseEventKind.
let kind = match mouse_event.kind {
MouseEventKind::Down(btn) => TerminalMouseEventKind::Down(convert_button(btn)),
MouseEventKind::Up(btn) => TerminalMouseEventKind::Up(convert_button(btn)),
MouseEventKind::Drag(btn) => TerminalMouseEventKind::Drag(convert_button(btn)),
MouseEventKind::Moved => TerminalMouseEventKind::Moved,
MouseEventKind::ScrollUp => TerminalMouseEventKind::ScrollUp,
MouseEventKind::ScrollDown => TerminalMouseEventKind::ScrollDown,
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => {
// Horizontal scroll not typically supported in terminal mouse protocols.
return Ok(false);
}
};
// Send to terminal.
self.send_terminal_mouse(term_col, term_row, kind, mouse_event.modifiers);
// Terminal renders itself, so we need to trigger a render.
Ok(true)
}
}
/// Convert crossterm MouseButton to our TerminalMouseButton.
fn convert_button(btn: MouseButton) -> TerminalMouseButton {
match btn {
MouseButton::Left => TerminalMouseButton::Left,
MouseButton::Right => TerminalMouseButton::Right,
MouseButton::Middle => TerminalMouseButton::Middle,
}
}