panasyn 0.1.0

A lightweight GPU-accelerated terminal emulator for macOS and Linux.
/// Tracks text selection state for copy/paste.
#[derive(Debug, Clone, PartialEq)]
pub struct Selection {
    pub active: bool,
    pub start: (usize, usize), // (row, col)
    pub end: (usize, usize),   // (row, col)
}

impl Selection {
    pub fn new() -> Self {
        Self {
            active: false,
            start: (0, 0),
            end: (0, 0),
        }
    }

    /// Begin selecting at a cell position.
    pub fn start_at(&mut self, pos: (usize, usize)) {
        self.active = true;
        self.start = pos;
        self.end = pos;
    }

    /// Extend selection to a new cell position.
    pub fn extend_to(&mut self, pos: (usize, usize)) {
        if self.active {
            self.end = pos;
        }
    }

    /// Clear selection.
    pub fn clear(&mut self) {
        self.active = false;
    }

    pub fn is_empty(&self) -> bool {
        !self.active || self.start == self.end
    }

    /// Set from drag start/end.
    pub fn set_span(&mut self, start: (usize, usize), end: (usize, usize)) {
        self.active = true;
        self.start = start;
        self.end = end;
    }

    /// Normalised (start ≤ end) selection bounds.
    pub fn bounds(&self) -> ((usize, usize), (usize, usize)) {
        let (r1, c1) = self.start;
        let (r2, c2) = self.end;
        if r1 < r2 || (r1 == r2 && c1 <= c2) {
            (self.start, self.end)
        } else {
            (self.end, self.start)
        }
    }

    /// Whether the given cell is inside the selection.
    pub fn contains(&self, row: usize, col: usize) -> bool {
        if !self.active {
            return false;
        }
        let (start, end) = self.bounds();
        let (r1, c1) = start;
        let (r2, c2) = end;

        if row < r1 || row > r2 {
            return false;
        }
        if row == r1 && col < c1 {
            return false;
        }
        if row == r2 && col > c2 {
            return false;
        }
        true
    }

    /// Whether an entire row is fully selected (for highlight optimisation).
    pub fn row_fully_selected(&self, row: usize) -> bool {
        if !self.active {
            return false;
        }
        let (start, end) = self.bounds();
        row > start.0 && row < end.0
    }

    /// Return the byte-span offsets of selection within a given row's text.
    /// Returns `None` if the row has no selection.
    pub fn span_in_row(&self, row: usize, row_len: usize) -> Option<(usize, usize)> {
        if !self.active {
            return None;
        }
        let (start, end) = self.bounds();
        let (r1, c1) = start;
        let (r2, c2) = end;

        if row < r1 || row > r2 {
            return None;
        }
        if row == r1 && row == r2 {
            // Single-row selection
            if c1 < row_len {
                Some((c1, (c2 + 1).min(row_len)))
            } else {
                None
            }
        } else if row == r1 {
            // First row of multi-row selection
            if c1 < row_len {
                Some((c1, row_len))
            } else {
                None
            }
        } else if row == r2 {
            // Last row of multi-row selection
            Some((0, (c2 + 1).min(row_len)))
        } else {
            // Fully selected middle row
            Some((0, row_len))
        }
    }

    /// Extract selected text from a row-based text provider.
    /// Safe for multi-byte UTF-8 (converts char indices to byte indices).
    pub fn extract_text<F>(&self, row_text: F) -> String
    where
        F: Fn(usize) -> Option<String>,
    {
        if !self.active {
            return String::new();
        }
        let (start, end) = self.bounds();
        let (r1, c1) = start;
        let (r2, c2) = end;

        let mut result = String::new();
        for row in r1..=r2 {
            if let Some(text) = row_text(row) {
                let chars: Vec<usize> = text.char_indices().map(|(i, _)| i).collect();
                let byte_end = text.len();
                let c1b = *chars
                    .get(c1)
                    .unwrap_or(&(chars.last().copied().unwrap_or(0)));
                let c2b = if c2 + 1 < chars.len() {
                    chars[c2 + 1]
                } else {
                    byte_end
                };
                let c1b_to_end = *chars.get(c1).unwrap_or(&byte_end);

                if row == r1 && row == r2 {
                    result.push_str(&text[c1b..c2b]);
                } else if row == r1 {
                    result.push_str(&text[c1b_to_end..]);
                    result.push('\n');
                } else if row == r2 {
                    result.push_str(&text[..c2b]);
                } else {
                    result.push_str(&text);
                    result.push('\n');
                }
            }
        }
        result
    }

