diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 732a15d345..78ccfb0387 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -18,6 +18,7 @@ use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; use crate::resume_picker::ResumeSelection; use crate::transcript_copy_ui::TranscriptCopyUi; +use crate::transcript_multi_click::TranscriptMultiClick; use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS; use crate::transcript_selection::TranscriptSelection; use crate::transcript_selection::TranscriptSelectionPoint; @@ -329,6 +330,7 @@ pub(crate) struct App { #[allow(dead_code)] transcript_scroll: TranscriptScroll, transcript_selection: TranscriptSelection, + transcript_multi_click: TranscriptMultiClick, transcript_view_top: usize, transcript_total_lines: usize, transcript_copy_ui: TranscriptCopyUi, @@ -359,7 +361,6 @@ pub(crate) struct App { // One-shot suppression of the next world-writable scan after user confirmation. skip_world_writable_scan_once: bool, } - impl App { async fn shutdown_current_conversation(&mut self) { if let Some(conversation_id) = self.chat_widget.conversation_id() { @@ -497,6 +498,7 @@ impl App { transcript_cells: Vec::new(), transcript_scroll: TranscriptScroll::default(), transcript_selection: TranscriptSelection::default(), + transcript_multi_click: TranscriptMultiClick::default(), transcript_view_top: 0, transcript_total_lines: 0, transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(copy_selection_shortcut), @@ -938,8 +940,12 @@ impl App { clamped_x, clamped_y, ); - if crate::transcript_selection::on_mouse_down(&mut self.transcript_selection, point) - { + if self.transcript_multi_click.on_mouse_down( + &mut self.transcript_selection, + &self.transcript_cells, + transcript_area.width, + point, + ) { tui.frame_requester().schedule_frame(); } } @@ -956,6 +962,8 @@ impl App { point, streaming, ); + self.transcript_multi_click + .on_mouse_drag(&self.transcript_selection, point); if outcome.lock_scroll { self.lock_transcript_scroll_to_current_view( transcript_area.height as usize, @@ -2101,6 +2109,7 @@ mod tests { transcript_cells: Vec::new(), transcript_scroll: TranscriptScroll::default(), transcript_selection: TranscriptSelection::default(), + transcript_multi_click: TranscriptMultiClick::default(), transcript_view_top: 0, transcript_total_lines: 0, transcript_copy_ui: TranscriptCopyUi::new_with_shortcut( @@ -2150,6 +2159,7 @@ mod tests { transcript_cells: Vec::new(), transcript_scroll: TranscriptScroll::default(), transcript_selection: TranscriptSelection::default(), + transcript_multi_click: TranscriptMultiClick::default(), transcript_view_top: 0, transcript_total_lines: 0, transcript_copy_ui: TranscriptCopyUi::new_with_shortcut( diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index a2fa8fb349..4583a5d84a 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -79,6 +79,7 @@ mod text_formatting; mod tooltips; mod transcript_copy; mod transcript_copy_ui; +mod transcript_multi_click; mod transcript_render; mod transcript_selection; mod tui; diff --git a/codex-rs/tui2/src/transcript_multi_click.rs b/codex-rs/tui2/src/transcript_multi_click.rs new file mode 100644 index 0000000000..d0dd40352b --- /dev/null +++ b/codex-rs/tui2/src/transcript_multi_click.rs @@ -0,0 +1,918 @@ +//! Transcript-relative multi-click selection helpers. +//! +//! This module implements multi-click selection in terms of the **rendered +//! transcript model** (wrapped transcript lines + content columns), not +//! terminal buffer coordinates. +//! +//! Terminal `(row, col)` coordinates are ephemeral: scrolling, resizing, and +//! reflow (especially while streaming) change where a given piece of transcript +//! content appears on screen. Transcript-relative selection coordinates are +//! stable because they are anchored to the flattened, wrapped transcript line +//! model. +//! +//! Integration notes: +//! - Mouse event → `TranscriptSelectionPoint` mapping is handled by `app.rs`. +//! - This module: +//! - groups nearby clicks into a multi-click sequence +//! - expands the selection based on the current click count +//! - rebuilds the wrapped transcript lines from `HistoryCell::display_lines(width)` +//! so selection expansion matches on-screen wrapping. +//! - In TUI2 we start transcript selection on drag. A single click stores an +//! anchor but is not an "active" selection (no head). Multi-click selection +//! (double/triple/quad+) *does* create an active selection immediately. +//! +//! Complexity / cost model: +//! - single clicks are `O(1)` (just click tracking + caret placement) +//! - multi-click expansion rebuilds the current wrapped transcript view +//! (`O(total rendered transcript text)`) so selection matches what is on screen +//! *right now* (including streaming/reflow). +//! +//! Coordinates: +//! - `TranscriptSelectionPoint::line_index` is an index into the flattened, +//! wrapped transcript lines ("visual lines"). +//! - `TranscriptSelectionPoint::column` is a 0-based *content* column offset, +//! measured from immediately after the transcript gutter +//! (`TRANSCRIPT_GUTTER_COLS`). +//! - Selection endpoints are inclusive (they represent a closed interval of +//! selected cells). +//! +//! Selection expansion is UI-oriented: +//! - "word" selection uses display width (`unicode_width`) and a lightweight +//! character class heuristic. +//! - "paragraph" selection is based on contiguous non-empty wrapped 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 ratatui::text::Line; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; +use unicode_width::UnicodeWidthChar; + +/// Stateful multi-click selection handler for the transcript viewport. +/// +/// This holds the click history required to infer multi-click sequences across +/// mouse events. The actual selection expansion is computed from the current +/// transcript content so it stays aligned with on-screen wrapping. +#[derive(Debug, Default)] +pub(crate) struct TranscriptMultiClick { + /// Tracks recent clicks so we can infer a multi-click sequence. + /// + /// This is intentionally kept separate from the selection itself: selection + /// endpoints are owned by `TranscriptSelection`, while multi-click behavior + /// is a transient input gesture state. + tracker: ClickTracker, +} + +impl TranscriptMultiClick { + /// Handle a left-button mouse down within the transcript viewport. + /// + /// This is intended to be called from `App`'s mouse handler. + /// + /// Behavior: + /// - Always updates the underlying selection anchor (delegates to + /// [`crate::transcript_selection::on_mouse_down`]) so dragging can extend + /// from this point. + /// - Tracks the click as part of a potential multi-click sequence. + /// - On multi-click (double/triple/quad+), replaces the selection with an + /// active expanded selection (word/line/paragraph). + /// + /// `width` must match the transcript viewport width used for rendering so + /// wrapping (and therefore word/paragraph boundaries) align with what the + /// user sees. + /// + /// Returns whether the selection changed (useful to decide whether to + /// request a redraw). + pub(crate) fn on_mouse_down( + &mut self, + selection: &mut TranscriptSelection, + cells: &[Arc], + width: u16, + point: Option, + ) -> bool { + self.on_mouse_down_at(selection, cells, width, point, Instant::now()) + } + + /// Notify the handler that the user is drag-selecting. + /// + /// Drag-selection should not be interpreted as a continuation of a + /// multi-click sequence, so we reset click history once the cursor moves + /// away from the anchor point. + /// + /// `point` is expected to be clamped to transcript content coordinates. If + /// `point` is `None`, this is a no-op. + pub(crate) fn on_mouse_drag( + &mut self, + selection: &TranscriptSelection, + point: Option, + ) { + if let (Some(anchor), Some(point)) = (selection.anchor, point) + && point != anchor + { + self.tracker.reset(); + } + } + + /// Testable implementation of [`Self::on_mouse_down`]. + /// + /// Taking `now` as an input makes click grouping deterministic in tests. + /// + /// High-level flow (kept here so callers don’t have to mentally simulate the + /// selection state machine): + /// 1. Update the underlying selection state using + /// [`crate::transcript_selection::on_mouse_down`]. In TUI2 this records an + /// anchor and clears any head so a single click does not leave a visible + /// selection. + /// 2. If the click is outside the transcript content (`point == None`), + /// reset the click tracker and return. + /// 3. Register the click with the tracker to infer the click count. + /// 4. For multi-click (`>= 2`), compute an expanded selection from the + /// *current* wrapped transcript view and overwrite the selection with an + /// active selection (`anchor` + `head` set). + fn on_mouse_down_at( + &mut self, + selection: &mut TranscriptSelection, + cells: &[Arc], + width: u16, + point: Option, + now: Instant, + ) -> bool { + let before = *selection; + + let selection_changed = crate::transcript_selection::on_mouse_down(selection, point); + let Some(point) = point else { + self.tracker.reset(); + return selection_changed; + }; + + let click_count = self.tracker.register_click(point, now); + if click_count == 1 { + return *selection != before; + } + + *selection = selection_for_click(cells, width, point, click_count); + *selection != before + } +} + +/// Tracks recent clicks so we can infer multi-click counts. +#[derive(Debug, Default)] +struct ClickTracker { + /// The last click observed (used to group nearby clicks into a sequence). + last_click: Option, +} + +/// A single click event used for multi-click grouping. +#[derive(Debug, Clone, Copy)] +struct Click { + /// Location of the click in transcript coordinates. + point: TranscriptSelectionPoint, + /// Click count for the current sequence. + click_count: u8, + /// Time the click occurred (used to bound multi-click grouping). + at: Instant, +} + +impl ClickTracker { + /// Maximum time gap between clicks to be considered part of a sequence. + const MAX_DELAY: Duration = Duration::from_millis(500); + /// Maximum horizontal motion (in transcript *content* columns) to be + /// considered "the same click target" for multi-click grouping. + const MAX_COLUMN_DISTANCE: u16 = 2; + + /// Reset click history so the next click begins a new sequence. + fn reset(&mut self) { + self.last_click = None; + } + + /// Record a click and return the inferred click count for this sequence. + /// + /// Clicks are grouped when: + /// - they occur close in time (`MAX_DELAY`), and + /// - they target the same transcript wrapped line, and + /// - they occur at nearly the same content column (`MAX_COLUMN_DISTANCE`) + /// + /// The returned count saturates at `u8::MAX` (we only care about the + /// `>= 4` bucket). + fn register_click(&mut self, point: TranscriptSelectionPoint, now: Instant) -> u8 { + let mut click_count = 1u8; + if let Some(prev) = self.last_click + && now.duration_since(prev.at) <= Self::MAX_DELAY + && prev.point.line_index == point.line_index + && prev.point.column.abs_diff(point.column) <= Self::MAX_COLUMN_DISTANCE + { + click_count = prev.click_count.saturating_add(1); + } + + self.last_click = Some(Click { + point, + click_count, + at: now, + }); + + click_count + } +} + +/// Expand a click (plus inferred `click_count`) into a transcript selection. +/// +/// This is the core of multi-click behavior. For expanded selections it +/// rebuilds the current wrapped transcript view from history cells so selection +/// boundaries line up with the rendered transcript model (not raw source +/// strings, and not terminal buffer coordinates). +/// +/// `TranscriptSelectionPoint::column` is interpreted in content coordinates: +/// column 0 is the first column immediately after the transcript gutter +/// (`TRANSCRIPT_GUTTER_COLS`). The returned selection columns are clamped to +/// the content width for the given `width`. +/// +/// Gesture mapping: +/// - double click selects a “word-ish” run on the clicked wrapped line +/// - 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) +/// +/// Returned selections are always “active” (both `anchor` and `head` set). This +/// intentionally differs from normal single-click behavior in TUI2 (which only +/// stores an anchor until a drag makes the selection active). +/// +/// Defensiveness: +/// - if the transcript is empty, or wrapping yields no lines, this falls back +/// to a caret-like selection at `point` so multi-click never produces “no +/// selection” +/// - if `point` refers past the end of the wrapped line list, it is clamped to +/// the last wrapped line so behavior stays stable during scroll/resize/reflow +fn selection_for_click( + cells: &[Arc], + width: u16, + point: TranscriptSelectionPoint, + click_count: u8, +) -> TranscriptSelection { + if click_count == 1 { + return TranscriptSelection { + anchor: Some(point), + head: Some(point), + }; + } + + // `width` is the total viewport width, including the gutter. Selection + // columns are content-relative, so compute the maximum selectable *content* + // column. + let max_content_col = width + .saturating_sub(1) + .saturating_sub(TRANSCRIPT_GUTTER_COLS); + + // 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); + if lines.is_empty() { + return TranscriptSelection { + anchor: Some(point), + head: Some(point), + }; + } + + // 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); + if wrapped.is_empty() { + return TranscriptSelection { + anchor: Some(point), + head: Some(point), + }; + } + + // Clamp both the target line and column into the current wrapped view. This + // matters during live streaming, where the transcript can grow between the + // time the UI clamps the click and the time we compute expansion. + let line_index = point.line_index.min(wrapped.len().saturating_sub(1)); + let point = TranscriptSelectionPoint::new(line_index, point.column.min(max_content_col)); + + if click_count == 2 { + let Some((start, end)) = + word_bounds_in_wrapped_line(&wrapped[line_index], TRANSCRIPT_GUTTER_COLS, point.column) + else { + return TranscriptSelection { + anchor: Some(point), + head: Some(point), + }; + }; + return TranscriptSelection { + anchor: Some(TranscriptSelectionPoint::new( + line_index, + start.min(max_content_col), + )), + head: Some(TranscriptSelectionPoint::new( + line_index, + end.min(max_content_col), + )), + }; + } + + if click_count == 3 { + return TranscriptSelection { + anchor: Some(TranscriptSelectionPoint::new(line_index, 0)), + head: Some(TranscriptSelectionPoint::new(line_index, max_content_col)), + }; + } + + let (start_line, end_line) = + paragraph_bounds_in_wrapped_lines(&wrapped, TRANSCRIPT_GUTTER_COLS, line_index) + .unwrap_or((line_index, line_index)); + TranscriptSelection { + anchor: Some(TranscriptSelectionPoint::new(start_line, 0)), + head: Some(TranscriptSelectionPoint::new(end_line, max_content_col)), + } +} + +/// Flatten transcript history cells into the same line stream used by the UI. +/// +/// 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. +fn build_transcript_lines(cells: &[Arc], width: u16) -> Vec> { + let mut lines: Vec> = Vec::new(); + let mut has_emitted_lines = false; + + for cell in cells { + let cell_lines = cell.display_lines(width); + if cell_lines.is_empty() { + continue; + } + + if !cell.is_stream_continuation() { + if has_emitted_lines { + // `App` inserts a spacer between distinct (non-continuation) + // history cells; preserve that here so paragraph detection + // matches what users see. + lines.push(Line::from("")); + } else { + has_emitted_lines = true; + } + } + + lines.extend(cell_lines); + } + + lines +} + +/// Coarse character classes used for "word-ish" selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WordCharClass { + /// Any whitespace (select as a contiguous run). + Whitespace, + /// Alphanumeric plus token punctuation (paths/idents/URLs). + Token, + /// Everything else. + Other, +} + +/// Classify characters for UI-oriented "word-ish" selection. +/// +/// This intentionally does not attempt full Unicode word boundary semantics. +/// It is tuned for terminal transcript interactions, where "word" often means +/// identifiers, paths, URLs, and punctuation-adjacent tokens. +fn word_char_class(ch: char) -> WordCharClass { + if ch.is_whitespace() { + return WordCharClass::Whitespace; + } + + let is_token = ch.is_alphanumeric() + || matches!( + ch, + '_' | '-' + | '.' + | '/' + | '\\' + | ':' + | '@' + | '#' + | '$' + | '%' + | '+' + | '=' + | '?' + | '&' + | '~' + | '*' + ); + if is_token { + WordCharClass::Token + } else { + WordCharClass::Other + } +} + +/// Concatenate a styled `Line` into its plain text representation. +/// +/// Multi-click selection operates on the rendered text content (what the user +/// sees), independent of styling. +fn flatten_line_text(line: &Line<'_>) -> String { + line.spans.iter().map(|s| s.content.as_ref()).collect() +} + +/// Find the UTF-8 byte index that corresponds to `prefix_cols` display columns. +/// +/// This is used to exclude the transcript gutter/prefix when interpreting +/// clicks and paragraph breaks. Column math uses display width, not byte +/// offsets, to match terminal layout. +fn byte_index_after_prefix_cols(text: &str, prefix_cols: u16) -> usize { + let mut col = 0u16; + for (idx, ch) in text.char_indices() { + if col >= prefix_cols { + return idx; + } + col = col.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16); + } + text.len() +} + +/// Compute the (inclusive) content column bounds of the "word" under a click. +/// +/// This is defined in terms of the *rendered* line: +/// - `line` is a visual wrapped transcript line (including the gutter/prefix). +/// - `prefix_cols` is the number of display columns to ignore on the left +/// (the transcript gutter). +/// - `click_col` is a 0-based content column, measured from the first column +/// after the gutter. +/// +/// The returned `(start, end)` is an inclusive selection range in content +/// columns (`0..=max_content_col`), suitable for populating +/// [`TranscriptSelectionPoint::column`]. +fn word_bounds_in_wrapped_line( + line: &Line<'_>, + prefix_cols: u16, + click_col: u16, +) -> Option<(u16, u16)> { + // We compute word bounds by flattening to plain text and mapping each + // displayed glyph to a column range (by display width). This mirrors what + // the user sees, even if the underlying spans have multiple styles. + // + // Notes / limitations: + // - This operates at the `char` level, not grapheme clusters. For most + // transcript content (ASCII-ish tokens/paths/URLs) that’s sufficient. + // - Zero-width chars are skipped; they don’t occupy a terminal cell. + let full = flatten_line_text(line); + let prefix_byte = byte_index_after_prefix_cols(&full, prefix_cols); + let content = &full[prefix_byte..]; + + let mut cells: Vec<(char, u16, u16)> = Vec::new(); + let mut col = 0u16; + for ch in content.chars() { + let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; + if w == 0 { + continue; + } + let start = col; + let end = col.saturating_add(w); + cells.push((ch, start, end)); + col = end; + } + + let total_width = col; + if cells.is_empty() || total_width == 0 { + return None; + } + + let click_col = click_col.min(total_width.saturating_sub(1)); + let mut idx = cells + .iter() + .position(|(_, start, end)| click_col >= *start && click_col < *end) + .unwrap_or(0); + if idx >= cells.len() { + idx = cells.len().saturating_sub(1); + } + + let class = word_char_class(cells[idx].0); + + let mut start_idx = idx; + while start_idx > 0 && word_char_class(cells[start_idx - 1].0) == class { + start_idx = start_idx.saturating_sub(1); + } + + let mut end_idx = idx; + while end_idx + 1 < cells.len() && word_char_class(cells[end_idx + 1].0) == class { + end_idx = end_idx.saturating_add(1); + } + + let start_col = cells[start_idx].1; + let end_col = cells[end_idx].2.saturating_sub(1); + Some((start_col, end_col)) +} + +/// Compute the (inclusive) wrapped line index bounds of the paragraph +/// surrounding `line_index`. +/// +/// Paragraphs are defined on *wrapped visual lines* (not underlying history +/// cells): a paragraph is any contiguous run of non-empty wrapped lines, and +/// empty lines (after trimming the transcript gutter/prefix) break paragraphs. +/// +/// When `line_index` points at a break line, this selects the nearest preceding +/// non-break line so a quad-click on the spacer line between history cells +/// selects the paragraph above (matching common terminal UX expectations). +fn paragraph_bounds_in_wrapped_lines( + lines: &[Line<'_>], + prefix_cols: u16, + line_index: usize, +) -> Option<(usize, usize)> { + if lines.is_empty() { + return None; + } + + // Paragraph breaks are determined after skipping the transcript gutter so a + // line that only contains the gutter prefix still counts as “empty”. + let is_break = |idx: usize| -> bool { + let full = flatten_line_text(&lines[idx]); + let prefix_byte = byte_index_after_prefix_cols(&full, prefix_cols); + full[prefix_byte..].trim().is_empty() + }; + + let mut target = line_index.min(lines.len().saturating_sub(1)); + if is_break(target) { + // Prefer the paragraph above for spacer lines inserted between history + // cells. If there is no paragraph above, fall back to the next + // paragraph below. + target = (0..target) + .rev() + .find(|idx| !is_break(*idx)) + .or_else(|| (target + 1..lines.len()).find(|idx| !is_break(*idx)))?; + } + + let mut start = target; + while start > 0 && !is_break(start - 1) { + start = start.saturating_sub(1); + } + + let mut end = target; + while end + 1 < lines.len() && !is_break(end + 1) { + end = end.saturating_add(1); + } + + Some((start, end)) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use ratatui::text::Line; + + #[derive(Debug)] + struct StaticCell { + lines: Vec>, + is_stream_continuation: bool, + } + + impl StaticCell { + fn new(lines: Vec>) -> Self { + Self { + lines, + is_stream_continuation: false, + } + } + + fn continuation(lines: Vec>) -> Self { + Self { + lines, + is_stream_continuation: true, + } + } + } + + impl HistoryCell for StaticCell { + fn display_lines(&self, _width: u16) -> Vec> { + self.lines.clone() + } + + fn is_stream_continuation(&self) -> bool { + self.is_stream_continuation + } + } + + #[test] + fn word_bounds_respects_prefix_and_word_classes() { + let line = Line::from("› hello world"); + let prefix_cols = 2; + + assert_eq!( + word_bounds_in_wrapped_line(&line, prefix_cols, 1), + Some((0, 4)) + ); + assert_eq!( + word_bounds_in_wrapped_line(&line, prefix_cols, 6), + Some((5, 7)) + ); + assert_eq!( + word_bounds_in_wrapped_line(&line, prefix_cols, 9), + Some((8, 12)) + ); + } + + #[test] + fn paragraph_bounds_selects_contiguous_non_empty_lines() { + let lines = vec![ + Line::from("› first"), + Line::from(" second"), + Line::from(""), + Line::from("› third"), + ]; + let prefix_cols = 2; + + assert_eq!( + paragraph_bounds_in_wrapped_lines(&lines, prefix_cols, 1), + Some((0, 1)) + ); + assert_eq!( + paragraph_bounds_in_wrapped_lines(&lines, prefix_cols, 2), + Some((0, 1)) + ); + assert_eq!( + paragraph_bounds_in_wrapped_lines(&lines, prefix_cols, 3), + Some((3, 3)) + ); + } + + #[test] + fn click_sequence_expands_selection_word_then_line_then_paragraph() { + let cells: Vec> = vec![Arc::new(StaticCell::new(vec![ + Line::from("› first"), + Line::from(" second"), + ]))]; + let width = 20; + + let mut multi = TranscriptMultiClick::default(); + let t0 = Instant::now(); + let point = TranscriptSelectionPoint::new(1, 1); + let mut selection = TranscriptSelection::default(); + + multi.on_mouse_down_at(&mut selection, &cells, width, Some(point), t0); + assert_eq!(selection.anchor, Some(point)); + assert_eq!(selection.head, None); + + multi.on_mouse_down_at( + &mut selection, + &cells, + width, + Some(point), + t0 + Duration::from_millis(10), + ); + assert_eq!( + selection + .anchor + .zip(selection.head) + .map(|(a, h)| (a.line_index, a.column, h.column)), + Some((1, 0, 5)) + ); + + multi.on_mouse_down_at( + &mut selection, + &cells, + width, + Some(point), + t0 + Duration::from_millis(20), + ); + 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.column)), + Some((1, 0, max_content_col)) + ); + + multi.on_mouse_down_at( + &mut selection, + &cells, + width, + Some(point), + t0 + Duration::from_millis(30), + ); + assert_eq!( + selection + .anchor + .zip(selection.head) + .map(|(a, h)| (a.line_index, h.line_index)), + Some((0, 1)) + ); + } + + #[test] + fn double_click_on_whitespace_selects_whitespace_run() { + let cells: Vec> = vec![Arc::new(StaticCell::new(vec![Line::from( + "› hello world", + )]))]; + let width = 40; + + let mut multi = TranscriptMultiClick::default(); + let t0 = Instant::now(); + let point = TranscriptSelectionPoint::new(0, 6); + 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(5), + ); + + assert_eq!( + selection + .anchor + .zip(selection.head) + .map(|(a, h)| (a.column, h.column)), + Some((5, 7)) + ); + } + + #[test] + fn click_sequence_resets_when_click_moves_too_far_horizontally() { + let cells: Vec> = + vec![Arc::new(StaticCell::new(vec![Line::from("› hello world")]))]; + let width = 40; + + let mut multi = TranscriptMultiClick::default(); + let t0 = Instant::now(); + let mut selection = TranscriptSelection::default(); + + multi.on_mouse_down_at( + &mut selection, + &cells, + width, + Some(TranscriptSelectionPoint::new(0, 0)), + t0, + ); + multi.on_mouse_down_at( + &mut selection, + &cells, + width, + Some(TranscriptSelectionPoint::new(0, 3)), + t0 + Duration::from_millis(10), + ); + + assert_eq!(selection.anchor, Some(TranscriptSelectionPoint::new(0, 3))); + assert_eq!(selection.head, None); + } + + #[test] + fn click_sequence_resets_when_click_is_too_slow() { + let cells: Vec> = + vec![Arc::new(StaticCell::new(vec![Line::from("› hello world")]))]; + let width = 40; + + let mut multi = TranscriptMultiClick::default(); + let t0 = Instant::now(); + let point = TranscriptSelectionPoint::new(0, 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 + ClickTracker::MAX_DELAY + Duration::from_millis(1), + ); + + assert_eq!(selection.anchor, Some(point)); + assert_eq!(selection.head, None); + } + + #[test] + fn click_sequence_resets_when_click_changes_line() { + let cells: Vec> = vec![Arc::new(StaticCell::new(vec![ + Line::from("› first"), + Line::from(" second"), + ]))]; + let width = 40; + + let mut multi = TranscriptMultiClick::default(); + let t0 = Instant::now(); + let mut selection = TranscriptSelection::default(); + + multi.on_mouse_down_at( + &mut selection, + &cells, + width, + Some(TranscriptSelectionPoint::new(0, 1)), + t0, + ); + multi.on_mouse_down_at( + &mut selection, + &cells, + width, + Some(TranscriptSelectionPoint::new(1, 1)), + t0 + Duration::from_millis(10), + ); + + assert_eq!(selection.anchor, Some(TranscriptSelectionPoint::new(1, 1))); + assert_eq!(selection.head, None); + } + + #[test] + fn drag_resets_multi_click_sequence() { + let cells: Vec> = + vec![Arc::new(StaticCell::new(vec![Line::from("› hello world")]))]; + let width = 40; + + let mut multi = TranscriptMultiClick::default(); + let t0 = Instant::now(); + let point = TranscriptSelectionPoint::new(0, 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), + ); + assert_eq!( + selection + .anchor + .zip(selection.head) + .map(|(a, h)| (a.column, h.column)), + Some((0, 4)) + ); + + multi.on_mouse_drag(&selection, Some(TranscriptSelectionPoint::new(0, 10))); + multi.on_mouse_down_at( + &mut selection, + &cells, + width, + Some(point), + t0 + Duration::from_millis(20), + ); + assert_eq!(selection.anchor, Some(point)); + assert_eq!(selection.head, None); + } + + #[test] + fn paragraph_selects_nearest_non_empty_when_clicking_break_line() { + let cells: Vec> = 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); + 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), + ); + + assert_eq!( + selection + .anchor + .zip(selection.head) + .map(|(a, h)| (a.line_index, h.line_index)), + Some((0, 0)) + ); + } + + #[test] + fn build_transcript_lines_inserts_spacer_between_non_continuation_cells() { + let cells: Vec> = vec![ + Arc::new(StaticCell::new(vec![Line::from("› first")])), + Arc::new(StaticCell::continuation(vec![Line::from(" cont")])), + Arc::new(StaticCell::new(vec![Line::from("› second")])), + ]; + let width = 40; + + let lines = build_transcript_lines(&cells, width); + let text: Vec = lines.iter().map(flatten_line_text).collect(); + assert_eq!(text, vec!["› first", " cont", "", "› second"]); + } +}