Extract shared text selection helpers for the TUI (#46857)

## What changed

Move selection units, word and logical-line boundaries, click counting, and copy-key detection into `text_selection`, and update the transcript view to use them. Clamp selection offsets to valid UTF-8 boundaries and exclude key-release events from copy-key detection. Make `TranscriptView::cancel_search` accessible throughout the crate.

## Testing

Update existing text-layout tests to exercise the shared selection ranges for wrapped lines, combining characters, and empty logical lines.

GitOrigin-RevId: 6a8b44b4aa97fe2965a5e330121dc22fddc335b5
This commit is contained in:
Eric Traut
2026-09-20 17:58:19 +00:00
committed by copyberry
parent 2426ed7684
commit f3f07753ca
7 changed files with 93 additions and 69 deletions
+1
View File
@@ -216,6 +216,7 @@ mod terminal_probe;
mod terminal_title;
mod terminal_visualization_instructions;
mod text_formatting;
mod text_selection;
mod theme_picker;
mod thread_color;
mod thread_transcript;
+76
View File
@@ -0,0 +1,76 @@
//! Shared selection gestures and source-text boundaries; each view owns its hit testing and state.
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use std::ops::Range;
use std::time::Duration;
use std::time::Instant;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SelectionUnit {
Character,
Word,
Line,
}
impl SelectionUnit {
pub(crate) fn from_clicks(clicks: u8) -> Self {
match clicks {
2 => Self::Word,
3 => Self::Line,
_ => Self::Character,
}
}
pub(crate) fn range(self, text: &str, offset: usize) -> Range<usize> {
let offset = text.floor_char_boundary(offset.min(text.len()));
match self {
Self::Character => offset..offset,
Self::Word => text
.split_word_bound_indices()
.map(|(start, word)| start..start + word.len())
.find(|range| range.contains(&offset))
.unwrap_or(text.len()..text.len()),
// Logical lines include their hard newline, regardless of visual wrapping.
Self::Line => {
let start = text[..offset]
.rfind('\n')
.map_or(/*default*/ 0, |newline| newline + 1);
let end = text[offset..]
.find('\n')
.map_or(text.len(), |newline| offset + newline + 1);
start..end
}
}
}
}
pub(crate) fn click_count(
last_click: &mut Option<(Instant, u16, u16, u8)>,
column: u16,
row: u16,
) -> u8 {
let now = Instant::now();
let clicks = last_click
.filter(|(at, x, y, _)| {
now.duration_since(*at) < Duration::from_millis(/*millis*/ 400)
&& *x == column
&& *y == row
})
.map_or(/*default*/ 1, |(_, _, _, clicks)| clicks % 3 + 1);
*last_click = Some((now, column, row, clicks));
clicks
}
pub(crate) fn is_copy_key(key: KeyEvent) -> bool {
// Kitty reports Cmd+C as Super+C, including over SSH. Crossterm may report
// Ctrl+Shift+C as uppercase C with only Control set.
let (code, modifiers) = crate::key_hint::normalize_key_parts(key.code, key.modifiers);
key.kind != KeyEventKind::Release
&& code == KeyCode::Char('c')
&& (matches!(modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER)
|| modifiers == (KeyModifiers::CONTROL | KeyModifiers::SHIFT))
}
+3 -20
View File
@@ -9,8 +9,6 @@ use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
use ratatui::layout::Position as ScreenPosition;
use std::time::Duration;
use std::time::Instant;
use super::*;
@@ -230,14 +228,7 @@ impl TranscriptView {
key: KeyEvent,
cells: &[Arc<dyn HistoryCell>],
) -> Option<ViewAction> {
// Kitty keyboard reporting delivers macOS Cmd+C as Super+C, including over SSH to
// non-macOS hosts. Ghostty forwards it when there is no terminal-native selection.
// Crossterm can encode Ctrl+Shift+C as uppercase C with only Control set.
let (code, modifiers) = crate::key_hint::normalize_key_parts(key.code, key.modifiers);
if (matches!(modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER)
&& code == KeyCode::Char('c'))
|| (modifiers == (KeyModifiers::CONTROL | KeyModifiers::SHIFT)
&& code == KeyCode::Char('c'))
if crate::text_selection::is_copy_key(key)
|| (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Enter)
{
return Some(
@@ -316,16 +307,8 @@ impl TranscriptView {
.link_at(visible.row, event.column.saturating_sub(self.area.x))
.map(ViewAction::OpenLink);
}
let now = Instant::now();
let clicks = self
.last_click
.filter(|(at, column, row, _)| {
now.duration_since(*at) < Duration::from_millis(/*millis*/ 400)
&& *column == event.column
&& *row == event.row
})
.map_or(/*default*/ 1, |(_, _, _, clicks)| clicks % 3 + 1);
self.last_click = Some((now, event.column, event.row, clicks));
let clicks =
crate::text_selection::click_count(&mut self.last_click, event.column, event.row);
if clicks >= 2
&& visible
.layout
+1 -1
View File
@@ -215,7 +215,7 @@ impl TranscriptView {
}
/// Restore the pre-search presentation before an explicit return or cancellation.
pub(super) fn cancel_search(&mut self) {
pub(crate) fn cancel_search(&mut self) {
if !self.search.active {
return;
}
+4 -24
View File
@@ -6,6 +6,7 @@ use ratatui::layout::Position as ScreenPosition;
use unicode_segmentation::UnicodeSegmentation;
use super::*;
use crate::text_selection::SelectionUnit;
pub(super) struct Selection {
pub(super) snapshot: ViewSnapshot,
@@ -20,23 +21,6 @@ pub(super) struct Selection {
preferred_column: Option<u16>,
}
#[derive(Clone, Copy)]
enum SelectionUnit {
Character,
Word,
Line,
}
impl SelectionUnit {
fn range(self, layout: &TextLayout, offset: usize) -> std::ops::Range<usize> {
match self {
Self::Character => offset..offset,
Self::Word => layout.word_range(offset),
Self::Line => layout.line_range(offset),
}
}
}
impl TranscriptView {
pub(super) fn begin_selection(
&mut self,
@@ -49,12 +33,8 @@ impl TranscriptView {
return;
};
self.cancel_beginning();
let unit = match clicks {
2 => SelectionUnit::Word,
3 => SelectionUnit::Line,
_ => SelectionUnit::Character,
};
let range = unit.range(&layout, anchor.offset);
let unit = SelectionUnit::from_clicks(clicks);
let range = unit.range(layout.text(), anchor.offset);
let snapshot = self.capture_snapshot(cells);
self.held_reading = None;
let start = Anchor {
@@ -130,7 +110,7 @@ impl TranscriptView {
return;
};
let cells = Arc::clone(&selection.snapshot.cells);
let range = selection.unit.range(&layout, end.offset);
let range = selection.unit.range(layout.text(), end.offset);
let origin = selection.origin;
let backwards = (self.resolve(&cells, end), end.offset)
< (self.resolve(&cells, origin.0), origin.0.offset);
-20
View File
@@ -282,14 +282,6 @@ impl TextLayout {
}
}
pub(super) fn word_range(&self, offset: usize) -> Range<usize> {
self.text
.split_word_bound_indices()
.map(|(start, word)| start..start + word.len())
.find(|range| range.contains(&offset))
.unwrap_or(self.text.len()..self.text.len())
}
/// Resolve the displayed link using the same destination policy as terminal OSC-8 output.
pub(super) fn link_at(&self, row: usize, column: u16) -> Option<String> {
let row = self.rows.get(row)?;
@@ -301,18 +293,6 @@ impl TextLayout {
.terminal_destination()
}
/// Select a logical line, including its terminating hard newline when present.
pub(super) fn line_range(&self, offset: usize) -> Range<usize> {
let offset = self.text.floor_char_boundary(offset.min(self.text.len()));
let start = self.text[..offset]
.rfind('\n')
.map_or(/*default*/ 0, |newline| newline + 1);
let end = self.text[offset..]
.find('\n')
.map_or(self.text.len(), |newline| offset + newline + 1);
start..end
}
fn visible_rows(&self, area: Rect, start_row: usize) -> impl Iterator<Item = (u16, &TextRow)> {
let height = if area.width == 0 { 0 } else { area.height };
debug_assert!(area.width == 0 || area.width == self.width);
@@ -4,6 +4,7 @@ use super::TextLayout;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines;
use crate::terminal_hyperlinks::prefix_hyperlink_lines;
use crate::text_selection::SelectionUnit;
use crate::wrapping::RtOptions;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
@@ -28,7 +29,7 @@ fn copying_wrapped_text_preserves_only_hard_newlines() {
(11, 1),
);
assert_eq!(
&layout.text()[layout.line_range(/*offset*/ 12)],
&layout.text()[SelectionUnit::Line.range(layout.text(), /*offset*/ 12)],
"alpha beta gamma\n"
);
@@ -69,7 +70,10 @@ fn hit_testing_uses_graphemes_and_does_not_select_padding() {
.collect::<Vec<_>>(),
vec![0, 1, 1, 4, 7, 7, 18, 18, 18, 18, 18, 18],
);
assert_eq!(&layout.text()[layout.word_range(/*offset*/ 4)], "e\u{301}");
assert_eq!(
&layout.text()[SelectionUnit::Word.range(layout.text(), /*offset*/ 4)],
"e\u{301}"
);
}
#[test]
@@ -151,8 +155,8 @@ fn empty_logical_lines_keep_their_hard_breaks() {
(
layout.text(),
layout.row_count(),
layout.line_range(/*offset*/ 0),
layout.line_range(/*offset*/ 5)
SelectionUnit::Line.range(layout.text(), /*offset*/ 0),
SelectionUnit::Line.range(layout.text(), /*offset*/ 5)
),
("\ntext\n", 3, 0..1, 1..6),
);