mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 00:15:06 +00:00
feat(terminal): follow links a table wrapped inside its cell
Markdown tables (Claude Code's among them) wrap a long cell by hand: each row is a hard newline framed by box-drawing borders, with the next cell's text in between. Hovering or clicking a URL wrapped that way only ever got the fragment on one row, so the link opened truncated. Link lookup now reads a table cell the way it already reads a wrapped row, just inside the cell's own columns: a row carries on into the next when its text reaches the right border and the next row's starts at the left one, with link characters on both sides and the borders lined up. Only `│┃║` count as borders; a bare `|` is too often a pipe. Cells that fit on one row and double-click selection are unchanged. The hover underline is now a run per row instead of one start..end span, so a stitched link underlines just its cell rather than the borders and neighbouring cells between its rows.
This commit is contained in:
+10
-24
@@ -1996,31 +1996,17 @@ impl TerminalElement {
|
||||
let Some(link) = self.view.read(cx).hovered_link.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let (start, end) = (link.start, link.end);
|
||||
let mut line = start.line.0;
|
||||
while line <= end.line.0 {
|
||||
let grid_row = line + display_offset;
|
||||
if grid_row >= 0 && (grid_row as usize) < rows {
|
||||
let grid_row = grid_row as usize;
|
||||
let col_start = if line == start.line.0 {
|
||||
start.column.0
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let col_end = if line == end.line.0 {
|
||||
end.column.0
|
||||
} else {
|
||||
cols.saturating_sub(1)
|
||||
};
|
||||
let mut col = col_start;
|
||||
while col <= col_end && col < cols {
|
||||
let cell = &mut buf[grid_row * cols + col];
|
||||
cell.link_hover = true;
|
||||
cell.underline_color = link_underline_color(cell, link.armed);
|
||||
col += 1;
|
||||
}
|
||||
for &(start, end) in &link.runs {
|
||||
let grid_row = start.line.0 + display_offset;
|
||||
if grid_row < 0 || grid_row as usize >= rows {
|
||||
continue;
|
||||
}
|
||||
let row = grid_row as usize * cols;
|
||||
for col in start.column.0..=end.column.0.min(cols.saturating_sub(1)) {
|
||||
let cell = &mut buf[row + col];
|
||||
cell.link_hover = true;
|
||||
cell.underline_color = link_underline_color(cell, link.armed);
|
||||
}
|
||||
line += 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -284,6 +284,11 @@ pub(super) fn logical_line_at<T: EventListener>(
|
||||
if click.column.0 >= cols {
|
||||
return None;
|
||||
}
|
||||
// Only a link is worth reading across a hard break, and a renderer that
|
||||
// wrapped text inside a table cell has left nothing but hard breaks.
|
||||
if bridge_hard_wrap && let Some(cell) = table_cell_at(term, click) {
|
||||
return Some(cell);
|
||||
}
|
||||
let grid = term.grid();
|
||||
let last_col = Column(cols - 1);
|
||||
let top = term.topmost_line();
|
||||
@@ -298,25 +303,128 @@ pub(super) fn logical_line_at<T: EventListener>(
|
||||
};
|
||||
let continues = |line: Line| wraps(line) || hard(line);
|
||||
|
||||
let mut start_line = click.line;
|
||||
let (start_line, end_line) = run_of_rows(click.line, top, bottom, continues);
|
||||
let rows = (start_line.0..=end_line.0).map(|line| (Line(line), Column(0), last_col));
|
||||
read_rows(term, click, rows)
|
||||
}
|
||||
|
||||
/// What a table's renderer draws between cells. Only the box-drawing ones: a
|
||||
/// bare `|` is a shell pipe or a markdown source line far more often than it
|
||||
/// is a border.
|
||||
const CELL_BORDERS: [char; 3] = ['│', '┃', '║'];
|
||||
|
||||
/// The text of the table cell under `click`, gathered from every row the
|
||||
/// table's renderer wrapped it onto.
|
||||
///
|
||||
/// It is the same stitching [`logical_line_at`] does for a whole row, only
|
||||
/// inside the cell's own columns: a row carries on into the next when its
|
||||
/// text runs up to the cell's right border and the next row's picks up at
|
||||
/// the left one, link characters on both sides of the break. The border
|
||||
/// columns have to line up too, which is what stops the walk at a `├─┼`
|
||||
/// rule or the table's edge.
|
||||
///
|
||||
/// A cell that fits on one row is left to the ordinary reading, so a table
|
||||
/// changes nothing until it actually wraps something.
|
||||
fn table_cell_at<T: EventListener>(
|
||||
term: &Term<T>,
|
||||
click: Point,
|
||||
) -> Option<(String, Vec<Point>, usize)> {
|
||||
let grid = term.grid();
|
||||
let cols = term.columns();
|
||||
let is_border = |line: Line, col: usize| CELL_BORDERS.contains(&grid[line][Column(col)].c);
|
||||
if is_border(click.line, click.column.0) {
|
||||
return None;
|
||||
}
|
||||
let left = (0..click.column.0)
|
||||
.rev()
|
||||
.find(|&col| is_border(click.line, col))?;
|
||||
let right = (click.column.0 + 1..cols).find(|&col| is_border(click.line, col))?;
|
||||
|
||||
// The written part of one row of the cell, padding trimmed off, or
|
||||
// nothing when that row is not part of this cell.
|
||||
let content = |line: Line| -> Option<(usize, usize)> {
|
||||
if !(is_border(line, left) && is_border(line, right)) {
|
||||
return None;
|
||||
}
|
||||
let written = |col: usize| {
|
||||
let cell = &grid[line][Column(col)];
|
||||
cell.c != ' ' || cell.flags.contains(Flags::WIDE_CHAR_SPACER)
|
||||
};
|
||||
let first = (left + 1..right).find(|&col| written(col))?;
|
||||
let last = (left + 1..right).rev().find(|&col| written(col))?;
|
||||
Some((first, last))
|
||||
};
|
||||
let top = term.topmost_line();
|
||||
let bottom = term.bottommost_line();
|
||||
let is_link_char = |c: char| super::search::is_url_char(c);
|
||||
let continues = |line: Line| {
|
||||
line < bottom && {
|
||||
let next = line + 1;
|
||||
match (content(line), content(next)) {
|
||||
(Some((_, last)), Some((first, _))) => {
|
||||
let head = grid[next][Column(first)].c;
|
||||
// One column of padding either side is what every
|
||||
// renderer leaves; a row that stops short of that ended
|
||||
// on its own.
|
||||
last + 2 >= right
|
||||
&& first <= left + 2
|
||||
&& is_link_char(grid[line][Column(last)].c)
|
||||
&& is_link_char(head)
|
||||
&& head != '@'
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (start_line, end_line) = run_of_rows(click.line, top, bottom, continues);
|
||||
if start_line == end_line {
|
||||
return None;
|
||||
}
|
||||
let rows = (start_line.0..=end_line.0).filter_map(|line| {
|
||||
let line = Line(line);
|
||||
let (first, last) = content(line)?;
|
||||
Some((line, Column(first), Column(last)))
|
||||
});
|
||||
read_rows(term, click, rows)
|
||||
}
|
||||
|
||||
/// The rows `click` shares a line of text with: every row above that
|
||||
/// `continues` into the next, and every row below that one continues into.
|
||||
fn run_of_rows(
|
||||
click: Line,
|
||||
top: Line,
|
||||
bottom: Line,
|
||||
continues: impl Fn(Line) -> bool,
|
||||
) -> (Line, Line) {
|
||||
let mut start_line = click;
|
||||
let mut guard = 0;
|
||||
while start_line > top && guard < MAX_WRAP_ROWS && continues(start_line - 1) {
|
||||
start_line -= 1;
|
||||
guard += 1;
|
||||
}
|
||||
let mut end_line = click.line;
|
||||
let mut end_line = click;
|
||||
guard = 0;
|
||||
while end_line < bottom && guard < MAX_WRAP_ROWS && continues(end_line) {
|
||||
end_line += 1;
|
||||
guard += 1;
|
||||
}
|
||||
(start_line, end_line)
|
||||
}
|
||||
|
||||
/// Reads the given stretch of each row, in order, as one string: its text,
|
||||
/// the grid point of every character in it, and which of those is `click`.
|
||||
fn read_rows<T: EventListener>(
|
||||
term: &Term<T>,
|
||||
click: Point,
|
||||
rows: impl Iterator<Item = (Line, Column, Column)>,
|
||||
) -> Option<(String, Vec<Point>, usize)> {
|
||||
let grid = term.grid();
|
||||
let mut text = String::new();
|
||||
let mut points = Vec::new();
|
||||
let mut click_idx = None;
|
||||
let mut line = start_line;
|
||||
while line <= end_line {
|
||||
for col in 0..cols {
|
||||
for (line, from, to) in rows {
|
||||
for col in from.0..=to.0 {
|
||||
let cell = &grid[line][Column(col)];
|
||||
let p = Point::new(line, Column(col));
|
||||
if cell.flags.contains(Flags::LEADING_WIDE_CHAR_SPACER) {
|
||||
@@ -337,12 +445,52 @@ pub(super) fn logical_line_at<T: EventListener>(
|
||||
text.push(cell.c);
|
||||
points.push(p);
|
||||
}
|
||||
line += 1;
|
||||
}
|
||||
let click_idx = click_idx.filter(|&i| i < points.len())?;
|
||||
Some((text, points, click_idx))
|
||||
}
|
||||
|
||||
/// The cells `points` covers, as one run per row — what an underline has to
|
||||
/// paint when those points are not one stretch of the grid. A wide glyph at
|
||||
/// the end of a run takes its spacer column along.
|
||||
pub(super) fn row_runs<T: EventListener>(term: &Term<T>, points: &[Point]) -> Vec<(Point, Point)> {
|
||||
let mut runs: Vec<(Point, Point)> = Vec::new();
|
||||
for &p in points {
|
||||
match runs.last_mut() {
|
||||
Some((_, end)) if end.line == p.line => *end = p,
|
||||
_ => runs.push((p, p)),
|
||||
}
|
||||
}
|
||||
let grid = term.grid();
|
||||
for (_, end) in &mut runs {
|
||||
let next = end.column + 1;
|
||||
if next.0 < term.columns() && grid[end.line][next].flags.contains(Flags::WIDE_CHAR_SPACER) {
|
||||
*end = Point::new(end.line, next);
|
||||
}
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
/// Every cell from `start` to `end` in reading order, as one run per row.
|
||||
pub(super) fn grid_runs(start: Point, end: Point, cols: usize) -> Vec<(Point, Point)> {
|
||||
(start.line.0..=end.line.0)
|
||||
.map(|line| {
|
||||
let line = Line(line);
|
||||
let from = if line == start.line {
|
||||
start.column
|
||||
} else {
|
||||
Column(0)
|
||||
};
|
||||
let to = if line == end.line {
|
||||
end.column
|
||||
} else {
|
||||
Column(cols.saturating_sub(1))
|
||||
};
|
||||
(Point::new(line, from), Point::new(line, to))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn pair_range(chars: &[char], click: usize) -> Option<(usize, usize)> {
|
||||
bracket_range(chars, click).or_else(|| quote_range(chars, click))
|
||||
}
|
||||
@@ -747,6 +895,92 @@ mod tests {
|
||||
assert_eq!(url, "https://user1234@ex.com/z");
|
||||
}
|
||||
|
||||
/// A table renderer wraps a long cell by hand: every row is a hard
|
||||
/// newline, framed by borders, with the next cell's text in between.
|
||||
/// This is the shape Claude Code prints a markdown table in.
|
||||
const TABLE: [&str; 8] = [
|
||||
"┌───────────────────────────────┬──────────┐",
|
||||
"│ report.xlsx (https://s3.examp │ 10/2 │",
|
||||
"│ le.com/a/b.xlsx?Sig=xy%3D&Exp │ 20:13 │",
|
||||
"│ ires=179)(美妆) │ │",
|
||||
"├───────────────────────────────┼──────────┤",
|
||||
"│ short https://a.com │ 10/2 │",
|
||||
"│ tail.com │ │",
|
||||
"└───────────────────────────────┴──────────┘",
|
||||
];
|
||||
|
||||
const TABLE_URL: &str = "https://s3.example.com/a/b.xlsx?Sig=xy%3D&Expires=179";
|
||||
|
||||
fn table_term() -> Term<VoidListener> {
|
||||
term_with(50, 8, &TABLE.join("\r\n"))
|
||||
}
|
||||
|
||||
fn table_col(line: usize, needle: &str) -> usize {
|
||||
let row = TABLE[line];
|
||||
row[..row.find(needle).expect("needle in fixture")]
|
||||
.chars()
|
||||
.count()
|
||||
}
|
||||
|
||||
fn table_link(term: &Term<VoidListener>, line: usize, col: usize) -> Option<String> {
|
||||
let click = Point::new(Line(line as i32), Column(col));
|
||||
let (text, _points, idx) = logical_line_at(term, click, true)?;
|
||||
crate::terminal::search::url_span_at(&text, idx).map(|(_, _, url)| url)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_url_a_table_wrapped_inside_its_cell_is_one_link() {
|
||||
let term = table_term();
|
||||
for (line, needle, where_) in [
|
||||
(1, "https", "the row it starts on"),
|
||||
(2, "Exp", "the middle row"),
|
||||
(3, "ires", "the row it ends on"),
|
||||
] {
|
||||
assert_eq!(
|
||||
table_link(&term, line, table_col(line, needle)).as_deref(),
|
||||
Some(TABLE_URL),
|
||||
"{where_} opens the whole URL"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_cell_underlines_only_its_own_columns() {
|
||||
let term = table_term();
|
||||
let click = Point::new(Line(2), Column(4));
|
||||
let (text, points, idx) = logical_line_at(&term, click, true).expect("cell text");
|
||||
let (s, e, _) = crate::terminal::search::url_span_at(&text, idx).expect("a url");
|
||||
let runs: Vec<_> = row_runs(&term, &points[s..=e])
|
||||
.iter()
|
||||
.map(|(a, b)| (a.line.0, a.column.0, b.column.0))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
runs,
|
||||
vec![(1, table_col(1, "https"), 30), (2, 2, 30), (3, 2, 9)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_cell_that_stops_short_of_its_border_is_not_wrapped() {
|
||||
let term = table_term();
|
||||
assert_eq!(
|
||||
table_link(&term, 5, table_col(5, "a.com")).as_deref(),
|
||||
Some("https://a.com"),
|
||||
"the next row's text is its own line, not the URL's tail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_does_not_read_across_table_rows() {
|
||||
let term = table_term();
|
||||
let click = Point::new(Line(2), Column(4));
|
||||
let (text, _points, _idx) = logical_line_at(&term, click, false).expect("row text");
|
||||
assert!(
|
||||
!text.contains("https"),
|
||||
"selection keeps to its row: {text:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_glyph_and_its_spacer_resolve_to_the_same_word() {
|
||||
ensure_segmenter();
|
||||
|
||||
+26
-14
@@ -511,8 +511,10 @@ pub struct TerminalView {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct HoveredLink {
|
||||
pub start: Point,
|
||||
pub end: Point,
|
||||
/// The cells to underline, one run per row. A link in a table cell
|
||||
/// shares its rows with other cells, so its extent is not always one
|
||||
/// stretch of the grid from a start to an end.
|
||||
pub runs: Vec<(Point, Point)>,
|
||||
/// Whether the modifier that would open this link is down.
|
||||
///
|
||||
/// A link underlines as soon as the pointer reaches it, so the user can
|
||||
@@ -532,8 +534,9 @@ enum LoopbackOpen {
|
||||
|
||||
/// What sits under a cell, once the grid has been read.
|
||||
enum GridLink {
|
||||
/// An OSC 8 hyperlink the emitter declared, with the extent it declared.
|
||||
Hyperlink(String, Point, Point),
|
||||
/// An OSC 8 hyperlink the emitter declared, with the extent it declared,
|
||||
/// one run per row.
|
||||
Hyperlink(String, Vec<(Point, Point)>),
|
||||
/// The logical line the cell belongs to, the grid point of every character
|
||||
/// in it, and which of those the cell is.
|
||||
Text(String, Vec<Point>, usize),
|
||||
@@ -541,7 +544,8 @@ enum GridLink {
|
||||
|
||||
/// The outcome of asking what a cell links to.
|
||||
enum LinkAt {
|
||||
Found(LinkTarget, Point, Point),
|
||||
/// A link, and the cells it covers as one run per row.
|
||||
Found(LinkTarget, Vec<(Point, Point)>),
|
||||
/// A token that parses as a path, that nothing has answered for.
|
||||
/// `pending` separates the two reasons: the path is not there, or the host
|
||||
/// that would know has not replied yet.
|
||||
@@ -6294,7 +6298,7 @@ impl TerminalView {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<HoveredLink> {
|
||||
match self.resolve_link_at(col, row, true, include_loopback, cx) {
|
||||
LinkAt::Found(_, start, end) => Some(HoveredLink { start, end, armed }),
|
||||
LinkAt::Found(_, runs) => Some(HoveredLink { runs, armed }),
|
||||
LinkAt::Unresolved { .. } | LinkAt::None => None,
|
||||
}
|
||||
}
|
||||
@@ -6311,8 +6315,8 @@ impl TerminalView {
|
||||
return LinkAt::None;
|
||||
};
|
||||
let (text, points, click_idx) = match line {
|
||||
GridLink::Hyperlink(uri, start, end) => {
|
||||
return LinkAt::Found(LinkTarget::Url(uri), start, end);
|
||||
GridLink::Hyperlink(uri, runs) => {
|
||||
return LinkAt::Found(LinkTarget::Url(uri), runs);
|
||||
}
|
||||
GridLink::Text(text, points, click_idx) => (text, points, click_idx),
|
||||
};
|
||||
@@ -6359,7 +6363,11 @@ impl TerminalView {
|
||||
})?
|
||||
});
|
||||
match link {
|
||||
Some(link) => LinkAt::Found(link.target, points[link.start], points[link.end]),
|
||||
Some(link) => {
|
||||
let term = self.terminal.term.lock();
|
||||
let runs = super::smart_select::row_runs(&term, &points[link.start..=link.end]);
|
||||
LinkAt::Found(link.target, runs)
|
||||
}
|
||||
// Nothing answered. Hand back what the token *said* so a click can
|
||||
// say so out loud instead of looking broken.
|
||||
None => match files
|
||||
@@ -6385,7 +6393,8 @@ impl TerminalView {
|
||||
if let Some(hl) = term.grid()[line][Column(col)].hyperlink() {
|
||||
let uri = hl.uri().to_string();
|
||||
if let Some((start, end)) = super::smart_select::hyperlink_run(&term, click) {
|
||||
return Some(GridLink::Hyperlink(uri, start, end));
|
||||
let runs = super::smart_select::grid_runs(start, end, term.columns());
|
||||
return Some(GridLink::Hyperlink(uri, runs));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11594,8 +11603,10 @@ mod gpui_tests {
|
||||
view.hover_link_at(0, 23, true, cx);
|
||||
assert_eq!(view.last_hover_cell, Some((0, 23)));
|
||||
view.hovered_link = Some(HoveredLink {
|
||||
start: Point::new(Line(23), Column(0)),
|
||||
end: Point::new(Line(23), Column(3)),
|
||||
runs: vec![(
|
||||
Point::new(Line(23), Column(0)),
|
||||
Point::new(Line(23), Column(3)),
|
||||
)],
|
||||
armed: true,
|
||||
});
|
||||
view.set_grid_size(80, 24, px(8.), px(17.), 1., cx);
|
||||
@@ -11648,12 +11659,13 @@ mod gpui_tests {
|
||||
"the wrapped path is a link from {where_}"
|
||||
);
|
||||
let link = view.hovered_link.as_ref().expect("a span");
|
||||
let (start, end) = (link.runs[0].0, link.runs[link.runs.len() - 1].1);
|
||||
assert_eq!(
|
||||
(link.start.line, link.end.line),
|
||||
(start.line, end.line),
|
||||
(Line(0), Line(1)),
|
||||
"and the span the element paints covers both rows"
|
||||
);
|
||||
assert_eq!(link.start.column, Column(4), "starting at the path itself");
|
||||
assert_eq!(start.column, Column(4), "starting at the path itself");
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
Reference in New Issue
Block a user