feat(tui2): add multi-click transcript selection (#8471)

Support multi-click transcript selection using transcript/viewport
coordinates
(wrapped visual line index + content column), not terminal buffer
positions.

Gestures:
- double click: select word-ish token under cursor
- triple click: select entire wrapped line
- quad click: select paragraph (contiguous non-empty wrapped lines)
- quint+ click: select the entire history cell (all wrapped lines
belonging to a
  single `HistoryCell`, including blank lines inside the cell)

Selection expansion rebuilds the wrapped transcript view from
`HistoryCell::display_lines(width)` so boundaries match on-screen
wrapping during
scroll/resize/streaming reflow. Click grouping is resilient to minor
drag jitter
(some terminals emit tiny Drag events during clicks) and becomes more
tolerant as
the sequence progresses so quad/quint clicks are practical.

Tests cover expansion (word/line/paragraph/cell), sequence resets
(timing, motion,
line changes, real drags), drag jitter, and behavior on spacer lines
between
history cells (paragraph/cell selection prefers the cell above).
This commit is contained in:
Josh McKinney authored and GitHub committed 2025-12-23 21:05:06 +00:00
1 parent 53eb2e9f27
commit 0130a2fa40
13 files changed
+3256 -1021

No files matched your search

+21 -28
View File
@@ -1,4 +1,5 @@
use pretty_assertions::assert_eq;
use ratatui::style::Color;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
@@ -382,34 +383,20 @@ fn blockquote_heading_inherits_heading_style() {
fn blockquote_with_code_block() {
let md = "> ```\n> code\n> ```\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect();
assert_eq!(lines, vec!["> code".to_string()]);
assert_eq!(text.lines, [Line::from_iter(["> ", "", "code"]).cyan()]);
}
#[test]
fn blockquote_with_multiline_code_block() {
let md = "> ```\n> first\n> second\n> ```\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect();
assert_eq!(lines, vec!["> first", "> second"]);
assert_eq!(
text.lines,
[
Line::from_iter(["> ", "", "first"]).cyan(),
Line::from_iter(["> ", "", "second"]).cyan(),
]
);
}
#[test]
@@ -453,6 +440,12 @@ fn nested_blockquote_with_inline_and_fenced_code() {
"> > echo \"hello from a quote\"".to_string(),
]
);
// Fenced code inside nested blockquotes should keep code styling so copy logic can treat it as
// preformatted.
for idx in [4usize, 5usize] {
assert_eq!(text.lines[idx].style.fg, Some(Color::Cyan));
}
}
#[test]
@@ -654,7 +647,7 @@ fn link() {
#[test]
fn code_block_unhighlighted() {
let text = render_markdown_text("```rust\nfn main() {}\n```\n");
let expected = Text::from_iter([Line::from_iter(["", "fn main() {}"])]);
let expected = Text::from_iter([Line::from_iter(["", "fn main() {}"]).cyan()]);
assert_eq!(text, expected);
}
@@ -663,8 +656,8 @@ fn code_block_multiple_lines_root() {
let md = "```\nfirst\nsecond\n```\n";
let text = render_markdown_text(md);
let expected = Text::from_iter([
Line::from_iter(["", "first"]),
Line::from_iter(["", "second"]),
Line::from_iter(["", "first"]).cyan(),
Line::from_iter(["", "second"]).cyan(),
]);
assert_eq!(text, expected);
}
@@ -674,9 +667,9 @@ fn code_block_indented() {
let md = " function greet() {\n console.log(\"Hi\");\n }\n";
let text = render_markdown_text(md);
let expected = Text::from_iter([
Line::from_iter([" ", "function greet() {"]),
Line::from_iter([" ", " console.log(\"Hi\");"]),
Line::from_iter([" ", "}"]),
Line::from_iter([" ", "function greet() {"]).cyan(),
Line::from_iter([" ", " console.log(\"Hi\");"]).cyan(),
Line::from_iter([" ", "}"]).cyan(),
]);
assert_eq!(text, expected);
}