feat(tui2): select whole cell on quint-click

Add a 5+ click gesture to select the entire history cell in the transcript.

Implementation notes:
- Selection is computed in transcript/viewport coordinates (wrapped visual line
  indices + content columns), not terminal buffer coordinates.
- We rebuild the wrapped transcript view and carry forward a mapping from each
  wrapped line to its originating HistoryCell index.
- Quint-click expands to the contiguous wrapped-line range for that cell; if the
  click lands on a spacer line between cells, it selects the cell above (falling
  back to the next cell below).

Tests cover selecting the full cell (including blank lines inside the cell) and
quint-click behavior on spacer lines.
This commit is contained in:
Josh McKinney
2025-12-23 10:15:44 -08:00
parent 4ce57ef890
commit bc9ab79d09
2 changed files with 255 additions and 9 deletions

View File

@@ -56,7 +56,6 @@ use std::time::Instant;
use tracing::error;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone)]
/// Visual transcript lines plus soft-wrap joiners.
///
/// A history cell can produce multiple "visual lines" once prefixes/indents and wrapping are
@@ -87,6 +86,7 @@ use unicode_width::UnicodeWidthStr;
/// Consumers:
/// - `transcript_render` threads joiners through transcript flattening/wrapping.
/// - `transcript_copy` uses them to join wrapped prose while preserving hard breaks.
#[derive(Debug, Clone)]
pub(crate) struct TranscriptLinesWithJoiners {
/// Visual transcript lines for a history cell, including any indent/prefix spans.
///

View File

@@ -40,12 +40,15 @@
//! - "word" selection uses display width (`unicode_width`) and a lightweight
//! character class heuristic.
//! - "paragraph" selection is based on contiguous non-empty wrapped lines.
//! - "cell" selection selects all wrapped lines that belong to a single history
//! cell (the unit returned by `HistoryCell::display_lines`).
use crate::history_cell::HistoryCell;
use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS;
use crate::transcript_selection::TranscriptSelection;
use crate::transcript_selection::TranscriptSelectionPoint;
use crate::wrapping::word_wrap_lines_borrowed;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use ratatui::text::Line;
use std::sync::Arc;
use std::time::Duration;
@@ -259,6 +262,7 @@ fn max_column_distance(prev_click_count: u8) -> u16 {
/// - triple click selects the entire wrapped line
/// - quad+ click selects the containing paragraph (contiguous non-empty wrapped
/// lines, with empty/spacer lines treated as paragraph breaks)
/// - quint+ click selects the entire history cell
///
/// Returned selections are always “active” (both `anchor` and `head` set). This
/// intentionally differs from normal single-click behavior in TUI2 (which only
@@ -293,7 +297,7 @@ fn selection_for_click(
// Rebuild the same logical line stream the transcript renders from. This
// keeps expansion boundaries aligned with current streaming output and the
// current wrap width.
let lines = build_transcript_lines(cells, width);
let (lines, line_cell_index) = build_transcript_lines_with_cell_index(cells, width);
if lines.is_empty() {
return TranscriptSelection {
anchor: Some(point),
@@ -301,9 +305,13 @@ fn selection_for_click(
};
}
// Expand based on the wrapped *visual* lines so triple/quad-click selection
// respects the current wrap width.
let wrapped = word_wrap_lines_borrowed(&lines, width.max(1) as usize);
// Expand based on the wrapped *visual* lines so triple/quad/quint-click
// selection respects the current wrap width.
let (wrapped, wrapped_cell_index) = word_wrap_lines_with_cell_index(
&lines,
&line_cell_index,
RtOptions::new(width.max(1) as usize),
);
if wrapped.is_empty() {
return TranscriptSelection {
anchor: Some(point),
@@ -345,9 +353,24 @@ fn selection_for_click(
};
}
let (start_line, end_line) =
paragraph_bounds_in_wrapped_lines(&wrapped, TRANSCRIPT_GUTTER_COLS, line_index)
.unwrap_or((line_index, line_index));
if click_count == 4 {
let (start_line, end_line) =
paragraph_bounds_in_wrapped_lines(&wrapped, TRANSCRIPT_GUTTER_COLS, line_index)
.unwrap_or((line_index, line_index));
return TranscriptSelection {
anchor: Some(TranscriptSelectionPoint::new(start_line, 0)),
head: Some(TranscriptSelectionPoint::new(end_line, max_content_col)),
};
}
let Some((start_line, end_line)) =
cell_bounds_in_wrapped_lines(&wrapped_cell_index, line_index)
else {
return TranscriptSelection {
anchor: Some(point),
head: Some(point),
};
};
TranscriptSelection {
anchor: Some(TranscriptSelectionPoint::new(start_line, 0)),
head: Some(TranscriptSelectionPoint::new(end_line, max_content_col)),
@@ -359,6 +382,7 @@ fn selection_for_click(
/// This mirrors `App::build_transcript_lines` semantics: insert a blank spacer
/// line between non-continuation cells so word/paragraph boundaries match what
/// the user sees.
#[cfg(test)]
fn build_transcript_lines(cells: &[Arc<dyn HistoryCell>], width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut has_emitted_lines = false;
@@ -386,6 +410,126 @@ fn build_transcript_lines(cells: &[Arc<dyn HistoryCell>], width: u16) -> Vec<Lin
lines
}
/// Like [`build_transcript_lines`], but also returns a per-line mapping to the
/// originating history cell index.
///
/// This mapping lets us implement "select the whole history cell" in terms of
/// wrapped visual line indices.
fn build_transcript_lines_with_cell_index(
cells: &[Arc<dyn HistoryCell>],
width: u16,
) -> (Vec<Line<'static>>, Vec<Option<usize>>) {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut line_cell_index: Vec<Option<usize>> = Vec::new();
let mut has_emitted_lines = false;
for (cell_index, cell) in cells.iter().enumerate() {
let cell_lines = cell.display_lines(width);
if cell_lines.is_empty() {
continue;
}
if !cell.is_stream_continuation() {
if has_emitted_lines {
lines.push(Line::from(""));
line_cell_index.push(None);
} else {
has_emitted_lines = true;
}
}
line_cell_index.extend(std::iter::repeat_n(Some(cell_index), cell_lines.len()));
lines.extend(cell_lines);
}
debug_assert_eq!(lines.len(), line_cell_index.len());
(lines, line_cell_index)
}
/// Wrap lines and carry forward a per-line mapping to history cell index.
///
/// This mirrors [`word_wrap_lines_borrowed`] behavior so selection expansion
/// uses the same wrapped line model as rendering.
fn word_wrap_lines_with_cell_index<'a, O>(
lines: &'a [Line<'a>],
line_cell_index: &[Option<usize>],
width_or_options: O,
) -> (Vec<Line<'a>>, Vec<Option<usize>>)
where
O: Into<RtOptions<'a>>,
{
debug_assert_eq!(lines.len(), line_cell_index.len());
let base_opts: RtOptions<'a> = width_or_options.into();
let mut out: Vec<Line<'a>> = Vec::new();
let mut out_cell_index: Vec<Option<usize>> = Vec::new();
let mut first = true;
for (line, cell_index) in lines.iter().zip(line_cell_index.iter().copied()) {
let opts = if first {
base_opts.clone()
} else {
base_opts
.clone()
.initial_indent(base_opts.subsequent_indent.clone())
};
let wrapped = word_wrap_line(line, opts);
out_cell_index.extend(std::iter::repeat_n(cell_index, wrapped.len()));
out.extend(wrapped);
first = false;
}
debug_assert_eq!(out.len(), out_cell_index.len());
(out, out_cell_index)
}
/// Expand to the contiguous range of wrapped lines that belong to a single
/// history cell.
///
/// `line_index` is in wrapped line coordinates. If the line at `line_index` is
/// a spacer (no cell index), we select the nearest preceding cell, falling back
/// to the next cell below.
fn cell_bounds_in_wrapped_lines(
wrapped_cell_index: &[Option<usize>],
line_index: usize,
) -> Option<(usize, usize)> {
let total = wrapped_cell_index.len();
if total == 0 {
return None;
}
let mut target = line_index.min(total.saturating_sub(1));
let mut cell_index = wrapped_cell_index[target];
if cell_index.is_none() {
if let Some(found) = (0..target)
.rev()
.find(|idx| wrapped_cell_index[*idx].is_some())
{
target = found;
cell_index = wrapped_cell_index[found];
} else if let Some(found) =
(target + 1..total).find(|idx| wrapped_cell_index[*idx].is_some())
{
target = found;
cell_index = wrapped_cell_index[found];
}
}
let cell_index = cell_index?;
let mut start = target;
while start > 0 && wrapped_cell_index[start - 1] == Some(cell_index) {
start = start.saturating_sub(1);
}
let mut end = target;
while end + 1 < total && wrapped_cell_index[end + 1] == Some(cell_index) {
end = end.saturating_add(1);
}
Some((start, end))
}
/// Coarse character classes used for "word-ish" selection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WordCharClass {
@@ -972,4 +1116,106 @@ mod tests {
let text: Vec<String> = lines.iter().map(flatten_line_text).collect();
assert_eq!(text, vec![" first", " cont", "", " second"]);
}
#[test]
fn quint_click_selects_entire_history_cell() {
let cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(StaticCell::new(vec![
Line::from(" first"),
Line::from(""),
Line::from(" second"),
])),
Arc::new(StaticCell::new(vec![Line::from(" other")])),
];
let width = 40;
let mut multi = TranscriptMultiClick::default();
let t0 = Instant::now();
let point = TranscriptSelectionPoint::new(2, 1);
let mut selection = TranscriptSelection::default();
multi.on_mouse_down_at(&mut selection, &cells, width, Some(point), t0);
multi.on_mouse_down_at(
&mut selection,
&cells,
width,
Some(point),
t0 + Duration::from_millis(10),
);
multi.on_mouse_down_at(
&mut selection,
&cells,
width,
Some(point),
t0 + Duration::from_millis(20),
);
multi.on_mouse_down_at(
&mut selection,
&cells,
width,
Some(point),
t0 + Duration::from_millis(30),
);
multi.on_mouse_down_at(
&mut selection,
&cells,
width,
Some(TranscriptSelectionPoint::new(2, 10)),
t0 + Duration::from_millis(40),
);
let max_content_col = width
.saturating_sub(1)
.saturating_sub(TRANSCRIPT_GUTTER_COLS);
assert_eq!(
selection.anchor.zip(selection.head).map(|(a, h)| (
a.line_index,
a.column,
h.line_index,
h.column
)),
Some((0, 0, 2, max_content_col))
);
}
#[test]
fn quint_click_on_spacer_selects_cell_above() {
let cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(StaticCell::new(vec![Line::from(" first")])),
Arc::new(StaticCell::new(vec![Line::from(" second")])),
];
let width = 40;
let mut multi = TranscriptMultiClick::default();
let t0 = Instant::now();
let mut selection = TranscriptSelection::default();
// Index 1 is the spacer line inserted between the two non-continuation cells.
let point = TranscriptSelectionPoint::new(1, 0);
for (idx, dt) in [0u64, 10, 20, 30, 40].into_iter().enumerate() {
multi.on_mouse_down_at(
&mut selection,
&cells,
width,
Some(TranscriptSelectionPoint::new(
point.line_index,
if idx < 3 { 0 } else { (idx as u16) * 5 },
)),
t0 + Duration::from_millis(dt),
);
}
let max_content_col = width
.saturating_sub(1)
.saturating_sub(TRANSCRIPT_GUTTER_COLS);
assert_eq!(
selection.anchor.zip(selection.head).map(|(a, h)| (
a.line_index,
a.column,
h.line_index,
h.column
)),
Some((0, 0, 0, max_content_col))
);
}
}