diff --git a/src/app/mod.rs b/src/app/mod.rs index bbb71d1..05949a3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -318,9 +318,18 @@ pub(crate) struct Ashell { pub(crate) events_tx: mpsc::Sender, pub(crate) last_window_size: Option>, pub(crate) last_sidebar_width: Option, + pub(crate) hovered_url: Option, + pub(crate) cmd_ctrl_pressed: bool, pub(crate) _subscriptions: Vec, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct HoveredUrl { + pub(crate) url: String, + pub(crate) tab_id: String, + pub(crate) cells: Vec<(usize, usize)>, +} + #[derive(Clone)] pub(crate) enum SelectorEntry { Local, @@ -682,6 +691,8 @@ impl Ashell { events_tx, last_window_size: None, last_sidebar_width, + hovered_url: None, + cmd_ctrl_pressed: false, _subscriptions, }; diff --git a/src/app/ui.rs b/src/app/ui.rs index 87bad41..d64354a 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -2334,9 +2334,11 @@ impl Ashell { let font_size = px(this.terminal_font_size); let line_height = px(this.terminal_line_height()); let cell_width = px(this.terminal_cell_width()); + let is_url_hovered = this.hovered_url.as_ref().map_or(false, |hu| hu.tab_id == *tab_id); let mut el = div() .size_full() .overflow_hidden() + .when(is_url_hovered, |d| d.cursor_pointer()) .on_mouse_down( MouseButton::Left, cx.listener(move |this, _, _, cx| { diff --git a/src/session/mod.rs b/src/session/mod.rs index 404cba4..2189f40 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -898,6 +898,16 @@ impl Ashell { } } if event.button == MouseButton::Left { + if event.modifiers.platform { + if let Some((row, col, _side)) = self.terminal_grid_point_and_side(event.position) { + if let Some(snapshot) = self.active_snapshot() { + if let Some((url, _)) = crate::terminal::highlight::find_url_at_cell(&snapshot.cells, snapshot.rows, row, col) { + let _ = open::that(&url); + return; + } + } + } + } if self.config.right_click_copy_paste() { if let Some(text) = self.active_terminal_selection_text() { if !text.is_empty() { diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 28fdc65..09c6a39 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -39,6 +39,26 @@ impl LayoutRect { } } +#[derive(Clone)] +struct LayoutUnderline { + row: i32, + col: i32, + cells: usize, + color: Hsla, +} + +impl LayoutUnderline { + fn paint(&self, origin: Point, metrics: TerminalMetrics, window: &mut Window) { + let thickness = px(1.0); + let position = point( + origin.x + metrics.cell_width * self.col as f32, + origin.y + metrics.line_height * (self.row as f32 + 1.0) - thickness, + ); + let size = gpui::size(metrics.cell_width * self.cells as f32, thickness); + window.paint_quad(fill(Bounds::new(position, size), self.color)); + } +} + #[derive(Clone)] struct BatchedTextRun { row: i32, @@ -143,6 +163,7 @@ pub struct PrepaintState { runs: Vec, custom_blocks: Vec, cursor: Option, + underlines: Vec, } #[derive(Clone)] @@ -354,10 +375,14 @@ impl TerminalElement { fn layout_grid( &self, cx: &App, - ) -> (Vec, Vec, Vec) { + ) -> (Vec, Vec, Vec, Vec) { + let view_read = self.view.read(cx); + let hovered_url = view_read.hovered_url.clone(); + let mut rects = Vec::new(); let mut runs = Vec::new(); let mut custom_blocks = Vec::new(); + let mut underlines = Vec::new(); let mut current_run: Option = None; // Retrieve cached keyword highlights and merge with search highlights @@ -411,6 +436,20 @@ impl TerminalElement { style.color = hl_color; } + // Apply hover underline if mouse is hovering over this URL + if let Some(hu) = &hovered_url { + if hu.tab_id == self.tab_id + && hu.cells.contains(&(render_cell.row as usize, render_cell.col as usize)) + { + underlines.push(LayoutUnderline { + row: render_cell.row, + col: render_cell.col, + cells: if cell.flags.contains(Flags::WIDE_CHAR) { 2 } else { 1 }, + color: style.color, + }); + } + } + // Box Drawing & Block Elements interception let is_custom_block = is_custom_block_supported(cell.c); @@ -463,7 +502,12 @@ impl TerminalElement { runs.push(run); } - (merge_rects(rects), runs, custom_blocks) + ( + merge_rects(rects), + runs, + custom_blocks, + merge_underlines(underlines), + ) } fn cursor_layout(&self, cx: &App) -> Option { @@ -543,7 +587,7 @@ impl Element for TerminalElement { cx: &mut App, ) -> Self::PrepaintState { let _ = self.base_text_style(cx); - let (rects, runs, custom_blocks) = self.layout_grid(cx); + let (rects, runs, custom_blocks, underlines) = self.layout_grid(cx); // Save the precise GPUI-rendered bounds of this terminal element. // This is 100% accurate because it is recorded during layout prepaint. @@ -580,6 +624,7 @@ impl Element for TerminalElement { runs, custom_blocks, cursor: self.cursor_layout(cx), + underlines, } } @@ -607,6 +652,10 @@ impl Element for TerminalElement { run.paint(draw_origin, prepaint.metrics, window, cx); } + for u in &prepaint.underlines { + u.paint(draw_origin, prepaint.metrics, window); + } + for block in &prepaint.custom_blocks { let x = draw_origin.x.as_f32() + block.col as f32 * prepaint.metrics.cell_width.as_f32(); @@ -746,6 +795,26 @@ fn merge_rects(mut rects: Vec) -> Vec { merged } +fn merge_underlines(mut underlines: Vec) -> Vec { + underlines.sort_by_key(|u| (u.row, u.col)); + let mut merged: Vec = Vec::with_capacity(underlines.len()); + + for u in underlines { + if let Some(last) = merged.last_mut() { + if last.row == u.row + && last.color == u.color + && last.col + last.cells as i32 == u.col + { + last.cells += u.cells; + continue; + } + } + merged.push(u); + } + + merged +} + fn selection_contains(selection: ViewportSelection, row: i32, col: i32) -> bool { let row = row.max(0) as usize; let col = col.max(0) as usize; diff --git a/src/terminal/highlight.rs b/src/terminal/highlight.rs index 8588773..cd71f9c 100644 --- a/src/terminal/highlight.rs +++ b/src/terminal/highlight.rs @@ -477,15 +477,7 @@ pub fn highlight_cells( } } - // ── 32. URLs ─────────────────────────────────────────── - for m in find_urls(text) { - let url_len = find_url_len(&text[m..]); - let start_col = byte_to_col[m]; - let end_col = byte_to_col[(m + url_len - 1).min(byte_to_col.len() - 1)]; - for c in start_col..=end_col { - map.entry((row_i32, c)).or_insert(colors.url); - } - } + // ── 33. Port numbers ─────────────────────────────────── for m in find_ports(text) { @@ -497,6 +489,21 @@ pub fn highlight_cells( } } } + // ── 32. URLs (Logical lines for wrapping support) ─────────── + let logical_lines = build_logical_lines(cells, rows); + for line in &logical_lines { + let text = line.text.as_str(); + for m in find_urls(text) { + let url_len = find_url_len(&text[m..]); + for i in 0..url_len { + let idx = m + i; + if idx < line.byte_to_cell.len() { + let (r, c) = line.byte_to_cell[idx]; + map.entry((r as i32, c as i32)).or_insert(colors.url); + } + } + } + } map } @@ -630,3 +637,105 @@ fn find_port_len(text: &str) -> usize { } len } + +#[derive(Clone)] +pub struct LogicalLine<'a> { + pub text: String, + pub byte_to_cell: Vec<(usize, usize)>, + pub row_cells: Vec<&'a RenderCell>, +} + +pub fn build_logical_lines<'a>(cells: &'a [RenderCell], rows: usize) -> Vec> { + let mut row_chars: Vec> = vec![Vec::with_capacity(128); rows]; + for rc in cells { + if rc.row < 0 || (rc.row as usize) >= rows { + continue; + } + row_chars[rc.row as usize].push(rc); + } + for row in row_chars.iter_mut() { + row.sort_by_key(|rc| rc.col); + } + + let mut logical_lines = Vec::new(); + let mut current_line: Option = None; + + for (row_idx, row_cells) in row_chars.into_iter().enumerate() { + if row_cells.is_empty() { + if let Some(line) = current_line.take() { + logical_lines.push(line); + } + continue; + } + + let wraps_from_prev = row_idx > 0 && { + current_line.as_ref().map_or(false, |line| { + line.row_cells.last().map_or(false, |rc| { + rc.cell.flags.contains(alacritty_terminal::term::cell::Flags::WRAPLINE) + }) + }) + }; + + if !wraps_from_prev { + if let Some(line) = current_line.take() { + logical_lines.push(line); + } + } + + let mut line = current_line.take().unwrap_or_else(|| LogicalLine { + text: String::with_capacity(128), + byte_to_cell: Vec::with_capacity(128), + row_cells: Vec::new(), + }); + + for rc in row_cells { + line.text.push(rc.cell.c); + let end_len = line.text.len(); + while line.byte_to_cell.len() < end_len { + line.byte_to_cell.push((rc.row as usize, rc.col as usize)); + } + line.row_cells.push(rc); + } + + current_line = Some(line); + } + + if let Some(line) = current_line.take() { + logical_lines.push(line); + } + + logical_lines +} + +pub fn find_url_at_cell( + cells: &[RenderCell], + rows: usize, + row: usize, + col: usize, +) -> Option<(String, Vec<(usize, usize)>)> { + let logical_lines = build_logical_lines(cells, rows); + for line in logical_lines { + let text = line.text.as_str(); + for m in find_urls(text) { + let url_len = find_url_len(&text[m..]); + let mut url_cells = Vec::with_capacity(url_len); + let mut matched = false; + for i in 0..url_len { + let idx = m + i; + if idx < line.byte_to_cell.len() { + let (r, c) = line.byte_to_cell[idx]; + if r == row && c == col { + matched = true; + } + url_cells.push((r, c)); + } + } + if matched { + let url_str = text[m..m + url_len].to_string(); + return Some((url_str, url_cells)); + } + } + } + None +} + diff --git a/src/terminal/input.rs b/src/terminal/input.rs index 4f335e3..1ae2e78 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -23,6 +23,7 @@ impl Ashell { window: &mut Window, cx: &mut Context, ) { + self.cmd_ctrl_pressed = event.keystroke.modifiers.platform; // If the search input is focused, skip terminal key processing // so the input can handle text entry, paste, etc. normally. if self @@ -379,6 +380,30 @@ impl Ashell { } return; } + + // Track URL hover + let mut hovered_url = None; + let cmd_ctrl_pressed = event.modifiers.platform; + if let Some((row, col, _side)) = self.terminal_grid_point_and_side(event.position) { + if let Some(snapshot) = self.active_snapshot() { + if let Some(active_id) = &self.active_tab { + if let Some((url, url_cells)) = crate::terminal::highlight::find_url_at_cell(&snapshot.cells, snapshot.rows, row, col) { + hovered_url = Some(crate::app::HoveredUrl { + url, + tab_id: active_id.clone(), + cells: url_cells, + }); + } + } + } + } + + if self.hovered_url != hovered_url || self.cmd_ctrl_pressed != cmd_ctrl_pressed { + self.hovered_url = hovered_url; + self.cmd_ctrl_pressed = cmd_ctrl_pressed; + cx.notify(); + } + if !self.terminal_selecting || event.pressed_button != Some(MouseButton::Left) { return; } @@ -454,7 +479,7 @@ impl Ashell { cx.notify(); } - fn terminal_grid_point_and_side( + pub(crate) fn terminal_grid_point_and_side( &self, position: Point, ) -> Option<(usize, usize, Side)> {