tastty-core 0.1.0

Sans-IO core of the tastty terminal session library: VT parser, screen buffer, and byte encoders.
//! Selection state attached to a [`crate::Screen`].
//!
//! The screen owns the cell grid, the wrap flags, the grapheme widths,
//! and the scrollback origin -- everything required to give a clean
//! anchor + extend selection model that survives scrolling. Embedders
//! (terminal renderers, automation tools, screen scrapers) call
//! [`Screen::selection_start`], [`Screen::selection_extend`],
//! [`Screen::selection_clear`], [`Screen::selection_range`], and
//! [`Screen::selected_text`] instead of reimplementing the same logic
//! against [`Screen::contents_between`] and friends.

use super::{AbsolutePosition, Screen};
use crate::cell::Cell;
use crate::row::Row;

/// How a selection extends as its cursor end moves.
///
/// Each variant defines a different geometry. All variants share the
/// "anchor + cursor" two-point model: the anchor is set by
/// [`Screen::selection_start`] and stays fixed; the cursor end moves
/// with each [`Screen::selection_extend`] call.
#[non_exhaustive]
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
pub enum SelectionMode {
    /// Character-by-character. The selection is the inclusive range of
    /// cells from anchor to cursor in row-major reading order. Rows
    /// that ended in a soft wrap (the renderer overflowed the
    /// rightmost column rather than emitting a line feed) are joined
    /// continuously into the copied text; rows ended by a hard line
    /// break are joined with `'\n'`. Trailing whitespace is trimmed
    /// per row.
    Linear,
    /// Whole lines from anchor's row to cursor's row, edge to edge.
    /// Identical to [`SelectionMode::Linear`] except the column
    /// endpoints are forced to the row's first and last columns. The
    /// copied text is joined with the same wrap-aware rule as Linear.
    Line,
    /// Word-aware. Both endpoints snap outward to enclose the word
    /// they fall on; otherwise the joined text is built as for
    /// [`SelectionMode::Linear`]. Word boundaries are detected
    /// per-row by a three-class scheme: alphanumeric runs (with
    /// `_`) form a word, whitespace is its own class, and any other
    /// character (punctuation, symbols) is its own class. A click
    /// on a punctuation cell selects only the contiguous run of
    /// punctuation, matching xterm's "double-click on `+`" behaviour.
    Word,
    /// Rectangular. The selection is the rectangle whose opposite
    /// corners are anchor and cursor. Rows are joined with `'\n'`
    /// (no wrap-awareness, since a rectangular slice across a wrap
    /// boundary is rarely what the user intended). When the
    /// rectangle's left edge falls on the trailing half of a wide
    /// cell, the column is moved one cell left to include the wide
    /// head: a block selection should never start mid-glyph.
    Block,
}

/// A normalized, immutable view of an active selection.
///
/// Returned by [`Screen::selection_range`]. `start` is guaranteed to
/// precede `end` in the order defined by the selection mode (row-major
/// for Linear and Line). The struct is `#[non_exhaustive]` so future
/// fields (mode-specific metadata) can be added without breaking
/// match arms.
#[non_exhaustive]
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
pub struct SelectionRange {
    /// First selected cell in reading order.
    pub start: AbsolutePosition,
    /// Last selected cell in reading order. Inclusive.
    pub end: AbsolutePosition,
    /// The geometry the selection was started with. Preserved so
    /// renderers can shade Block selections differently from Linear.
    pub mode: SelectionMode,
}

/// Mutable selection state stored on [`Screen`].
///
/// Anchor and cursor are stored in the order the API received them
/// (anchor from [`Screen::selection_start`], cursor from the most
/// recent [`Screen::selection_extend`]). Normalization to row-major
/// order happens in [`SelectionState::range`] so the unnormalized
/// state is preserved for any future "side"-aware extensions.
#[derive(Copy, Clone, Debug)]
pub(super) struct SelectionState {
    pub(super) anchor: AbsolutePosition,
    pub(super) cursor: AbsolutePosition,
    pub(super) mode: SelectionMode,
}

impl SelectionState {
    pub(super) fn new(anchor: AbsolutePosition, mode: SelectionMode) -> Self {
        Self {
            anchor,
            cursor: anchor,
            mode,
        }
    }

    pub(super) fn extend(&mut self, to: AbsolutePosition) {
        self.cursor = to;
    }

    pub(super) fn range(&self) -> SelectionRange {
        let (start, end) =
            if (self.anchor.row, self.anchor.col) <= (self.cursor.row, self.cursor.col) {
                (self.anchor, self.cursor)
            } else {
                (self.cursor, self.anchor)
            };
        SelectionRange {
            start,
            end,
            mode: self.mode,
        }
    }
}

/// Compute the selected text for the active selection.
///
/// Pulled out of [`Screen`] so the selection rendering logic lives
/// next to the type definitions. Returns `None` if any row in the
/// selection range has aged out of scrollback (because the
/// `AbsolutePosition` stored in the selection no longer maps to a
/// live row), so callers can distinguish "no selection" from
/// "selection partially evicted".
pub(super) fn selected_text(screen: &Screen, state: &SelectionState) -> Option<String> {
    let range = state.range();
    let cols = screen.size().cols;
    if cols == 0 {
        return Some(String::new());
    }
    let last_col = cols - 1;
    match range.mode {
        SelectionMode::Linear => render_linear(screen, range.start, range.end, last_col),
        SelectionMode::Line => render_linear(
            screen,
            AbsolutePosition {
                row: range.start.row,
                col: 0,
            },
            AbsolutePosition {
                row: range.end.row,
                col: last_col,
            },
            last_col,
        ),
        SelectionMode::Word => {
            let (start, end) = snap_word_endpoints(screen, range.start, range.end, last_col)?;
            render_linear(screen, start, end, last_col)
        }
        SelectionMode::Block => render_block(screen, range.start, range.end),
    }
}

