use std::cmp::{max, min}; use std::ops::Range; use std::sync::{Arc, LazyLock}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier}; use regex::Regex; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::scrollback::table_geometry::{CellRef, TableGeometry}; use crate::scrollback::types::SelectionBoundary; use crate::theme::Theme; // --------------------------------------------------------------------------- // Auto-scroll types // --------------------------------------------------------------------------- /// Direction for drag auto-scroll. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AutoScrollDirection { Up, Down, } /// State for timer-driven drag auto-scroll. /// /// While active, `tick_drag_autoscroll` scrolls by `speed` rows per tick /// in the given direction. The direction and speed are recomputed from the /// mouse position each time the pointer moves, and the state is cleared /// when the pointer returns inside the content area or the drag ends. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DragAutoScrollState { pub direction: AutoScrollDirection, /// Rows to scroll per tick. pub speed: u16, } /// Rows of near-edge interior zone that still trigger autoscroll. const EDGE_THRESHOLD: u16 = 2; /// Compute autoscroll direction and speed from mouse position relative to /// the scrollback content area. /// /// Returns `Some(state)` when the pointer is above, below, or within /// [`EDGE_THRESHOLD`] rows of the content boundary. Returns `None` when /// the pointer is comfortably inside the viewport. pub fn compute_autoscroll(mouse_row: u16, content_area: Rect) -> Option { let top = content_area.y; let bottom = content_area.y.saturating_add(content_area.height); if content_area.height == 0 { return None; } if mouse_row < top.saturating_add(EDGE_THRESHOLD) { // Above or near top edge. let distance = top.saturating_add(EDGE_THRESHOLD).saturating_sub(mouse_row); Some(DragAutoScrollState { direction: AutoScrollDirection::Up, speed: speed_for_distance(distance), }) } else if mouse_row >= bottom.saturating_sub(EDGE_THRESHOLD) { // Below or near bottom edge. let distance = mouse_row .saturating_sub(bottom.saturating_sub(EDGE_THRESHOLD)) .saturating_add(1); Some(DragAutoScrollState { direction: AutoScrollDirection::Down, speed: speed_for_distance(distance), }) } else { None } } /// Map distance-beyond-edge to scroll speed (rows per tick). fn speed_for_distance(distance: u16) -> u16 { match distance { 0..=2 => 1, 3..=5 => 2, 6..=10 => 3, _ => 5, } } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ResolvedSelectionModel { pub ranges: Vec, pub visible_blocks: Vec, pub content_area: Rect, } #[derive(Debug, Clone, PartialEq, Eq)] struct ResolvedSelectionBoundary { entry_idx: usize, range_id: u16, block_line_idx: usize, boundary: Arc, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct ResolvedSelectionBoundaries(Vec); impl ResolvedSelectionBoundaries { pub(crate) fn push(&mut self, line: &ResolvedSelectableLine, boundary: Arc) { self.0.push(ResolvedSelectionBoundary { entry_idx: line.entry_idx, range_id: line.range_id, block_line_idx: line.block_line_idx, boundary, }); } fn boundary_for_line(&self, line: &ResolvedSelectableLine) -> Option<&SelectionBoundary> { self.0 .iter() .find(|boundary| { boundary.entry_idx == line.entry_idx && boundary.range_id == line.range_id && boundary.block_line_idx == line.block_line_idx }) .map(|boundary| boundary.boundary.as_ref()) } pub(crate) fn boundary_for_hit(&self, hit: &RangeHit) -> Option<&SelectionBoundary> { self.0 .iter() .find(|boundary| { boundary.entry_idx == hit.entry_idx && boundary.range_id == hit.range_id && boundary.block_line_idx == hit.block_line_idx }) .map(|boundary| boundary.boundary.as_ref()) } #[cfg(test)] pub(crate) fn is_empty(&self) -> bool { self.0.is_empty() } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolvedSelectableRange { pub entry_idx: usize, pub range_id: u16, pub lines: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolvedSelectableLine { pub entry_idx: usize, /// Block `selection_range` ids count up from 0; `u16::MAX` is reserved /// for the labeled group header's synthetic row /// (`render::GROUP_HEADER_RANGE_ID`). pub range_id: u16, pub block_line_idx: usize, pub screen_y: u16, pub screen_x: u16, pub selectable_cols: Range, pub text: String, pub joiner_to_previous: Option, } impl ResolvedSelectableLine { /// `(col distance, clamped col-within-range)` for a pointer at screen /// `col` on this line: distance 0 with the exact offset inside the /// selectable span, otherwise the gap to the nearer edge with the offset /// clamped to that edge. `None` when the line has no selectable width. /// /// The single source of column semantics for every hit test, so their /// same-row behavior cannot diverge. fn col_metrics(&self, col: u16) -> Option<(u16, u16)> { let start = self.screen_x.saturating_add(self.selectable_cols.start); let end = self.screen_x.saturating_add(self.selectable_cols.end); if end <= start { return None; } let width = end.saturating_sub(start); Some(if col < start { (start.saturating_sub(col), 0) } else if col >= end { ( col.saturating_sub(end.saturating_sub(1)), width.saturating_sub(1), ) } else { (0, col.saturating_sub(start)) }) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct VisibleBlockGeometry { pub entry_idx: usize, pub area: Rect, pub content_area: Rect, pub selection_area: Rect, pub content_width: u16, pub top_clipped: bool, pub bottom_clipped: bool, pub drag_startable: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RangeHit { pub entry_idx: usize, pub range_id: u16, /// Stable line identifier: the line's index within the block's full output. /// Unlike the position in the visible `range.lines[]` array, this does not /// change when the viewport scrolls. pub block_line_idx: usize, pub col_within_range: u16, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PendingTextDrag { pub anchor: RangeHit, pub start_col: u16, pub start_row: u16, /// The anchor block's `VisibleBlockGeometry.content_width` at mouse-down /// (`None` when the block had no geometry then). Copy needs the width the /// drag's `block_line_idx` values were captured against even after the /// block scrolls fully out of `visible_blocks`. pub anchor_content_width: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ActiveTextDrag { pub anchor: RangeHit, pub head: RangeHit, pub kind: SelectionKind, /// Carried over from [`PendingTextDrag::anchor_content_width`]. pub anchor_content_width: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SelectionEndpoint { pub block_line_idx: usize, pub col_within_range: u16, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SelectionOrigin { Drag, DoubleClick, TripleClick, } /// Shape of a text selection: `Linear` sweeps whole lines between the /// endpoints; drags anchored inside a detected table cell are table-shaped /// (see [`crate::scrollback::table_geometry`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SelectionKind { #[default] Linear, /// Head latched to the anchor cell: text selection clamped to the cell's /// column band, spanning its wrapped fragment lines. TableCell, /// A rectangular range of whole cells (`anchor` to `head`), copied as /// TSV. Cells are carried, not re-derived: endpoints can sit on columns /// that resolve to no cell, and paint/copy must match resolution. TableGrid { anchor: CellRef, head: CellRef }, } /// Side-car [`TableGeometry`] keyed to the selection it was resolved for. /// Consumers check the key; a stale side-car is ignored (table kinds paint /// nothing without geometry). #[derive(Debug, Clone, PartialEq, Eq)] pub struct TableSelectionGeometry { pub entry_idx: usize, pub range_id: u16, pub geometry: TableGeometry, } impl TableSelectionGeometry { /// The geometry, if it was resolved for (`entry_idx`, `range_id`). pub fn for_selection(&self, entry_idx: usize, range_id: u16) -> Option<&TableGeometry> { (self.entry_idx == entry_idx && self.range_id == range_id).then_some(&self.geometry) } } /// A text selection that persists after mouse-up for visual feedback. /// /// Cleared on the next click elsewhere, Escape, or any scrollback navigation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PersistentTextSelection { pub entry_idx: usize, pub range_id: u16, pub anchor: SelectionEndpoint, pub head: SelectionEndpoint, pub origin: SelectionOrigin, pub kind: SelectionKind, } impl ResolvedSelectionModel { pub fn push_line(&mut self, line: ResolvedSelectableLine) { if let Some(last) = self.ranges.last_mut() && last.entry_idx == line.entry_idx && last.range_id == line.range_id { last.lines.push(line); return; } self.ranges.push(ResolvedSelectableRange { entry_idx: line.entry_idx, range_id: line.range_id, lines: vec![line], }); } pub fn hit_test_selectable_range(&self, col: u16, row: u16) -> Option { let mut best: Option<(u16, RangeHit)> = None; for range in &self.ranges { for line in &range.lines { if line.screen_y != row { continue; } let Some((distance, col_within_range)) = line.col_metrics(col) else { continue; }; let hit = RangeHit { entry_idx: range.entry_idx, range_id: range.range_id, block_line_idx: line.block_line_idx, col_within_range, }; if distance == 0 { return Some(hit); } if best .as_ref() .is_none_or(|(best_distance, _)| distance < *best_distance) { best = Some((distance, hit)); } } } best.map(|(_, hit)| hit) } /// Nearest line of the anchor's `(entry_idx, range_id)` to `(col, row)`, /// by `(|screen_y - row|, then col distance)` — the drag-head resolver. /// /// Unlike [`Self::hit_test_selectable_range`] this never lands on another /// range and never misses while the anchor's range has visible lines, so /// the head tracks the pointer across gap/vpad/chrome rows and past the /// range's last line (native drag semantics). Same-row behavior is /// identical to `hit_test_selectable_range` restricted to that range. /// Full ties (a pointer row equidistant between two lines) resolve to the /// line farther from the anchor, so a drag over a dead row keeps /// extending the selection instead of retreating. /// /// `None` only when the range has no selectable lines in this model /// (scrolled fully out) — callers keep the previous head then. pub fn hit_test_nearest_in_range( &self, anchor: RangeHit, col: u16, row: u16, ) -> Option { let range = self.range(anchor.entry_idx, anchor.range_id)?; let mut best: Option<((u16, u16), usize, RangeHit)> = None; for line in &range.lines { let Some((col_distance, col_within_range)) = line.col_metrics(col) else { continue; }; let key = (line.screen_y.abs_diff(row), col_distance); let anchor_distance = line.block_line_idx.abs_diff(anchor.block_line_idx); let hit = RangeHit { entry_idx: range.entry_idx, range_id: range.range_id, block_line_idx: line.block_line_idx, col_within_range, }; if best.as_ref().is_none_or(|(best_key, best_anchor_dist, _)| { key < *best_key || (key == *best_key && anchor_distance > *best_anchor_dist) }) { best = Some((key, anchor_distance, hit)); } } best.map(|(_, _, hit)| hit) } pub fn hit_test_visible_block(&self, col: u16, row: u16) -> Option<&VisibleBlockGeometry> { self.visible_blocks .iter() .find(|block| rect_contains(block.area, col, row)) } /// The content width `entry_idx`'s block was rendered at this frame, or /// `None` when the block is not in the viewport. pub fn visible_block_content_width(&self, entry_idx: usize) -> Option { self.visible_blocks .iter() .find(|b| b.entry_idx == entry_idx) .map(|b| b.content_width) } pub fn range(&self, entry_idx: usize, range_id: u16) -> Option<&ResolvedSelectableRange> { self.ranges .iter() .find(|range| range.entry_idx == entry_idx && range.range_id == range_id) } /// Hit-test for text selection: only return a hit when the click is /// directly on selectable columns (distance == 0). /// /// This ensures clicks on accent bars, borders, and padding fall through /// to block-level handling (fold toggle). pub fn hit_test_text_exact(&self, col: u16, row: u16) -> Option { for range in &self.ranges { for line in &range.lines { if line.screen_y != row { continue; } let Some((distance, col_within_range)) = line.col_metrics(col) else { continue; }; if distance == 0 { return Some(RangeHit { entry_idx: range.entry_idx, range_id: range.range_id, block_line_idx: line.block_line_idx, col_within_range, }); } } } None } /// Look up the selectable line matching a [`RangeHit`]. /// /// Convenience shorthand for `model.range(hit) → find(block_line_idx)`. pub fn line_for_hit(&self, hit: &RangeHit) -> Option<&ResolvedSelectableLine> { self.range(hit.entry_idx, hit.range_id)? .lines .iter() .find(|l| l.block_line_idx == hit.block_line_idx) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PendingBlockDrag { pub anchor_entry_idx: usize, pub start_col: u16, pub start_row: u16, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ActiveBlockDrag { pub anchor_entry_idx: usize, pub head_entry_idx: usize, } pub fn drag_threshold_exceeded(pending: &PendingTextDrag, col: u16, row: u16) -> bool { let dx = pending.start_col.abs_diff(col); let dy = pending.start_row.abs_diff(row); dx >= 1 || dy >= 1 } pub fn reconstruct_selection_text( model: &ResolvedSelectionModel, drag: &ActiveTextDrag, ) -> Option { reconstruct_selection_text_with_boundaries(model, &ResolvedSelectionBoundaries::default(), drag) } pub(crate) fn reconstruct_selection_text_with_boundaries( model: &ResolvedSelectionModel, boundaries: &ResolvedSelectionBoundaries, drag: &ActiveTextDrag, ) -> Option { let range = model.range(drag.anchor.entry_idx, drag.anchor.range_id)?; let start_bl = min(drag.anchor.block_line_idx, drag.head.block_line_idx); let end_bl = max(drag.anchor.block_line_idx, drag.head.block_line_idx); let mut out = String::new(); let mut first = true; for line in &range.lines { if line.block_line_idx < start_bl || line.block_line_idx > end_bl { continue; } if !first { out.push_str(line.joiner_to_previous.as_deref().unwrap_or("\n")); } first = false; let slice = selection_slice_for_line_by_block_idx(drag, line, boundaries.boundary_for_line(line))?; out.push_str(&slice); } if out.is_empty() && !first { return Some(out); } if first { // No visible lines found in the current model for the drag range. // This can happen when both anchor and head have scrolled off-screen. return None; } Some(out) } pub fn block_drag_threshold_exceeded(pending: &PendingBlockDrag, col: u16, row: u16) -> bool { let dx = pending.start_col.abs_diff(col); let dy = pending.start_row.abs_diff(row); dx >= 1 || dy >= 1 } pub fn render_block_drag_overlay( model: &ResolvedSelectionModel, drag: &ActiveBlockDrag, buf: &mut Buffer, ) { let theme = Theme::current(); let start = min(drag.anchor_entry_idx, drag.head_entry_idx); let end = max(drag.anchor_entry_idx, drag.head_entry_idx); for block in &model.visible_blocks { if block.entry_idx >= start && block.entry_idx <= end { for y in block.area.y..block.area.y.saturating_add(block.area.height) { for x in block.area.x..block.area.x.saturating_add(block.area.width) { if let Some(cell) = buf.cell_mut((x, y)) { apply_selection_highlight(&theme, cell); } } } } } } pub fn render_active_selection_overlay( model: &ResolvedSelectionModel, drag: &ActiveTextDrag, table: Option<&TableGeometry>, buf: &mut Buffer, ) { render_selection_overlay_impl( model, drag.anchor.entry_idx, drag.anchor.range_id, SelectionEndpoint { block_line_idx: drag.anchor.block_line_idx, col_within_range: drag.anchor.col_within_range, }, SelectionEndpoint { block_line_idx: drag.head.block_line_idx, col_within_range: drag.head.col_within_range, }, drag.kind, table, buf, ); } /// Render a persistent text selection overlay (after mouse-up). /// /// Unlike [`render_active_selection_overlay`] (which reads from [`ActiveTextDrag`]), /// this reads from [`PersistentTextSelection`] and maps stable `block_line_idx` /// coordinates back to screen positions using the current frame's /// [`ResolvedSelectionModel`]. pub fn render_persistent_selection_overlay( model: &ResolvedSelectionModel, selection: &PersistentTextSelection, table: Option<&TableGeometry>, buf: &mut Buffer, ) { render_selection_overlay_impl( model, selection.entry_idx, selection.range_id, selection.anchor, selection.head, selection.kind, table, buf, ); } #[allow(clippy::too_many_arguments)] fn render_selection_overlay_impl( model: &ResolvedSelectionModel, entry_idx: usize, range_id: u16, anchor: SelectionEndpoint, head: SelectionEndpoint, kind: SelectionKind, table: Option<&TableGeometry>, buf: &mut Buffer, ) { let Some(range) = model.range(entry_idx, range_id) else { return; }; // Table kinds need their geometry; without it (btw overlay, stale // side-car) paint nothing rather than a misleading linear sweep. let table = match kind { SelectionKind::Linear => None, SelectionKind::TableCell | SelectionKind::TableGrid { .. } => match table { Some(geom) => Some(geom), None => return, }, }; let theme = Theme::current(); let start_bl = min(anchor.block_line_idx, head.block_line_idx); let end_bl = max(anchor.block_line_idx, head.block_line_idx); for line in &range.lines { let col_ranges: Vec> = if let Some(geom) = table { // Clipped to content so the highlight matches the trimmed copy. table_selected_cols_for_line(geom, kind, anchor, head, line.block_line_idx) .into_iter() .map(|cols| clip_cols_to_content(&line.text, cols)) .collect() } else { if line.block_line_idx < start_bl || line.block_line_idx > end_bl { continue; } match selected_cols_for_endpoints( anchor.block_line_idx, anchor.col_within_range, head.block_line_idx, head.col_within_range, line, ) { Some(cols) => vec![cols], None => continue, } }; for cols in col_ranges { for col in cols.start..cols.end { let screen_x = line .screen_x .saturating_add(line.selectable_cols.start) .saturating_add(col); if let Some(cell) = buf.cell_mut((screen_x, line.screen_y)) { apply_selection_highlight(&theme, cell); } } } } } /// A table-cell selection's endpoints clamped into the cell's box (fragment /// lines x column band), normalized so start <= end. The head can sit /// outside the cell whenever the latch kept the selection there. fn table_cell_span( geom: &TableGeometry, cell: crate::scrollback::table_geometry::CellRef, anchor: SelectionEndpoint, head: SelectionEndpoint, ) -> ((usize, u16), (usize, u16)) { let lines = geom.row_lines(cell.row); let band = geom.band(cell.col); let clamp = |ep: SelectionEndpoint| { ( ep.block_line_idx .clamp(lines.start, lines.end.saturating_sub(1)), ep.col_within_range .clamp(band.start, band.end.saturating_sub(1)), ) }; let a = clamp(anchor); let h = clamp(head); if a <= h { (a, h) } else { (h, a) } } /// Clip a painted column range to the non-whitespace content it covers, /// mirroring the copy (which trims fragments and skips blank ones). /// Whitespace between content columns stays inside the range. fn clip_cols_to_content(text: &str, cols: Range) -> Range { let mut start: Option = None; let mut end = cols.start; let mut col = 0u16; for grapheme in text.graphemes(true) { let width = UnicodeWidthStr::width(grapheme) as u16; if width == 0 { continue; } let next = col.saturating_add(width); if col >= cols.end { break; } if next > cols.start && !grapheme.chars().all(char::is_whitespace) { start.get_or_insert(col.max(cols.start)); end = next.min(cols.end); } col = next; } match start { Some(start) => start..end, None => cols.start..cols.start, } } /// Selected column ranges on one line of a table-shaped selection: /// `TableCell` at most one band-clamped range, `TableGrid` one band per /// selected column. Border glyphs are never included. fn table_selected_cols_for_line( geom: &TableGeometry, kind: SelectionKind, anchor: SelectionEndpoint, head: SelectionEndpoint, block_line_idx: usize, ) -> Vec> { // Lines outside the grid can never paint; keep them allocation-free. if !geom.line_range().contains(&block_line_idx) { return Vec::new(); } match kind { SelectionKind::Linear => Vec::new(), SelectionKind::TableCell => { let Some(cell) = geom.cell_at(anchor.block_line_idx, anchor.col_within_range) else { return Vec::new(); }; let band = geom.band(cell.col); let ((l0, c0), (l1, c1)) = table_cell_span(geom, cell, anchor, head); if block_line_idx < l0 || block_line_idx > l1 { return Vec::new(); } let start = if block_line_idx == l0 { c0 } else { band.start }; let end = if block_line_idx == l1 { c1.saturating_add(1).min(band.end) } else { band.end }; if start >= end { return Vec::new(); } // Named binding: `vec![start..end]` trips single_range_in_vec_init. let band_cols: Range = start..end; vec![band_cols] } SelectionKind::TableGrid { anchor: a, head: h } => { let Some(row) = geom.row_of_line(block_line_idx) else { return Vec::new(); }; let (r0, r1) = (a.row.min(h.row), a.row.max(h.row)); let (c0, c1) = (a.col.min(h.col), a.col.max(h.col)); if row < r0 || row > r1 { return Vec::new(); } (c0..=c1).map(|c| geom.band(c)).collect() } } } /// Resolve a drag's [`SelectionKind`], with hysteresis: the head is /// latched from the cell the drag already holds, so only another cell's /// content changes the mode. Grid-line anchors stay `Linear`. pub fn resolve_table_drag_kind( geom: Option<&TableGeometry>, anchor: &RangeHit, head: &RangeHit, prev: SelectionKind, ) -> SelectionKind { let Some(geom) = geom else { return SelectionKind::Linear; }; let Some(a) = geom.cell_at(anchor.block_line_idx, anchor.col_within_range) else { return SelectionKind::Linear; }; let held = match prev { SelectionKind::TableGrid { head, .. } => head, SelectionKind::Linear | SelectionKind::TableCell => a, }; let h = geom.latched_cell_at(held, head.block_line_idx, head.col_within_range); if a == h { SelectionKind::TableCell } else { SelectionKind::TableGrid { anchor: a, head: h } } } /// Copied text for a table-shaped selection: the band-clamped span for /// `TableCell`, whole cells as TSV for `TableGrid`. `None` (= fall back to /// linear) for `Linear` drags or an anchor that no longer resolves. pub fn reconstruct_table_selection_text( geom: &TableGeometry, drag: &ActiveTextDrag, text_at: impl Fn(usize) -> Option, ) -> Option { let anchor = SelectionEndpoint { block_line_idx: drag.anchor.block_line_idx, col_within_range: drag.anchor.col_within_range, }; let head = SelectionEndpoint { block_line_idx: drag.head.block_line_idx, col_within_range: drag.head.col_within_range, }; match drag.kind { SelectionKind::Linear => None, SelectionKind::TableCell => { let cell = geom.cell_at(anchor.block_line_idx, anchor.col_within_range)?; let band = geom.band(cell.col); let ((l0, c0), (l1, c1)) = table_cell_span(geom, cell, anchor, head); let mut out = String::new(); for line in l0..=l1 { let text = text_at(line)?; let start = if line == l0 { c0 } else { band.start }; let end = if line == l1 { c1.saturating_add(1).min(band.end) } else { band.end }; let slice = crate::scrollback::types::slice_display_cols(&text, start, end); let fragment = slice.trim(); if fragment.is_empty() { continue; } if !out.is_empty() { out.push(' '); } out.push_str(fragment); } Some(out) } SelectionKind::TableGrid { anchor, head } => Some(geom.grid_tsv(anchor, head, text_at)), } } /// Uniform selection band in the classic inverted colors (`bg_base` on /// `text_primary`): styled spans (inline code, links, syntax highlighting) /// join the band instead of inverting to their own colors. /// Terminal-native / colorless themes fall back to reverse video. pub(crate) fn apply_selection_highlight(theme: &Theme, cell: &mut ratatui::buffer::Cell) { let band = theme.text_primary; if band == Color::Reset || theme.bg_base == Color::Reset { cell.modifier.insert(Modifier::REVERSED); return; } // A search-match highlight painted earlier in the frame sets REVERSED; // left in place it would swap the band right back out. cell.modifier.remove(Modifier::REVERSED); cell.set_fg(theme.bg_base); cell.set_bg(band); } fn selection_slice_for_line_by_block_idx( drag: &ActiveTextDrag, line: &ResolvedSelectableLine, boundary: Option<&SelectionBoundary>, ) -> Option { let cols = selected_cols_for_line_by_block_idx(drag, line)?; let visible_width = line .selectable_cols .end .saturating_sub(line.selectable_cols.start); let selected = slice_text_cols(&line.text, cols.clone()); Some(apply_selection_boundary( selected, boundary, cols.start == 0, cols.end == visible_width, )) } pub(crate) fn apply_selection_boundary( selected: String, boundary: Option<&SelectionBoundary>, include_prefix: bool, include_suffix: bool, ) -> String { let Some(boundary) = boundary else { return selected; }; boundary.apply(selected, include_prefix, include_suffix) } /// Compute the selected column range for a given line based on anchor/head endpoints. /// /// Shared implementation used by both active drag and persistent selection overlays: /// - Single-line: `min(anchor_col, head_col)..max(anchor_col, head_col)+1` /// - Multi-line first: `start_col..width` /// - Multi-line last: `0..end_col+1` /// - Multi-line middle: `0..width` (full line) /// /// Returns `None` if the line falls outside the anchor/head range. fn selected_cols_for_endpoints( anchor_block_line: usize, anchor_col: u16, head_block_line: usize, head_col: u16, line: &ResolvedSelectableLine, ) -> Option> { let width = line .selectable_cols .end .saturating_sub(line.selectable_cols.start); let bl = line.block_line_idx; let start_bl = min(anchor_block_line, head_block_line); let end_bl = max(anchor_block_line, head_block_line); let anchor_is_start = anchor_block_line <= head_block_line; if bl < start_bl || bl > end_bl { return None; } if start_bl == end_bl { let start = min(anchor_col, head_col); let end = max(anchor_col, head_col).saturating_add(1); return Some(start.min(width)..end.min(width)); } if bl == start_bl { let start = if anchor_is_start { anchor_col } else { head_col }; return Some(start.min(width)..width); } if bl == end_bl { let end = if anchor_is_start { head_col } else { anchor_col }; return Some(0..end.saturating_add(1).min(width)); } Some(0..width) } fn selected_cols_for_line_by_block_idx( drag: &ActiveTextDrag, line: &ResolvedSelectableLine, ) -> Option> { selected_cols_for_endpoints( drag.anchor.block_line_idx, drag.anchor.col_within_range, drag.head.block_line_idx, drag.head.col_within_range, line, ) } /// Reconstruct the full selected text from the block's complete output lines. /// /// Unlike [`reconstruct_selection_text`], which only has access to lines /// currently visible on screen, this function reads from the block's full /// output. This ensures copy produces the complete selection even when the /// anchor or head has scrolled off-screen. pub fn reconstruct_full_selection_text( block_lines: &[crate::scrollback::types::BlockLine], drag: &ActiveTextDrag, ) -> Option { reconstruct_full_selection_text_with_boundaries( block_lines, &crate::scrollback::types::SelectionBoundaries::default(), drag, ) } pub(crate) fn reconstruct_full_selection_text_with_boundaries( block_lines: &[crate::scrollback::types::BlockLine], boundaries: &crate::scrollback::types::SelectionBoundaries, drag: &ActiveTextDrag, ) -> Option { use crate::scrollback::types::{derive_selection_text, selectable_cols}; let start_bl = min(drag.anchor.block_line_idx, drag.head.block_line_idx); let end_bl = max(drag.anchor.block_line_idx, drag.head.block_line_idx); let anchor_is_start = drag.anchor.block_line_idx <= drag.head.block_line_idx; let mut out = String::new(); let mut first = true; for (idx, line) in block_lines.iter().enumerate() { if idx < start_bl || idx > end_bl { continue; } // Only include lines that belong to the same selection range. if line.selection_range != Some(drag.anchor.range_id) { continue; } let text = derive_selection_text(line); let Some(cols) = selectable_cols(&line.content, &line.selectable) else { continue; }; let width = cols.end.saturating_sub(cols.start); // Determine column slice for this line. let col_range = if start_bl == end_bl { let s = min(drag.anchor.col_within_range, drag.head.col_within_range); let e = max(drag.anchor.col_within_range, drag.head.col_within_range).saturating_add(1); s.min(width)..e.min(width) } else if idx == start_bl { let s = if anchor_is_start { drag.anchor.col_within_range } else { drag.head.col_within_range }; s.min(width)..width } else if idx == end_bl { let e = if anchor_is_start { drag.head.col_within_range } else { drag.anchor.col_within_range }; 0..e.saturating_add(1).min(width) } else { 0..width }; if !first { out.push_str(line.joiner.as_deref().unwrap_or("\n")); } first = false; let selected = slice_text_cols(&text, col_range.clone()); out.push_str(&apply_selection_boundary( selected, boundaries.get(idx).map(Arc::as_ref), col_range.start == 0, col_range.end == width, )); } if first { return None; } Some(out) } fn slice_text_cols(text: &str, cols: Range) -> String { crate::scrollback::types::slice_display_cols(text, cols.start, cols.end) } fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { col >= rect.x && col < rect.x.saturating_add(rect.width) && row >= rect.y && row < rect.y.saturating_add(rect.height) } // --------------------------------------------------------------------------- // Word / URL boundary detection (for double-click selection) // --------------------------------------------------------------------------- /// All printable ASCII punctuation except underscore, matching tmux's /// `word-separators` default from `options-table.c`. pub const DEFAULT_WORD_SEPARATORS: &str = "!\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~"; /// Load `[ui] word_separators` from config, falling back to [`DEFAULT_WORD_SEPARATORS`]. pub fn configured_word_separators() -> &'static str { static CACHED: std::sync::OnceLock = std::sync::OnceLock::new(); CACHED.get_or_init(|| { let root = match kigi_shell::config::load_effective_config() { Ok(r) => r, Err(_) => return DEFAULT_WORD_SEPARATORS.to_owned(), }; root.get("ui") .and_then(|ui| ui.get("word_separators")) .and_then(|v| v.as_str()) .unwrap_or(DEFAULT_WORD_SEPARATORS) .to_owned() }) } /// Three-class partition for word boundary detection (whitespace / separator / word). #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CharClass { Word, Whitespace, Separator, } fn classify_grapheme(grapheme: &str, separators: &str) -> CharClass { match grapheme.chars().next() { Some(c) if c.is_whitespace() => CharClass::Whitespace, Some(c) if separators.contains(c) => CharClass::Separator, _ => CharClass::Word, } } /// Find word boundaries around a display column using tmux-style three-class /// grouping. Returns the range in display columns. pub fn word_boundaries_at_col(text: &str, col: u16, separators: &str) -> Range { let mut segments: Vec<(u16, u16, CharClass)> = Vec::new(); let mut current_col: u16 = 0; for grapheme in text.graphemes(true) { let width = UnicodeWidthStr::width(grapheme) as u16; if width == 0 { continue; } let next_col = current_col.saturating_add(width); segments.push(( current_col, next_col, classify_grapheme(grapheme, separators), )); current_col = next_col; } if segments.is_empty() { return 0..0; } // If col is past the end, clamp to the last segment. let target_idx = segments .iter() .position(|(start, end, _)| col >= *start && col < *end) .unwrap_or(segments.len() - 1); let target_class = segments[target_idx].2; let mut left = target_idx; while left > 0 && segments[left - 1].2 == target_class { left -= 1; } let mut right = target_idx; while right + 1 < segments.len() && segments[right + 1].2 == target_class { right += 1; } segments[left].0..segments[right].1 } /// Pre-compiled regex for URL detection, cached for the process lifetime. static URL_RE: LazyLock = LazyLock::new(|| { Regex::new(r"(?i)\b(?:https?|ftp|file)://[^\s\x{00}-\x{1f}]+").expect("URL regex must compile") }); /// Punctuation that may appear as trailing prose punctuation after a URL. const TRAILING_URL_PUNCT: &[char] = &['.', ',', ':', ';', '!', '?', ')', ']', '}', '>', '"', '\'']; /// Strip trailing punctuation from a URL match, respecting balanced brackets. /// /// Closing brackets are only stripped when unbalanced (no matching opener /// inside the URL body), handling prose like `(see https://example.com)`. fn strip_trailing_url_punctuation(url: &str) -> &str { let mut end = url.len(); loop { let last = match url[..end].chars().next_back() { Some(c) if TRAILING_URL_PUNCT.contains(&c) => c, _ => break, }; if let Some(open) = match last { ')' => Some('('), ']' => Some('['), '}' => Some('{'), '>' => Some('<'), _ => None, } { let opens = url[..end].chars().filter(|&c| c == open).count(); let closes = url[..end].chars().filter(|&c| c == last).count(); if opens >= closes { break; } } end -= last.len_utf8(); } &url[..end] } /// Compute the display-column width of a string via grapheme clusters. fn display_width(text: &str) -> u16 { text.graphemes(true).fold(0u16, |acc, g| { acc.saturating_add(UnicodeWidthStr::width(g) as u16) }) } /// Try to find a URL that spans the given display column in `text`. /// /// Scans `text` for URLs matching common schemes (`https?://`, `ftp://`, /// `file://`) and returns the display-column range of the URL containing /// `col`, or `None` if `col` is not within any URL. /// /// Trailing punctuation (`.`, `,`, `)`, etc.) is stripped when unbalanced, /// handling prose contexts like `"see https://example.com."`. pub fn url_range_at_col(text: &str, col: u16) -> Option> { for m in URL_RE.find_iter(text) { let col_start = display_width(&text[..m.start()]); let url = strip_trailing_url_punctuation(m.as_str()); // Skip degenerate URLs reduced to just the scheme (e.g. "https://"). if url.find("://").is_some_and(|i| url[i + 3..].is_empty()) { continue; } let col_end = col_start.saturating_add(display_width(url)); if col >= col_start && col < col_end { return Some(col_start..col_end); } } None } #[cfg(test)] mod tests { use super::*; fn single_line_drag(block_line_idx: usize, width: u16) -> ActiveTextDrag { ActiveTextDrag { anchor: RangeHit { entry_idx: 0, range_id: 0, block_line_idx, col_within_range: 0, }, head: RangeHit { entry_idx: 0, range_id: 0, block_line_idx, col_within_range: width - 1, }, kind: SelectionKind::Linear, anchor_content_width: None, } } #[test] fn ordinary_copy_uses_only_selected_visible_columns() { for (text, joiner, expected) in [ ("world", Some(" ".to_string()), "world"), ("world ", None, "world"), ] { let mut model = ResolvedSelectionModel::default(); let boundaries = ResolvedSelectionBoundaries::default(); model.push_line(ResolvedSelectableLine { entry_idx: 0, range_id: 0, block_line_idx: 1, screen_y: 0, screen_x: 0, selectable_cols: 0..5, text: text.to_string(), joiner_to_previous: joiner.clone(), }); assert!(boundaries.is_empty()); assert_eq!( reconstruct_selection_text_with_boundaries( &model, &boundaries, &single_line_drag(1, 5), ), Some(expected.to_string()) ); let block_line = crate::scrollback::types::BlockLine { content: ratatui::text::Line::from("world"), selectable: crate::scrollback::types::Selectable::All, selection_range: Some(0), selection_text: Some(text.to_string()), joiner, ..Default::default() }; assert_eq!( reconstruct_full_selection_text(&[block_line], &single_line_drag(0, 5)), Some(expected.to_string()) ); } } #[test] fn edit_boundaries_apply_only_at_selected_path_edges() { use crate::scrollback::types::{ BlockLine, Selectable, SelectionBoundaries, SelectionBoundary, SelectionBoundaryEntry, }; let mut model = ResolvedSelectionModel::default(); let mut resolved_boundaries = ResolvedSelectionBoundaries::default(); for (idx, text, joiner, prefix, suffix) in [ (1, "foo", None, " ", ""), (2, "bar", Some(" ".to_string()), "", " "), ] { let line = ResolvedSelectableLine { entry_idx: 0, range_id: 0, block_line_idx: idx, screen_y: idx as u16, screen_x: 0, selectable_cols: 0..3, text: text.to_string(), joiner_to_previous: joiner, }; resolved_boundaries.push( &line, Arc::new(SelectionBoundary::new( prefix.to_string(), suffix.to_string(), )), ); model.push_line(line); } let drag = ActiveTextDrag { anchor: RangeHit { entry_idx: 0, range_id: 0, block_line_idx: 1, col_within_range: 0, }, head: RangeHit { entry_idx: 0, range_id: 0, block_line_idx: 2, col_within_range: 2, }, kind: SelectionKind::Linear, anchor_content_width: None, }; assert_eq!( reconstruct_selection_text_with_boundaries(&model, &resolved_boundaries, &drag), Some(" foo bar ".to_string()) ); let block_lines = vec![ BlockLine::separator(ratatui::text::Line::from("Edit ")), BlockLine { content: ratatui::text::Line::from("foo"), selectable: crate::scrollback::types::Selectable::All, selection_range: Some(0), ..Default::default() }, BlockLine { content: ratatui::text::Line::from("bar"), selectable: Selectable::All, selection_range: Some(0), joiner: Some(" ".to_string()), ..Default::default() }, ]; let boundaries = SelectionBoundaries::from_entries(vec![ SelectionBoundaryEntry { line_index: 1, boundary: Arc::new(SelectionBoundary::new(" ".to_string(), String::new())), }, SelectionBoundaryEntry { line_index: 2, boundary: Arc::new(SelectionBoundary::new(String::new(), " ".to_string())), }, ]); assert_eq!( reconstruct_full_selection_text_with_boundaries(&block_lines, &boundaries, &drag), Some(" foo bar ".to_string()) ); } #[test] fn hit_test_selectable_range_returns_matching_hit() { let mut model = ResolvedSelectionModel::default(); model.push_line(ResolvedSelectableLine { entry_idx: 1, range_id: 7, block_line_idx: 0, screen_y: 3, screen_x: 10, selectable_cols: 2..6, text: "body".to_string(), joiner_to_previous: None, }); let hit = model.hit_test_selectable_range(13, 3).unwrap(); assert_eq!(hit.entry_idx, 1); assert_eq!(hit.range_id, 7); assert_eq!(hit.block_line_idx, 0); assert_eq!(hit.col_within_range, 1); } #[test] fn hit_test_selectable_range_clamps_to_nearest_column_on_same_row() { let mut model = ResolvedSelectionModel::default(); model.push_line(ResolvedSelectableLine { entry_idx: 1, range_id: 7, block_line_idx: 0, screen_y: 3, screen_x: 10, selectable_cols: 2..6, text: "body".to_string(), joiner_to_previous: None, }); let left = model.hit_test_selectable_range(11, 3).unwrap(); assert_eq!(left.col_within_range, 0); let right = model.hit_test_selectable_range(16, 3).unwrap(); assert_eq!(right.col_within_range, 3); } // ----------------------------------------------------------------------- // hit_test_nearest_in_range tests // ----------------------------------------------------------------------- /// One selectable line for the nearest-in-range fixtures. fn nearest_line( range_id: u16, block_line_idx: usize, screen_y: u16, selectable_cols: Range, ) -> ResolvedSelectableLine { ResolvedSelectableLine { entry_idx: 0, range_id, block_line_idx, screen_y, screen_x: 4, selectable_cols, text: "text".to_string(), joiner_to_previous: None, } } fn range_anchor(range_id: u16, block_line_idx: usize) -> RangeHit { RangeHit { entry_idx: 0, range_id, block_line_idx, col_within_range: 0, } } /// Rows with a line of the anchor's range behave exactly like the /// range-restricted `hit_test_selectable_range`: exact col, and clamping /// to both ends. #[test] fn nearest_in_range_same_row_parity_with_selectable_range_hit() { let mut model = ResolvedSelectionModel::default(); model.push_line(nearest_line(7, 0, 3, 2..6)); let anchor = range_anchor(7, 0); for col in 0u16..14 { let nearest = model.hit_test_nearest_in_range(anchor, col, 3).unwrap(); let direct = model.hit_test_selectable_range(col, 3).unwrap(); assert_eq!(nearest, direct, "col {col} must match the same-row hit"); } } /// A pointer on a dead row (no line of the range) snaps to the nearest /// row of the range instead of missing. #[test] fn nearest_in_range_snaps_across_gap_rows() { let mut model = ResolvedSelectionModel::default(); model.push_line(nearest_line(0, 0, 2, 0..10)); model.push_line(nearest_line(0, 2, 5, 0..10)); let anchor = range_anchor(0, 0); // Row 3 is one row from the line at y=2 and two from y=5. let hit = model.hit_test_nearest_in_range(anchor, 6, 3).unwrap(); assert_eq!(hit.block_line_idx, 0); assert_eq!(hit.col_within_range, 2); // Row 4 is nearer the line at y=5. let hit = model.hit_test_nearest_in_range(anchor, 6, 4).unwrap(); assert_eq!(hit.block_line_idx, 2); } /// A pointer below the range's last line selects toward its end /// (native semantics) instead of freezing. #[test] fn nearest_in_range_snaps_from_below_last_line() { let mut model = ResolvedSelectionModel::default(); model.push_line(nearest_line(0, 0, 2, 0..10)); model.push_line(nearest_line(0, 1, 3, 0..8)); let anchor = range_anchor(0, 0); let hit = model.hit_test_nearest_in_range(anchor, 30, 9).unwrap(); assert_eq!(hit.block_line_idx, 1); // Pointer right of the line clamps to its last column. assert_eq!(hit.col_within_range, 7); let hit = model.hit_test_nearest_in_range(anchor, 1, 9).unwrap(); assert_eq!(hit.block_line_idx, 1); // Pointer left of the line clamps to its first column. assert_eq!(hit.col_within_range, 0); } /// Lines of other ranges are never candidates, even on nearer rows — /// the head stays pinned to the anchor's range. #[test] fn nearest_in_range_ignores_other_ranges_on_nearer_rows() { let mut model = ResolvedSelectionModel::default(); model.push_line(nearest_line(0, 0, 2, 0..10)); model.push_line(nearest_line(1, 0, 6, 0..10)); let anchor = range_anchor(0, 0); // Row 6 holds range 1's line; range 0's nearest is 4 rows away. let hit = model.hit_test_nearest_in_range(anchor, 3, 6).unwrap(); assert_eq!(hit.range_id, 0); assert_eq!(hit.block_line_idx, 0); } /// A full (row, col) tie resolves to the line farther from the anchor, /// so a drag paused on a dead row keeps the selection extended across it. #[test] fn nearest_in_range_tie_prefers_line_farther_from_anchor() { let mut model = ResolvedSelectionModel::default(); model.push_line(nearest_line(0, 0, 2, 0..10)); model.push_line(nearest_line(0, 2, 4, 0..10)); // Row 3 is equidistant; dragging down from line 0 crosses the gap. let hit = model .hit_test_nearest_in_range(range_anchor(0, 0), 5, 3) .unwrap(); assert_eq!(hit.block_line_idx, 2); // Dragging up from line 2 crosses it the other way. let hit = model .hit_test_nearest_in_range(range_anchor(0, 2), 5, 3) .unwrap(); assert_eq!(hit.block_line_idx, 0); } /// The range scrolled fully out of the model → no candidate; callers /// keep the previous head. #[test] fn nearest_in_range_misses_when_range_absent() { let mut model = ResolvedSelectionModel::default(); model.push_line(nearest_line(1, 0, 2, 0..10)); assert!( model .hit_test_nearest_in_range(range_anchor(0, 0), 5, 2) .is_none() ); } #[test] fn hit_test_visible_block_uses_visible_geometry() { let block = VisibleBlockGeometry { entry_idx: 2, area: Rect::new(4, 5, 10, 3), content_area: Rect::new(7, 5, 6, 3), selection_area: Rect::new(3, 5, 12, 3), content_width: 6, top_clipped: false, bottom_clipped: false, drag_startable: true, }; let model = ResolvedSelectionModel { visible_blocks: vec![block], ..Default::default() }; let hit = model.hit_test_visible_block(8, 6).unwrap(); assert_eq!(hit.entry_idx, 2); assert!(model.hit_test_visible_block(20, 20).is_none()); } #[test] fn block_drag_overlay_inverts_selected_blocks() { let model = ResolvedSelectionModel { visible_blocks: vec![ VisibleBlockGeometry { entry_idx: 0, area: Rect::new(0, 0, 5, 1), content_area: Rect::new(2, 0, 3, 1), selection_area: Rect::new(0, 0, 5, 1), content_width: 3, top_clipped: false, bottom_clipped: false, drag_startable: true, }, VisibleBlockGeometry { entry_idx: 1, area: Rect::new(0, 1, 5, 1), content_area: Rect::new(2, 1, 3, 1), selection_area: Rect::new(0, 1, 5, 1), content_width: 3, top_clipped: false, bottom_clipped: false, drag_startable: true, }, VisibleBlockGeometry { entry_idx: 2, area: Rect::new(0, 2, 5, 1), content_area: Rect::new(2, 2, 3, 1), selection_area: Rect::new(0, 2, 5, 1), content_width: 3, top_clipped: false, bottom_clipped: false, drag_startable: true, }, ], ..Default::default() }; let drag = ActiveBlockDrag { anchor_entry_idx: 0, head_entry_idx: 1, }; let mut buf = Buffer::empty(Rect::new(0, 0, 5, 3)); render_block_drag_overlay(&model, &drag, &mut buf); // Blocks 0 and 1 should be inverted, block 2 should not. // Just verify it runs without panic — visual correctness // is validated by manual testing. assert_eq!(buf.area.height, 3); } #[test] fn autoscroll_above_viewport() { let area = Rect::new(0, 10, 80, 20); let result = compute_autoscroll(5, area); assert!(result.is_some()); let s = result.unwrap(); assert_eq!(s.direction, AutoScrollDirection::Up); assert!(s.speed >= 1); } #[test] fn autoscroll_below_viewport() { let area = Rect::new(0, 10, 80, 20); let result = compute_autoscroll(35, area); assert!(result.is_some()); let s = result.unwrap(); assert_eq!(s.direction, AutoScrollDirection::Down); assert!(s.speed >= 1); } #[test] fn autoscroll_inside_viewport() { let area = Rect::new(0, 10, 80, 20); assert!(compute_autoscroll(20, area).is_none()); } #[test] fn autoscroll_near_top_edge() { let area = Rect::new(0, 10, 80, 20); // Row 11 is within EDGE_THRESHOLD (2) of top (10) let result = compute_autoscroll(11, area); assert!(result.is_some()); assert_eq!(result.unwrap().direction, AutoScrollDirection::Up); } #[test] fn autoscroll_near_bottom_edge() { let area = Rect::new(0, 10, 80, 20); // Row 28 is within EDGE_THRESHOLD (2) of bottom (30) let result = compute_autoscroll(28, area); assert!(result.is_some()); assert_eq!(result.unwrap().direction, AutoScrollDirection::Down); } #[test] fn autoscroll_speed_ramp() { // Farther from edge → faster speed assert!(speed_for_distance(1) < speed_for_distance(5)); assert!(speed_for_distance(5) <= speed_for_distance(15)); } #[test] fn block_drag_threshold() { let pending = PendingBlockDrag { anchor_entry_idx: 0, start_col: 5, start_row: 5, }; assert!(!block_drag_threshold_exceeded(&pending, 5, 5)); assert!(block_drag_threshold_exceeded(&pending, 6, 5)); assert!(block_drag_threshold_exceeded(&pending, 5, 6)); } /// Simulate selecting from the last line, then scrolling so the anchor /// scrolls off-screen. The overlay and copy should still work for all /// visible lines in the selection range. #[test] fn selection_survives_anchor_scrolling_off_screen() { // 10 lines, block_line_idx 0..9. Initially all visible. let lines: Vec<&str> = vec![ "line zero", "line one", "line two", "line three", "line four", "line five", "line six", "line seven", "line eight", "line nine", ]; // Build initial model with all 10 lines visible. let mut model = ResolvedSelectionModel::default(); for (i, text) in lines.iter().enumerate() { model.push_line(ResolvedSelectableLine { entry_idx: 0, range_id: 0, block_line_idx: i, screen_y: i as u16, screen_x: 0, selectable_cols: 0..(text.len() as u16), text: text.to_string(), joiner_to_previous: if i > 0 { Some("\n".to_string()) } else { None }, }); } // User clicks on line 9 (last line). let anchor = model.hit_test_selectable_range(0, 9).unwrap(); assert_eq!(anchor.block_line_idx, 9); // User drags to line 5. let head = model.hit_test_selectable_range(0, 5).unwrap(); assert_eq!(head.block_line_idx, 5); let drag = ActiveTextDrag { anchor, head, kind: SelectionKind::Linear, anchor_content_width: None, }; // Selection overlay and copy work normally. // Both anchor (line 9) and head (line 5) are at col 0, so // the start line gets partial selection (from col 0 to end) // and the end line gets partial selection (from 0 to col 0+1). let text = reconstruct_selection_text(&model, &drag).unwrap(); assert!( text.contains("line five"), "head line should be in selection" ); assert!( text.contains("line eight"), "middle lines should be fully selected" ); // Now simulate scroll: viewport shifts so only lines 0-6 are visible. // Lines 7-9 (including anchor at 9) are OFF SCREEN. let mut scrolled_model = ResolvedSelectionModel::default(); for (i, line_text) in lines.iter().enumerate().take(7) { scrolled_model.push_line(ResolvedSelectableLine { entry_idx: 0, range_id: 0, block_line_idx: i, screen_y: i as u16, screen_x: 0, selectable_cols: 0..(line_text.len() as u16), text: line_text.to_string(), joiner_to_previous: if i > 0 { Some("\n".to_string()) } else { None }, }); } // The anchor (block_line_idx=9) is now off-screen. During autoscroll // the resolver misses there and the caller keeps the previous head // (pinned by active_drag_motion_miss_keeps_previous_head). let new_head = head; // head was at block_line_idx=5 let scrolled_drag = ActiveTextDrag { anchor, head: new_head, kind: SelectionKind::Linear, anchor_content_width: None, }; // Overlay should highlight visible lines 5-6 (head=5, anchor=9 off-screen, // so range is 5..=9, visible portion is lines 5 and 6). let mut buf = Buffer::empty(Rect::new(0, 0, 30, 7)); render_active_selection_overlay(&scrolled_model, &scrolled_drag, None, &mut buf); // Copy from the visible-only model produces text for visible lines in range. let text = reconstruct_selection_text(&scrolled_model, &scrolled_drag).unwrap(); assert!(text.contains("line five"), "should contain head line"); assert!( text.contains("line six"), "should contain visible line in range" ); assert!( !text.contains("line zero"), "line before head not in selection" ); // Off-screen lines aren't in the model, so they can't be in the text. assert!(!text.contains("line nine"), "off-screen line not in model"); } // ----------------------------------------------------------------------- // word_boundaries_at_col tests // ----------------------------------------------------------------------- /// Shorthand for tests using the default tmux separator set. fn wb(text: &str, col: u16) -> Range { word_boundaries_at_col(text, col, DEFAULT_WORD_SEPARATORS) } #[test] fn word_boundaries_empty_string() { assert_eq!(wb("", 0), 0..0); } #[test] fn word_boundaries_single_word() { // "hello" → all Word class, col anywhere → 0..5 assert_eq!(wb("hello", 0), 0..5); assert_eq!(wb("hello", 2), 0..5); assert_eq!(wb("hello", 4), 0..5); } #[test] fn word_boundaries_two_words() { // "hello world" → Word(0..5), Whitespace(5..6), Word(6..11) assert_eq!(wb("hello world", 0), 0..5); assert_eq!(wb("hello world", 4), 0..5); assert_eq!(wb("hello world", 5), 5..6); assert_eq!(wb("hello world", 6), 6..11); assert_eq!(wb("hello world", 10), 6..11); } #[test] fn word_boundaries_underscore_joins_words() { // "foo_bar" → all Word class assert_eq!(wb("foo_bar", 0), 0..7); assert_eq!(wb("foo_bar", 3), 0..7); // on '_' assert_eq!(wb("foo_bar", 6), 0..7); } #[test] fn word_boundaries_punctuation_run() { // "a---b" → Word(0..1), Separator(1..4), Word(4..5) assert_eq!(wb("a---b", 0), 0..1); assert_eq!(wb("a---b", 1), 1..4); assert_eq!(wb("a---b", 3), 1..4); assert_eq!(wb("a---b", 4), 4..5); } #[test] fn word_boundaries_whitespace_run() { // "a b" → Word(0..1), Whitespace(1..4), Word(4..5) assert_eq!(wb("a b", 1), 1..4); assert_eq!(wb("a b", 3), 1..4); } #[test] fn word_boundaries_mixed_punct_and_word() { // "hello.world" → Word(0..5), Separator(5..6), Word(6..11) assert_eq!(wb("hello.world", 4), 0..5); assert_eq!(wb("hello.world", 5), 5..6); assert_eq!(wb("hello.world", 6), 6..11); } #[test] fn word_boundaries_line_start_and_end() { // " hello " → Whitespace(0..1), Word(1..6), Whitespace(6..7) assert_eq!(wb(" hello ", 0), 0..1); assert_eq!(wb(" hello ", 1), 1..6); assert_eq!(wb(" hello ", 6), 6..7); } #[test] fn word_boundaries_single_char() { assert_eq!(wb("a", 0), 0..1); assert_eq!(wb(".", 0), 0..1); assert_eq!(wb(" ", 0), 0..1); } #[test] fn word_boundaries_col_beyond_text() { // col past end clamps to last segment assert_eq!(wb("ab", 10), 0..2); assert_eq!(wb("a b", 10), 2..3); } #[test] fn word_boundaries_wide_char_cjk() { // CJK characters are not in DEFAULT_WORD_SEPARATORS and not whitespace, // so they are Word class (matching tmux). Each occupies 2 display cols. // "a\u{754c}b" → all Word class → 0..4 assert_eq!(wb("a\u{754c}b", 0), 0..4); assert_eq!(wb("a\u{754c}b", 1), 0..4); // first col of wide char assert_eq!(wb("a\u{754c}b", 2), 0..4); // second col of wide char assert_eq!(wb("a\u{754c}b", 3), 0..4); } #[test] fn word_boundaries_consecutive_wide_chars() { // Two CJK chars → both Word class, grouped: Word(0..4) assert_eq!(wb("\u{754c}\u{4e16}", 0), 0..4); assert_eq!(wb("\u{754c}\u{4e16}", 2), 0..4); } #[test] fn word_boundaries_combining_mark() { // "e\u{0301}f" → graphemes: "e\u{0301}" (width 1) + "f" (width 1) // Both are Word class (not separators, not whitespace). assert_eq!(wb("e\u{0301}f", 0), 0..2); assert_eq!(wb("e\u{0301}f", 1), 0..2); } #[test] fn word_boundaries_digits_are_word_chars() { // "x86_64" → all Word assert_eq!(wb("x86_64", 0), 0..6); assert_eq!(wb("x86_64", 5), 0..6); } #[test] fn word_boundaries_mixed_separators_same_class_grouped() { // "-=" → both Separator, grouped as consecutive same-class assert_eq!(wb("-=", 0), 0..2); assert_eq!(wb("-=", 1), 0..2); } #[test] fn word_boundaries_tab_is_whitespace() { // Tab has width 1 via UnicodeWidthStr 0.2 and is classified as // Whitespace, so it separates adjacent Word segments: // Word(0..1), Whitespace(1..2), Word(2..3). assert_eq!(wb("a\tb", 0), 0..1); assert_eq!(wb("a\tb", 1), 1..2); assert_eq!(wb("a\tb", 2), 2..3); } #[test] fn word_boundaries_non_ascii_letters_are_word_chars() { // Non-ASCII letters group with adjacent ASCII word chars, // matching tmux behaviour where only ASCII punctuation is a separator. // "caf\u{e9}" → all Word class → 0..4 assert_eq!(wb("caf\u{e9}", 0), 0..4); assert_eq!(wb("caf\u{e9}", 3), 0..4); } #[test] fn word_boundaries_cjk_between_separators() { // Separator, CJK (Word), Separator: // ".\u{754c}." → Separator(0..1), Word(1..3), Separator(3..4) assert_eq!(wb(".\u{754c}.", 0), 0..1); assert_eq!(wb(".\u{754c}.", 1), 1..3); assert_eq!(wb(".\u{754c}.", 3), 3..4); } #[test] fn word_boundaries_separator_chars_from_tmux_defaults() { // Verify all separator characters from DEFAULT_WORD_SEPARATORS are classified correctly. // Each should be its own Separator group when surrounded by Word chars. for sep in [ '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '-', '+', '=', '[', ']', '{', '}', '|', '\\', '/', ':', ';', '\'', '"', ',', '.', '<', '>', '?', '`', '~', ] { let text = format!("a{sep}b"); assert_eq!( wb(&text, 0), 0..1, "char '{sep}' should be a separator (Word before)", ); assert_eq!( wb(&text, 1), 1..2, "char '{sep}' should be its own Separator group", ); assert_eq!( wb(&text, 2), 2..3, "char '{sep}' should be a separator (Word after)", ); } } #[test] fn word_boundaries_underscore_is_not_a_separator() { // Underscore is NOT in DEFAULT_WORD_SEPARATORS (matches tmux). // "a_b" → all Word → 0..3 assert_eq!(wb("a_b", 0), 0..3); assert_eq!(wb("a_b", 1), 0..3); assert_eq!(wb("a_b", 2), 0..3); } #[test] fn word_boundaries_custom_separators_empty() { // Empty separators string → only whitespace breaks words. // "hello-world" with no separators → all Word → 0..11 assert_eq!(word_boundaries_at_col("hello-world", 0, ""), 0..11); assert_eq!(word_boundaries_at_col("hello-world", 5, ""), 0..11); assert_eq!(word_boundaries_at_col("hello-world", 6, ""), 0..11); // "a.b@c" → all Word → 0..5 assert_eq!(word_boundaries_at_col("a.b@c", 2, ""), 0..5); } #[test] fn word_boundaries_custom_separators_subset() { // Only "." and "/" are separators — hyphen and @ are word chars. let seps = "./"; assert_eq!(word_boundaries_at_col("hello-world", 0, seps), 0..11); assert_eq!(word_boundaries_at_col("hello.world", 0, seps), 0..5); assert_eq!(word_boundaries_at_col("hello.world", 5, seps), 5..6); assert_eq!(word_boundaries_at_col("a/b", 0, seps), 0..1); assert_eq!(word_boundaries_at_col("user@host", 0, seps), 0..9); } // ----------------------------------------------------------------------- // strip_trailing_url_punctuation tests // ----------------------------------------------------------------------- #[test] fn strip_trailing_no_punctuation() { assert_eq!( strip_trailing_url_punctuation("https://example.com/path"), "https://example.com/path" ); } #[test] fn strip_trailing_period() { assert_eq!( strip_trailing_url_punctuation("https://example.com."), "https://example.com" ); } #[test] fn strip_trailing_comma() { assert_eq!( strip_trailing_url_punctuation("https://example.com,"), "https://example.com" ); } #[test] fn strip_trailing_multiple_punct() { assert_eq!( strip_trailing_url_punctuation("https://example.com.)"), "https://example.com" ); } #[test] fn strip_trailing_balanced_parens_kept() { assert_eq!( strip_trailing_url_punctuation( "https://en.wikipedia.org/wiki/Rust_(programming_language)" ), "https://en.wikipedia.org/wiki/Rust_(programming_language)" ); } #[test] fn strip_trailing_unbalanced_close_paren() { // "(see https://example.com)" → regex captures "https://example.com)" assert_eq!( strip_trailing_url_punctuation("https://example.com)"), "https://example.com" ); } #[test] fn strip_trailing_balanced_then_unbalanced() { // URL has one ( and two ): the last ) is unbalanced assert_eq!( strip_trailing_url_punctuation("https://example.com/wiki_(test))"), "https://example.com/wiki_(test)" ); } #[test] fn strip_trailing_exclamation() { assert_eq!( strip_trailing_url_punctuation("https://example.com!"), "https://example.com" ); } #[test] fn strip_trailing_semicolon() { assert_eq!( strip_trailing_url_punctuation("https://example.com;"), "https://example.com" ); } #[test] fn strip_trailing_colon() { assert_eq!( strip_trailing_url_punctuation("https://example.com:"), "https://example.com" ); } // ----------------------------------------------------------------------- // url_range_at_col tests // ----------------------------------------------------------------------- #[test] fn url_range_simple_https() { let text = "see https://example.com here"; // "see " = 4 cols, URL = 19 cols → 4..23 assert_eq!(url_range_at_col(text, 4), Some(4..23)); assert_eq!(url_range_at_col(text, 10), Some(4..23)); assert_eq!(url_range_at_col(text, 22), Some(4..23)); } #[test] fn url_range_no_url() { assert_eq!(url_range_at_col("hello world", 0), None); assert_eq!(url_range_at_col("hello world", 5), None); } #[test] fn url_range_empty_string() { assert_eq!(url_range_at_col("", 0), None); } #[test] fn url_range_url_at_start() { let text = "https://example.com rest"; assert_eq!(url_range_at_col(text, 0), Some(0..19)); } #[test] fn url_range_url_at_end() { let text = "visit https://example.com"; assert_eq!(url_range_at_col(text, 6), Some(6..25)); assert_eq!(url_range_at_col(text, 24), Some(6..25)); } #[test] fn url_range_trailing_period_stripped() { let text = "see https://example.com."; // URL match = "https://example.com." → stripped to "https://example.com" assert_eq!(url_range_at_col(text, 4), Some(4..23)); assert_eq!(url_range_at_col(text, 22), Some(4..23)); // Click ON the trailing period → past the stripped range → None assert_eq!(url_range_at_col(text, 23), None); } #[test] fn url_range_trailing_comma_stripped() { let text = "see https://example.com, more"; assert_eq!(url_range_at_col(text, 4), Some(4..23)); // Click on comma → None assert_eq!(url_range_at_col(text, 23), None); } #[test] fn url_range_balanced_parens_preserved() { let text = "https://en.wikipedia.org/wiki/Rust_(programming_language)"; let len = text.len() as u16; assert_eq!(url_range_at_col(text, 0), Some(0..len)); assert_eq!(url_range_at_col(text, len - 1), Some(0..len)); } #[test] fn url_range_unbalanced_paren_in_prose() { let text = "(see https://example.com)"; // Match: "https://example.com)" → stripped to "https://example.com" assert_eq!(url_range_at_col(text, 5), Some(5..24)); // Click on the trailing ')' → None assert_eq!(url_range_at_col(text, 24), None); } #[test] fn url_range_query_string() { let text = "https://example.com/path?q=1&r=2"; assert_eq!(url_range_at_col(text, 0), Some(0..32)); } #[test] fn url_range_fragment() { let text = "https://example.com/page#section"; assert_eq!(url_range_at_col(text, 0), Some(0..32)); } #[test] fn url_range_ftp_scheme() { let text = "get ftp://files.example.com/data"; assert_eq!(url_range_at_col(text, 4), Some(4..32)); } #[test] fn url_range_file_scheme() { let text = "open file:///tmp/readme.txt"; assert_eq!(url_range_at_col(text, 5), Some(5..27)); } #[test] fn url_range_case_insensitive() { let text = "HTTP://EXAMPLE.COM/PATH"; assert_eq!(url_range_at_col(text, 0), Some(0..23)); } #[test] fn url_range_multiple_urls() { let text = "see https://a.com and https://b.com"; // First URL: col 4..17 assert_eq!(url_range_at_col(text, 4), Some(4..17)); // Between URLs: None assert_eq!(url_range_at_col(text, 18), None); // Second URL: col 22..35 assert_eq!(url_range_at_col(text, 22), Some(22..35)); } #[test] fn url_range_col_outside_text() { let text = "https://example.com"; assert_eq!(url_range_at_col(text, 100), None); } #[test] fn url_range_not_a_url_scheme() { // "notascheme://foo" should not match assert_eq!(url_range_at_col("notascheme://foo", 0), None); } #[test] fn url_range_url_with_port() { let text = "http://localhost:8080/api"; assert_eq!(url_range_at_col(text, 0), Some(0..25)); } #[test] fn url_range_degenerate_scheme_only_skipped() { // "https://." → regex matches "https://." → strip produces "https://" // which is scheme-only and should be skipped. assert_eq!(url_range_at_col("see https://.", 4), None); } #[test] fn url_range_combined_with_word_boundaries() { // Simulates the double-click fallback: url_range_at_col → word_boundaries_at_col let text = "click https://example.com/path or this_word"; // On URL → url_range returns the range let url = url_range_at_col(text, 6); assert!(url.is_some()); // On plain word → url_range returns None, word_boundaries takes over let non_url_col = 36; // inside "this_word" assert_eq!(url_range_at_col(text, non_url_col), None); assert_eq!(wb(text, non_url_col), 34..43); } // ----------------------------------------------------------------------- // selected_cols_for_endpoints tests // ----------------------------------------------------------------------- fn make_test_line( block_line_idx: usize, selectable_cols: Range, ) -> ResolvedSelectableLine { ResolvedSelectableLine { entry_idx: 0, range_id: 0, block_line_idx, screen_y: block_line_idx as u16, screen_x: 0, selectable_cols, text: String::new(), joiner_to_previous: None, } } #[test] fn endpoints_single_line_selection() { let line = make_test_line(5, 0..20); assert_eq!(selected_cols_for_endpoints(5, 3, 5, 10, &line), Some(3..11)); } #[test] fn endpoints_single_line_anchor_after_head() { let line = make_test_line(5, 0..20); // head before anchor produces same result (min/max) assert_eq!(selected_cols_for_endpoints(5, 10, 5, 3, &line), Some(3..11)); } #[test] fn endpoints_single_line_same_col() { let line = make_test_line(5, 0..20); assert_eq!(selected_cols_for_endpoints(5, 7, 5, 7, &line), Some(7..8)); } #[test] fn endpoints_multi_line_first_line() { let line = make_test_line(2, 0..15); // anchor=2, head=5: line 2 is first, selects anchor_col..width assert_eq!(selected_cols_for_endpoints(2, 4, 5, 10, &line), Some(4..15)); } #[test] fn endpoints_multi_line_last_line() { let line = make_test_line(5, 0..15); // anchor=2, head=5: line 5 is last, selects 0..head_col+1 assert_eq!(selected_cols_for_endpoints(2, 4, 5, 10, &line), Some(0..11)); } #[test] fn endpoints_multi_line_middle_line() { let line = make_test_line(3, 0..15); // anchor=2, head=5: line 3 is middle, selects 0..width (full line) assert_eq!(selected_cols_for_endpoints(2, 4, 5, 10, &line), Some(0..15)); } #[test] fn endpoints_multi_line_reversed_anchor_after_head() { // anchor=5, head=2 (reversed): first line (2) uses head_col, last line (5) uses anchor_col let first = make_test_line(2, 0..15); assert_eq!( selected_cols_for_endpoints(5, 10, 2, 4, &first), Some(4..15) ); let last = make_test_line(5, 0..15); assert_eq!(selected_cols_for_endpoints(5, 10, 2, 4, &last), Some(0..11)); let middle = make_test_line(3, 0..15); assert_eq!( selected_cols_for_endpoints(5, 10, 2, 4, &middle), Some(0..15) ); } #[test] fn endpoints_line_outside_range_returns_none() { let line = make_test_line(10, 0..20); assert_eq!(selected_cols_for_endpoints(2, 4, 5, 10, &line), None); let before = make_test_line(0, 0..20); assert_eq!(selected_cols_for_endpoints(2, 4, 5, 10, &before), None); } #[test] fn endpoints_width_clamping_single_line() { let line = make_test_line(5, 0..10); // head_col 20 exceeds width 10, clamped to width assert_eq!(selected_cols_for_endpoints(5, 3, 5, 20, &line), Some(3..10)); } #[test] fn endpoints_width_clamping_both_cols_beyond() { let line = make_test_line(5, 0..5); // Both cols 15 and 20 exceed width 5 -> clamped to 5..5 (empty range) assert_eq!(selected_cols_for_endpoints(5, 15, 5, 20, &line), Some(5..5)); } #[test] fn endpoints_width_clamping_first_line() { let line = make_test_line(2, 0..8); // anchor=2 (first line), col=12 exceeds width=8 -> 8..8 assert_eq!(selected_cols_for_endpoints(2, 12, 5, 3, &line), Some(8..8)); } #[test] fn endpoints_width_clamping_last_line() { let line = make_test_line(5, 0..8); // head=5 (last line), col=20 exceeds width=8 -> 0..8 assert_eq!(selected_cols_for_endpoints(2, 3, 5, 20, &line), Some(0..8)); } #[test] fn endpoints_nonzero_selectable_start() { // selectable_cols 5..15 means width = 10 let line = make_test_line(3, 5..15); assert_eq!(selected_cols_for_endpoints(3, 2, 3, 7, &line), Some(2..8)); } #[test] fn endpoints_wrapper_matches_direct_call() { // Verify selected_cols_for_line_by_block_idx produces the same result // as a direct call to selected_cols_for_endpoints with the drag fields. let line = make_test_line(3, 0..20); let drag = ActiveTextDrag { anchor: RangeHit { entry_idx: 0, range_id: 0, block_line_idx: 2, col_within_range: 5, }, head: RangeHit { entry_idx: 0, range_id: 0, block_line_idx: 4, col_within_range: 12, }, kind: SelectionKind::Linear, anchor_content_width: None, }; let via_wrapper = selected_cols_for_line_by_block_idx(&drag, &line); let via_direct = selected_cols_for_endpoints(2, 5, 4, 12, &line); assert_eq!(via_wrapper, via_direct); assert_eq!(via_wrapper, Some(0..20)); } // ----------------------------------------------------------------------- // render_persistent_selection_overlay tests // ----------------------------------------------------------------------- /// Marker foreground: test themes quantize to no color, so the /// highlight takes its reverse-video path (a modifier change). fn paint_marker(buf: &mut Buffer) { for y in buf.area.y..buf.area.y.saturating_add(buf.area.height) { for x in buf.area.x..buf.area.x.saturating_add(buf.area.width) { if let Some(cell) = buf.cell_mut((x, y)) { cell.set_fg(Color::White); } } } } /// Returns true if the cell at `(x, y)` was modified by the overlay /// relative to the baseline buffer. fn cell_was_modified(buf: &Buffer, baseline: &Buffer, x: u16, y: u16) -> bool { let cell = buf.cell((x, y)).unwrap(); let orig = baseline.cell((x, y)).unwrap(); (cell.fg, cell.bg, cell.modifier) != (orig.fg, orig.bg, orig.modifier) } #[test] fn persistent_overlay_renders_multi_line_correctly() { let mut model = ResolvedSelectionModel::default(); for i in 0..3u16 { model.push_line(ResolvedSelectableLine { entry_idx: 0, range_id: 0, block_line_idx: i as usize, screen_y: i, screen_x: 2, selectable_cols: 0..10, text: format!("line {i}"), joiner_to_previous: if i > 0 { Some("\n".into()) } else { None }, }); } // anchor=(line 0, col 3), head=(line 2, col 5) let sel = PersistentTextSelection { entry_idx: 0, range_id: 0, anchor: SelectionEndpoint { block_line_idx: 0, col_within_range: 3, }, head: SelectionEndpoint { block_line_idx: 2, col_within_range: 5, }, origin: SelectionOrigin::Drag, kind: SelectionKind::Linear, }; let mut buf = Buffer::empty(Rect::new(0, 0, 20, 3)); paint_marker(&mut buf); let baseline = buf.clone(); render_persistent_selection_overlay(&model, &sel, None, &mut buf); // Line 0 (first): cols 3..10 → screen_x 2+3=5 through 2+9=11 assert!(!cell_was_modified(&buf, &baseline, 4, 0)); for x in 5..12 { assert!( cell_was_modified(&buf, &baseline, x, 0), "line 0, screen_x={x} should be inverted" ); } assert!(!cell_was_modified(&buf, &baseline, 12, 0)); // Line 1 (middle): cols 0..10 → screen_x 2..12 assert!(!cell_was_modified(&buf, &baseline, 1, 1)); for x in 2..12 { assert!( cell_was_modified(&buf, &baseline, x, 1), "line 1, screen_x={x} should be inverted" ); } assert!(!cell_was_modified(&buf, &baseline, 12, 1)); // Line 2 (last): cols 0..6 → screen_x 2..8 assert!(!cell_was_modified(&buf, &baseline, 1, 2)); for x in 2..8 { assert!( cell_was_modified(&buf, &baseline, x, 2), "line 2, screen_x={x} should be inverted" ); } assert!(!cell_was_modified(&buf, &baseline, 8, 2)); } #[test] fn persistent_overlay_nonexistent_range_is_noop() { let model = ResolvedSelectionModel::default(); let sel = PersistentTextSelection { entry_idx: 99, range_id: 5, anchor: SelectionEndpoint { block_line_idx: 0, col_within_range: 0, }, head: SelectionEndpoint { block_line_idx: 0, col_within_range: 5, }, origin: SelectionOrigin::DoubleClick, kind: SelectionKind::Linear, }; let mut buf = Buffer::empty(Rect::new(0, 0, 20, 3)); paint_marker(&mut buf); let baseline = buf.clone(); render_persistent_selection_overlay(&model, &sel, None, &mut buf); assert_eq!(buf, baseline); } #[test] fn persistent_overlay_single_line_word_selection() { let mut model = ResolvedSelectionModel::default(); model.push_line(ResolvedSelectableLine { entry_idx: 0, range_id: 0, block_line_idx: 0, screen_y: 0, screen_x: 5, selectable_cols: 0..20, text: "hello world foo bar".into(), joiner_to_previous: None, }); // Selecting cols 6..11 ("world") on a line at screen_x=5. let sel = PersistentTextSelection { entry_idx: 0, range_id: 0, anchor: SelectionEndpoint { block_line_idx: 0, col_within_range: 6, }, head: SelectionEndpoint { block_line_idx: 0, col_within_range: 10, }, origin: SelectionOrigin::DoubleClick, kind: SelectionKind::Linear, }; let mut buf = Buffer::empty(Rect::new(0, 0, 30, 1)); paint_marker(&mut buf); let baseline = buf.clone(); render_persistent_selection_overlay(&model, &sel, None, &mut buf); // screen cols: 5+0+6=11 through 5+0+10=15 (cols 6..11 = values 6,7,8,9,10) assert!(!cell_was_modified(&buf, &baseline, 10, 0)); for x in 11..16 { assert!( cell_was_modified(&buf, &baseline, x, 0), "screen_x={x} should be inverted" ); } assert!(!cell_was_modified(&buf, &baseline, 16, 0)); } #[test] fn persistent_and_active_overlay_agree_on_same_endpoints() { // Pin theme to avoid races with parallel tests that call `cache::set`. crate::theme::cache::set(crate::theme::ThemeKind::GrokNight); let mut model = ResolvedSelectionModel::default(); for i in 0..4u16 { model.push_line(ResolvedSelectableLine { entry_idx: 0, range_id: 0, block_line_idx: i as usize, screen_y: i, screen_x: 0, selectable_cols: 0..15, text: format!("line {i} content"), joiner_to_previous: if i > 0 { Some("\n".into()) } else { None }, }); } let drag = ActiveTextDrag { anchor: RangeHit { entry_idx: 0, range_id: 0, block_line_idx: 1, col_within_range: 3, }, head: RangeHit { entry_idx: 0, range_id: 0, block_line_idx: 3, col_within_range: 8, }, kind: SelectionKind::Linear, anchor_content_width: None, }; let sel = PersistentTextSelection { entry_idx: 0, range_id: 0, anchor: SelectionEndpoint { block_line_idx: 1, col_within_range: 3, }, head: SelectionEndpoint { block_line_idx: 3, col_within_range: 8, }, origin: SelectionOrigin::Drag, kind: SelectionKind::Linear, }; // Render both overlays into separate buffers and compare results. let mut buf_active = Buffer::empty(Rect::new(0, 0, 20, 4)); let mut buf_persist = Buffer::empty(Rect::new(0, 0, 20, 4)); render_active_selection_overlay(&model, &drag, None, &mut buf_active); render_persistent_selection_overlay(&model, &sel, None, &mut buf_persist); for y in 0..4u16 { for x in 0..20u16 { let a = buf_active.cell((x, y)).unwrap(); let p = buf_persist.cell((x, y)).unwrap(); assert_eq!((a.fg, a.bg), (p.fg, p.bg), "mismatch at ({x}, {y})"); } } } // hit_test_text_exact tests #[test] fn hit_test_text_exact_returns_hit_on_selectable_column() { let mut model = ResolvedSelectionModel::default(); model.push_line(ResolvedSelectableLine { entry_idx: 0, range_id: 0, block_line_idx: 0, screen_y: 5, screen_x: 4, selectable_cols: 2..10, text: "hello wo".to_string(), joiner_to_previous: None, }); // Column 6 = screen_x(4) + selectable_cols.start(2) = within range. let hit = model.hit_test_text_exact(6, 5).unwrap(); assert_eq!(hit.entry_idx, 0); assert_eq!(hit.range_id, 0); assert_eq!(hit.block_line_idx, 0); assert_eq!(hit.col_within_range, 0); // Column 10 = screen_x(4) + 6 → col_within_range = 4. let hit = model.hit_test_text_exact(10, 5).unwrap(); assert_eq!(hit.col_within_range, 4); } #[test] fn hit_test_text_exact_returns_none_outside_selectable_cols() { let mut model = ResolvedSelectionModel::default(); model.push_line(ResolvedSelectableLine { entry_idx: 0, range_id: 0, block_line_idx: 0, screen_y: 5, screen_x: 4, selectable_cols: 2..6, text: "body".to_string(), joiner_to_previous: None, }); // Click on accent bar (col 4, which is screen_x but before selectable_cols.start). assert!(model.hit_test_text_exact(4, 5).is_none()); assert!(model.hit_test_text_exact(5, 5).is_none()); // Click past end of selectable cols (col 10 = 4 + 6). assert!(model.hit_test_text_exact(10, 5).is_none()); // Wrong row. assert!(model.hit_test_text_exact(7, 4).is_none()); } #[test] fn hit_test_text_exact_vs_nearest_range_comparison() { let mut model = ResolvedSelectionModel::default(); model.push_line(ResolvedSelectableLine { entry_idx: 0, range_id: 0, block_line_idx: 0, screen_y: 3, screen_x: 10, selectable_cols: 2..6, text: "body".to_string(), joiner_to_previous: None, }); // Click at col 11 (within screen_x but before selectable start 12). // Nearest-range should return a hit (clamped), exact should return None. let nearest = model.hit_test_selectable_range(11, 3); assert!(nearest.is_some()); assert!(model.hit_test_text_exact(11, 3).is_none()); // Click at col 12 (selectable start). Both should succeed. assert!(model.hit_test_selectable_range(12, 3).is_some()); assert!(model.hit_test_text_exact(12, 3).is_some()); } #[test] fn hit_test_text_exact_multiple_ranges_same_row() { let mut model = ResolvedSelectionModel::default(); model.push_line(ResolvedSelectableLine { entry_idx: 0, range_id: 0, block_line_idx: 0, screen_y: 2, screen_x: 0, selectable_cols: 0..5, text: "hello".to_string(), joiner_to_previous: None, }); model.push_line(ResolvedSelectableLine { entry_idx: 1, range_id: 1, block_line_idx: 0, screen_y: 2, screen_x: 10, selectable_cols: 0..5, text: "world".to_string(), joiner_to_previous: None, }); // Hit on first range. let hit = model.hit_test_text_exact(2, 2).unwrap(); assert_eq!(hit.entry_idx, 0); assert_eq!(hit.col_within_range, 2); // Gap between ranges — no exact hit. assert!(model.hit_test_text_exact(7, 2).is_none()); // Hit on second range. let hit = model.hit_test_text_exact(12, 2).unwrap(); assert_eq!(hit.entry_idx, 1); assert_eq!(hit.col_within_range, 2); } // ── Table-aware selection ──────────────────────────────────────────── const TABLE_LINES: &[&str] = &[ "┌─────────┬────────┐", "│ Name │ Role │", "├─────────┼────────┤", "│ Alice │ Eng │", "│ Smith │ │", "├─────────┼────────┤", "│ Bob │ Design │", "└─────────┴────────┘", ]; fn table_text_at(i: usize) -> Option { TABLE_LINES.get(i).map(|s| s.to_string()) } fn table_geometry() -> TableGeometry { TableGeometry::detect(table_text_at, 3).expect("grid") } fn table_model() -> ResolvedSelectionModel { let mut model = ResolvedSelectionModel::default(); for (i, text) in TABLE_LINES.iter().enumerate() { model.push_line(ResolvedSelectableLine { entry_idx: 0, range_id: 0, block_line_idx: i, screen_y: i as u16, screen_x: 0, selectable_cols: 0..(text.chars().count() as u16), text: text.to_string(), joiner_to_previous: None, }); } model } fn table_hit(block_line_idx: usize, col_within_range: u16) -> RangeHit { RangeHit { entry_idx: 0, range_id: 0, block_line_idx, col_within_range, } } fn table_drag(anchor: (usize, u16), head: (usize, u16), kind: SelectionKind) -> ActiveTextDrag { ActiveTextDrag { anchor: table_hit(anchor.0, anchor.1), head: table_hit(head.0, head.1), kind, anchor_content_width: None, } } #[test] fn resolve_kind_same_cell_vs_grid_vs_border() { let geom = table_geometry(); let linear = SelectionKind::Linear; // Same cell (Name/Alice) → TableCell, even across its wrapped line. assert_eq!( resolve_table_drag_kind(Some(&geom), &table_hit(3, 3), &table_hit(4, 6), linear), SelectionKind::TableCell ); // Reaching the Role column's content → TableGrid carrying that cell. assert_eq!( resolve_table_drag_kind(Some(&geom), &table_hit(3, 3), &table_hit(3, 14), linear), SelectionKind::TableGrid { anchor: CellRef { row: 1, col: 0 }, head: CellRef { row: 1, col: 1 }, } ); // Head on the bottom border latches back to the anchor cell. assert_eq!( resolve_table_drag_kind(Some(&geom), &table_hit(6, 3), &table_hit(7, 3), linear), SelectionKind::TableCell ); // Grid-line anchors stay Linear (the line-by-line escape hatch for // selecting the rendered table text); whole-table TSV is the // triple-click gesture instead. for anchor_line in [0, 2, 5, 7] { assert_eq!( resolve_table_drag_kind( Some(&geom), &table_hit(anchor_line, 3), &table_hit(3, 3), linear ), SelectionKind::Linear, "border anchor line {anchor_line}" ); } // No geometry → Linear. assert_eq!( resolve_table_drag_kind(None, &table_hit(3, 3), &table_hit(4, 3), linear), SelectionKind::Linear ); } #[test] fn resolve_kind_dead_zone_and_hysteresis() { let geom = table_geometry(); let anchor = table_hit(3, 3); // Name/Alice cell let cell = SelectionKind::TableCell; // Junction column (10), own padding (9), and the neighbor's padding // (11) keep the cell selection — no escalation on a small overshoot. for col in [9, 10, 11] { assert_eq!( resolve_table_drag_kind(Some(&geom), &anchor, &table_hit(3, col), cell), SelectionKind::TableCell, "boundary col {col} must not escalate" ); } // The divider row below keeps the cell selection too. assert_eq!( resolve_table_drag_kind(Some(&geom), &anchor, &table_hit(5, 3), cell), SelectionKind::TableCell ); // Neighbor content escalates; boundary touches then keep the grid // (and its head cell) instead of flickering back. let grid = resolve_table_drag_kind(Some(&geom), &anchor, &table_hit(3, 14), cell); let anchor_cell = CellRef { row: 1, col: 0 }; let head = CellRef { row: 1, col: 1 }; assert_eq!( grid, SelectionKind::TableGrid { anchor: anchor_cell, head } ); assert_eq!( resolve_table_drag_kind(Some(&geom), &anchor, &table_hit(3, 10), grid), SelectionKind::TableGrid { anchor: anchor_cell, head } ); // Returning into the anchor cell's content de-escalates. assert_eq!( resolve_table_drag_kind(Some(&geom), &anchor, &table_hit(3, 5), grid), SelectionKind::TableCell ); } #[test] fn reconstruct_cell_selection_joins_wrapped_fragments() { let geom = table_geometry(); // Whole Name cell of the wrapped row (lines 3-4). let drag = table_drag((3, 1), (4, 9), SelectionKind::TableCell); assert_eq!( reconstruct_table_selection_text(&geom, &drag, table_text_at), Some("Alice Smith".to_string()) ); // Reversed endpoints produce the same text. let drag = table_drag((4, 9), (3, 1), SelectionKind::TableCell); assert_eq!( reconstruct_table_selection_text(&geom, &drag, table_text_at), Some("Alice Smith".to_string()) ); // Partial selection within the cell respects the columns // (cols 2..=4 of "│ Alice" are "Ali"). let drag = table_drag((3, 2), (3, 4), SelectionKind::TableCell); assert_eq!( reconstruct_table_selection_text(&geom, &drag, table_text_at), Some("Ali".to_string()) ); } #[test] fn reconstruct_grid_selection_as_tsv() { let geom = table_geometry(); // Column drag down the Name column: header row through Bob. let drag = table_drag( (1, 3), (6, 3), SelectionKind::TableGrid { anchor: CellRef { row: 0, col: 0 }, head: CellRef { row: 2, col: 0 }, }, ); assert_eq!( reconstruct_table_selection_text(&geom, &drag, table_text_at), Some("Name\nAlice Smith\nBob".to_string()) ); // Rectangle over both columns of the body rows (reversed endpoints). let drag = table_drag( (6, 14), (3, 3), SelectionKind::TableGrid { anchor: CellRef { row: 2, col: 1 }, head: CellRef { row: 1, col: 0 }, }, ); assert_eq!( reconstruct_table_selection_text(&geom, &drag, table_text_at), Some("Alice Smith\tEng\nBob\tDesign".to_string()) ); // Whole-table selection (grid-line anchor): every cell as TSV. let drag = table_drag( (2, 3), (3, 3), SelectionKind::TableGrid { anchor: CellRef { row: 0, col: 0 }, head: CellRef { row: 2, col: 1 }, }, ); assert_eq!( reconstruct_table_selection_text(&geom, &drag, table_text_at), Some("Name\tRole\nAlice Smith\tEng\nBob\tDesign".to_string()) ); // Empty cell lands as an empty TSV field (constructed grid state; // resolution itself would keep this same-cell drag a TableCell). let drag = table_drag( (3, 12), (4, 12), SelectionKind::TableGrid { anchor: CellRef { row: 1, col: 1 }, head: CellRef { row: 1, col: 1 }, }, ); assert_eq!( reconstruct_table_selection_text(&geom, &drag, table_text_at), Some("Eng".to_string()) ); } #[test] fn linear_drag_ignores_table_reconstruction() { let geom = table_geometry(); let drag = table_drag((3, 3), (4, 3), SelectionKind::Linear); assert_eq!( reconstruct_table_selection_text(&geom, &drag, table_text_at), None ); } #[test] fn table_overlay_paints_bands_not_borders() { let geom = table_geometry(); let model = table_model(); let area = Rect::new(0, 0, 25, 8); // Grid selection over both columns of the wrapped row + Bob row. let drag = table_drag( (3, 3), (6, 14), SelectionKind::TableGrid { anchor: CellRef { row: 1, col: 0 }, head: CellRef { row: 2, col: 1 }, }, ); let mut buf = Buffer::empty(area); paint_marker(&mut buf); let baseline = buf.clone(); render_active_selection_overlay(&model, &drag, Some(&geom), &mut buf); // Cell interiors of selected rows are painted... assert!( cell_was_modified(&buf, &baseline, 2, 3), "Name band, Alice row" ); assert!( cell_was_modified(&buf, &baseline, 12, 6), "Role band, Bob row" ); assert!( cell_was_modified(&buf, &baseline, 2, 4), "Name band, wrapped row" ); // ...while padding spaces and all-blank fragments are clipped out... assert!( !cell_was_modified(&buf, &baseline, 1, 3), "padding before Alice" ); assert!( !cell_was_modified(&buf, &baseline, 8, 3), "padding after Alice" ); assert!( !cell_was_modified(&buf, &baseline, 12, 4), "blank Role fragment of the wrapped row" ); // ...and border columns, border rows, and unselected rows are not. for y in 0..8u16 { assert!( !cell_was_modified(&buf, &baseline, 0, y), "left border, y={y}" ); assert!( !cell_was_modified(&buf, &baseline, 10, y), "mid border, y={y}" ); assert!( !cell_was_modified(&buf, &baseline, 19, y), "right border, y={y}" ); } for x in 0..20u16 { assert!( !cell_was_modified(&buf, &baseline, x, 0), "top border, x={x}" ); assert!( !cell_was_modified(&buf, &baseline, x, 1), "header row, x={x}" ); assert!(!cell_was_modified(&buf, &baseline, x, 2), "divider, x={x}"); assert!(!cell_was_modified(&buf, &baseline, x, 5), "divider, x={x}"); assert!( !cell_was_modified(&buf, &baseline, x, 7), "bottom border, x={x}" ); } // A table-shaped selection with no geometry paints nothing. let mut buf2 = Buffer::empty(area); paint_marker(&mut buf2); render_active_selection_overlay(&model, &drag, None, &mut buf2); for y in 0..8u16 { for x in 0..25u16 { assert!(!cell_was_modified(&buf2, &baseline, x, y)); } } } #[test] fn table_cell_overlay_clamps_to_band() { let geom = table_geometry(); let model = table_model(); let area = Rect::new(0, 0, 25, 8); // Whole Name cell on the wrapped row. let drag = table_drag((3, 1), (4, 9), SelectionKind::TableCell); let mut buf = Buffer::empty(area); paint_marker(&mut buf); let baseline = buf.clone(); render_active_selection_overlay(&model, &drag, Some(&geom), &mut buf); assert!( cell_was_modified(&buf, &baseline, 2, 3), "band on fragment 1" ); assert!( cell_was_modified(&buf, &baseline, 2, 4), "band on fragment 2" ); assert!( !cell_was_modified(&buf, &baseline, 0, 3), "left border untouched" ); assert!( !cell_was_modified(&buf, &baseline, 10, 3), "mid border untouched" ); assert!( !cell_was_modified(&buf, &baseline, 12, 3), "other column untouched" ); assert!( !cell_was_modified(&buf, &baseline, 2, 6), "other row untouched" ); // Content clipping: the drag endpoints sit in the padding (cols 1 // and 9) but only "Alice" / "Smith" glyph columns paint. assert!( !cell_was_modified(&buf, &baseline, 1, 3), "leading padding untouched" ); assert!( !cell_was_modified(&buf, &baseline, 7, 4), "trailing padding untouched" ); } #[test] fn clip_cols_to_content_trims_padding_and_blanks() { // 0123456789 let text = "│ Quick Fox │"; // Full band (1..12) clips to the content span, keeping the interior space. assert_eq!(clip_cols_to_content(text, 1..12), 2..11); // A sub-range starting in padding clips its leading edge only. assert_eq!(clip_cols_to_content(text, 1..7), 2..7); // All-blank ranges collapse to empty. assert_eq!(clip_cols_to_content("│ │", 1..9), 1..1); assert_eq!(clip_cols_to_content("", 0..5), 0..0); // Wide glyphs count display columns. assert_eq!(clip_cols_to_content("│ 名前 │", 1..6), 2..6); } }