mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-09-22 00:00:59 +00:00
feat: support terminal URL highlighting on hover, click-to-open and multi-line wrapping
This commit is contained in:
@@ -318,9 +318,18 @@ pub(crate) struct Ashell {
|
||||
pub(crate) events_tx: mpsc::Sender<BackendEvent>,
|
||||
pub(crate) last_window_size: Option<gpui::Size<Pixels>>,
|
||||
pub(crate) last_sidebar_width: Option<Pixels>,
|
||||
pub(crate) hovered_url: Option<HoveredUrl>,
|
||||
pub(crate) cmd_ctrl_pressed: bool,
|
||||
pub(crate) _subscriptions: Vec<gpui::Subscription>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+72
-3
@@ -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<Pixels>, 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<BatchedTextRun>,
|
||||
custom_blocks: Vec<LayoutCustomBlock>,
|
||||
cursor: Option<CursorLayout>,
|
||||
underlines: Vec<LayoutUnderline>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -354,10 +375,14 @@ impl TerminalElement {
|
||||
fn layout_grid(
|
||||
&self,
|
||||
cx: &App,
|
||||
) -> (Vec<LayoutRect>, Vec<BatchedTextRun>, Vec<LayoutCustomBlock>) {
|
||||
) -> (Vec<LayoutRect>, Vec<BatchedTextRun>, Vec<LayoutCustomBlock>, Vec<LayoutUnderline>) {
|
||||
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<BatchedTextRun> = 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<CursorLayout> {
|
||||
@@ -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<LayoutRect>) -> Vec<LayoutRect> {
|
||||
merged
|
||||
}
|
||||
|
||||
fn merge_underlines(mut underlines: Vec<LayoutUnderline>) -> Vec<LayoutUnderline> {
|
||||
underlines.sort_by_key(|u| (u.row, u.col));
|
||||
let mut merged: Vec<LayoutUnderline> = 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;
|
||||
|
||||
+118
-9
@@ -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<LogicalLine<'a>> {
|
||||
let mut row_chars: Vec<Vec<&RenderCell>> = 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<LogicalLine> = 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
|
||||
}
|
||||
|
||||
|
||||
+26
-1
@@ -23,6 +23,7 @@ impl Ashell {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Pixels>,
|
||||
) -> Option<(usize, usize, Side)> {
|
||||
|
||||
Reference in New Issue
Block a user