fn render_linear(
    screen: &Screen,
    start: AbsolutePosition,
    end: AbsolutePosition,
    last_col: u16,
) -> Option<String> {
    let mut result = String::new();
    let mut prev_wrapped = false;
    for abs_row in start.row..=end.row {
        let row = screen.grid.row_at_absolute(abs_row)?;
        let col_start = if abs_row == start.row { start.col } else { 0 };
        let col_end = if abs_row == end.row {
            end.col
        } else {
            last_col
        };
        if abs_row > start.row && !prev_wrapped {
            result.push('\n');
        }
        let mut row_text = String::new();
        let mut col = col_start;
        while col <= col_end {
            if let Some(cell) = row.get(col) {
                if cell.is_wide_continuation() {
                    col += 1;
                    continue;
                }
                let c = cell.contents();
                if c.is_empty() {
                    row_text.push(' ');
                } else {
                    row_text.push_str(c);
                }
            }
            col += 1;
        }
        result.push_str(row_text.trim_end());
        prev_wrapped = row.wrapped();
    }
    Some(result)
}

fn render_block(screen: &Screen, start: AbsolutePosition, end: AbsolutePosition) -> Option<String> {
    let cols = screen.size().cols;
    if cols == 0 {
        return Some(String::new());
    }
    let max_col_in_grid = cols - 1;
    let raw_min = start.col.min(end.col);
    let max_col = start.col.max(end.col).min(max_col_in_grid);
    let mut result = String::new();
    for abs_row in start.row..=end.row {
        let row = screen.grid.row_at_absolute(abs_row)?;
        if abs_row > start.row {
            result.push('\n');
        }
        // If the rectangle's left edge falls on the trailing half of
        // a wide cell, back up one column so the wide head is
        // included. Decided per row because some rows may have a
        // wide char at the boundary while others do not.
        let min_col = if raw_min > 0
            && row
                .get(raw_min)
                .map(Cell::is_wide_continuation)
                .unwrap_or(false)
        {
            raw_min - 1
        } else {
            raw_min
        };
        let mut row_text = String::new();
        let mut col = min_col;
        while col <= max_col {
            if let Some(cell) = row.get(col) {
                if cell.is_wide_continuation() {
                    col += 1;
                    continue;
                }
                let c = cell.contents();
                if c.is_empty() {
                    row_text.push(' ');
                } else {
                    row_text.push_str(c);
                }
            }
            col += 1;
        }
        result.push_str(row_text.trim_end());
    }
    Some(result)
}

#[derive(Copy, Clone, Eq, PartialEq, Debug)]
enum WordClass {
    /// Empty cells and whitespace characters. Words and other runs
    /// stop at this class.
    Whitespace,
    /// Alphanumeric characters and `_`. Identifiers, numbers,
    /// camelCase fragments.
    Word,
    /// Anything else (punctuation, symbols, control glyphs).
    /// Selected as its own contiguous run, matching xterm's
    /// double-click-on-`+` behaviour.
    Other,
}

fn cell_word_class(cell: &Cell) -> WordClass {
    let s = cell.contents();
    let Some(c) = s.chars().next() else {
        return WordClass::Whitespace;
    };
    if c.is_whitespace() {
        return WordClass::Whitespace;
    }
    if c.is_alphanumeric() || c == '_' {
        return WordClass::Word;
    }
    WordClass::Other
}

fn class_at(row: &Row, col: u16) -> WordClass {
    let Some(cell) = row.get(col) else {
        return WordClass::Whitespace;
    };
    if cell.is_wide_continuation() && col > 0 {
        return class_at(row, col - 1);
    }
    cell_word_class(cell)
}

fn snap_word_endpoints(
    screen: &Screen,
    start: AbsolutePosition,
    end: AbsolutePosition,
    last_col: u16,
) -> Option<(AbsolutePosition, AbsolutePosition)> {
    let start_row = screen.grid.row_at_absolute(start.row)?;
    let end_row = screen.grid.row_at_absolute(end.row)?;
    let snapped_start_col = snap_left(start_row, start.col);
    let snapped_end_col = snap_right(end_row, end.col, last_col);
    Some((
        AbsolutePosition {
            row: start.row,
            col: snapped_start_col,
        },
        AbsolutePosition {
            row: end.row,
            col: snapped_end_col,
        },
    ))
}

fn snap_left(row: &Row, col: u16) -> u16 {
    let target = class_at(row, col);
    let mut cur = col;
    while cur > 0 {
        let next = cur - 1;
        if class_at(row, next) != target {
            break;
        }
        cur = next;
    }
    // If we landed on the trail half of a wide cell, step left one
    // more so the selection starts at the wide head.
    if let Some(cell) = row.get(cur)
        && cell.is_wide_continuation()
        && cur > 0
    {
        cur -= 1;
    }
    cur
}

fn snap_right(row: &Row, col: u16, last_col: u16) -> u16 {
    let target = class_at(row, col);
    let mut cur = col;
    while cur < last_col {
        let next = cur + 1;
        if class_at(row, next) != target {
            break;
        }
        cur = next;
    }
    // If we landed on the wide head, extend over the trailing
    // continuation cell so the wide character is whole.
    if let Some(cell) = row.get(cur)
        && cell.is_wide()
        && cur < last_col
    {
        cur += 1;
    }
    cur
}