    /// Extract selected text from row text plus a cell-column to byte-offset map.
    ///
    /// This keeps wide cells and multi-scalar grapheme clusters intact while
    /// trimming padding spaces when a normal stream selection reaches the end
    /// of a terminal row.
    pub fn extract_text_by_cell<F>(&self, row_text: F) -> String
    where
        F: Fn(usize) -> Option<(String, Vec<usize>)>,
    {
        if !self.active {
            return String::new();
        }
        let (start, end) = self.bounds();
        let (r1, c1) = start;
        let (r2, c2) = end;
        let mut result = String::new();

        for row in r1..=r2 {
            let Some((text, offsets)) = row_text(row) else {
                continue;
            };
            let row_len = offsets.len().saturating_sub(1);
            let start_col = if row == r1 { c1 } else { 0 }.min(row_len);
            let end_col = if row == r2 {
                c2.saturating_add(1)
            } else {
                row_len
            }
            .min(row_len);
            let start_col = expand_start_over_continuation(&offsets, start_col, row_len);
            let start_byte = byte_for_cell_col(&offsets, start_col, text.len());
            let end_byte = byte_for_cell_col(&offsets, end_col, text.len()).max(start_byte);
            let segment = text[start_byte..end_byte].trim_end_matches(' ');
            result.push_str(segment);
            if row != r2 {
                result.push('\n');
            }
        }
        result
    }
}

fn byte_for_cell_col(offsets: &[usize], col: usize, text_len: usize) -> usize {
    offsets.get(col).copied().unwrap_or(text_len).min(text_len)
}

fn expand_start_over_continuation(offsets: &[usize], mut col: usize, row_len: usize) -> usize {
    col = col.min(row_len);
    while col > 0 && col < row_len && offsets.get(col).copied() == offsets.get(col + 1).copied() {
        col -= 1;
    }
    col
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn extract_text_by_cell_keeps_grapheme_cluster_intact() {
        let mut selection = Selection::new();
        selection.set_span((0, 0), (0, 1));
        let text = "👍🏽x  ".to_string();
        let offsets = vec![
            0,
            "👍🏽".len(),
            "👍🏽".len(),
            "👍🏽".len(),
            "👍🏽".len(),
            "👍🏽x".len(),
            "👍🏽x ".len(),
            text.len(),
        ];

        let copied = selection.extract_text_by_cell(|_| Some((text.clone(), offsets.clone())));

        assert_eq!(copied, "👍🏽");
    }

    #[test]
    fn extract_text_by_cell_trims_padding_at_row_end() {
        let mut selection = Selection::new();
        selection.set_span((0, 0), (0, 9));
        let text = "hello     ".to_string();
        let offsets: Vec<usize> = (0..=text.len()).collect();

        let copied = selection.extract_text_by_cell(|_| Some((text.clone(), offsets.clone())));

        assert_eq!(copied, "hello");
    }

    #[test]
    fn extract_text_by_cell_trims_selected_trailing_padding() {
        let mut selection = Selection::new();
        selection.set_span((0, 0), (0, 7));
        let text = "hello     ".to_string();
        let offsets: Vec<usize> = (0..=text.len()).collect();

        let copied = selection.extract_text_by_cell(|_| Some((text.clone(), offsets.clone())));

        assert_eq!(copied, "hello");
    }

    #[test]
    fn extract_text_by_cell_expands_start_from_wide_continuation() {
        let mut selection = Selection::new();
        selection.set_span((0, 1), (0, 1));
        let text = "好x".to_string();
        let offsets = vec![0, "".len(), "".len(), text.len()];

        let copied = selection.extract_text_by_cell(|_| Some((text.clone(), offsets.clone())));

        assert_eq!(copied, "");
    }

    #[test]
    fn extract_text_by_cell_keeps_next_cell_after_wide_cluster_precise() {
        let mut selection = Selection::new();
        selection.set_span((0, 2), (0, 2));
        let text = "好x".to_string();
        let offsets = vec![0, "".len(), "".len(), text.len()];

        let copied = selection.extract_text_by_cell(|_| Some((text.clone(), offsets.clone())));

        assert_eq!(copied, "x");
    }

    #[test]
    fn extract_text_by_cell_copies_mixed_emoji_and_cjk_without_padding() {
        let mut selection = Selection::new();
        selection.set_span((0, 14), (0, 22));
        let text = "unicode: café 👍🏽 你好".to_string();
        let emoji = "👍🏽";
        let ni = "";
        let hao = "";
        let prefix = "unicode: café ";
        let offsets = vec![
            0,
            1,
            2,
            3,
            4,
            5,
            6,
            7,
            8,
            9,
            10,
            11,
            12,
            13,
            prefix.len(),
            prefix.len() + emoji.len(),
            prefix.len() + emoji.len(),
            prefix.len() + emoji.len(),
            prefix.len() + emoji.len(),
            prefix.len() + emoji.len() + 1,
            prefix.len() + emoji.len() + 1 + ni.len(),
            prefix.len() + emoji.len() + 1 + ni.len(),
            prefix.len() + emoji.len() + 1 + ni.len() + hao.len(),
            text.len(),
        ];

        let copied = selection.extract_text_by_cell(|_| Some((text.clone(), offsets.clone())));

        assert_eq!(copied, "👍🏽 你好");
    }
}