Files
Kigi-CLI/crates/codegen/kigi-tui/src/scrollback/text_selection.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

3107 lines
106 KiB
Rust

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<DragAutoScrollState> {
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<ResolvedSelectableRange>,
pub visible_blocks: Vec<VisibleBlockGeometry>,
pub content_area: Rect,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedSelectionBoundary {
entry_idx: usize,
range_id: u16,
block_line_idx: usize,
boundary: Arc<SelectionBoundary>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ResolvedSelectionBoundaries(Vec<ResolvedSelectionBoundary>);
impl ResolvedSelectionBoundaries {
pub(crate) fn push(&mut self, line: &ResolvedSelectableLine, boundary: Arc<SelectionBoundary>) {
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<ResolvedSelectableLine>,
}
#[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<u16>,
pub text: String,
pub joiner_to_previous: Option<String>,
}
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<u16>,
}
#[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<u16>,
}
#[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<RangeHit> {
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<RangeHit> {
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<u16> {
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<RangeHit> {
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<String> {
reconstruct_selection_text_with_boundaries(model, &ResolvedSelectionBoundaries::default(), drag)
}
pub(crate) fn reconstruct_selection_text_with_boundaries(
model: &ResolvedSelectionModel,
boundaries: &ResolvedSelectionBoundaries,
drag: &ActiveTextDrag,
) -> Option<String> {
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<Range<u16>> = 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<u16>) -> Range<u16> {
let mut start: Option<u16> = 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<Range<u16>> {
// 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<u16> = 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<String>,
) -> Option<String> {
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<String> {
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<Range<u16>> {
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<Range<u16>> {
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<String> {
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<String> {
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<u16>) -> 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<String> = 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<u16> {
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<Regex> = 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<Range<u16>> {
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<u16>,
) -> 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<u16> {
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<u16>,
) -> 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<String> {
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);
}
}