tact 0.1.1

Terminal interface for Nanocodex
//! Shared mouse selection over rendered component surfaces.

use ratatui::{
    buffer::Buffer,
    layout::{Position, Rect},
    style::{Color, Style},
};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum Surface {
    Transcript,
    Composer,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Point {
    surface: Surface,
    position: Position,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Range {
    anchor: Point,
    head: Point,
}

#[derive(Default)]
pub(super) struct Selection {
    pending: Option<Point>,
    range: Option<Range>,
    snapshot: Option<Snapshot>,
}

struct Snapshot {
    area: Rect,
    rows: Vec<Vec<String>>,
}

impl Selection {
    pub(super) fn begin(&mut self, surface: Surface, position: Position) {
        let point = Point { surface, position };
        self.pending = Some(point);
        self.range = None;
        self.snapshot = None;
    }

    pub(super) fn drag(&mut self, position: Position, area: Rect) -> bool {
        let Some(anchor) = self
            .pending
            .or_else(|| self.range.map(|range| range.anchor))
        else {
            return false;
        };
        let head = Point {
            surface: anchor.surface,
            position: clamp(position, area),
        };
        self.range = Some(Range { anchor, head });
        true
    }

    pub(super) fn finish(&mut self, position: Position, area: Rect) -> bool {
        let Some(anchor) = self
            .pending
            .or_else(|| self.range.map(|range| range.anchor))
        else {
            return false;
        };
        let head = Point {
            surface: anchor.surface,
            position: clamp(position, area),
        };
        if self.range.is_none() && head == anchor {
            self.cancel_pending();
            return false;
        }
        self.range = Some(Range { anchor, head });
        self.pending = None;
        true
    }

    fn cancel_pending(&mut self) {
        self.pending = None;
        if self.range.is_none() {
            self.snapshot = None;
        }
    }

    pub(super) fn clear(&mut self) -> bool {
        let changed = self.pending.take().is_some() || self.range.take().is_some();
        self.snapshot = None;
        changed
    }

    pub(super) fn is_pending(&self) -> bool {
        self.pending.is_some()
    }

    pub(super) fn is_active(&self) -> bool {
        self.range.is_some()
    }

    pub(super) fn surface(&self) -> Option<Surface> {
        self.pending
            .map(|point| point.surface)
            .or_else(|| self.range.map(|range| range.anchor.surface))
    }

    pub(super) fn capture_and_render(&mut self, buffer: &mut Buffer, area: Rect) {
        if self.pending.is_none() && self.range.is_none() {
            return;
        }

        self.snapshot = Some(Snapshot::capture(buffer, area));
        let Some(range) = self.range else {
            return;
        };
        for position in positions(range, area) {
            if let Some(cell) = buffer.cell_mut(position) {
                cell.set_style(Style::reset().fg(Color::Black).bg(Color::Yellow));
            }
        }
    }

    pub(super) fn take_text(&mut self) -> Option<String> {
        let range = self.range.take()?;
        self.pending = None;
        let snapshot = self.snapshot.take()?;
        let text = snapshot.text(range);
        (!text.is_empty()).then_some(text)
    }
}

impl Snapshot {
    fn capture(buffer: &Buffer, area: Rect) -> Self {
        let rows = (area.y..area.bottom())
            .map(|y| capture_row(buffer, area, y))
            .collect();
        Self { area, rows }
    }

    fn text(&self, range: Range) -> String {
        let mut lines = Vec::new();
        for row in selected_rows(range, self.area) {
            let y = usize::from(row.y.saturating_sub(self.area.y));
            let start = usize::from(row.start.saturating_sub(self.area.x));
            let end = usize::from(row.end.saturating_sub(self.area.x));
            let Some(cells) = self.rows.get(y).and_then(|line| line.get(start..=end)) else {
                continue;
            };
            let line = cells.concat();
            lines.push(line.trim_end().to_owned());
        }
        lines.join("\n")
    }
}

fn capture_row(buffer: &Buffer, area: Rect, y: u16) -> Vec<String> {
    let mut continuation_cells = 0;
    (area.x..area.right())
        .map(|x| {
            if continuation_cells > 0 {
                continuation_cells -= 1;
                return String::new();
            }
            let symbol = buffer[(x, y)].symbol();
            continuation_cells = unicode_width::UnicodeWidthStr::width(symbol).saturating_sub(1);
            symbol.to_owned()
        })
        .collect()
}

#[derive(Clone, Copy)]
struct SelectedRow {
    y: u16,
    start: u16,
    end: u16,
}

fn positions(range: Range, area: Rect) -> impl Iterator<Item = Position> {
    selected_rows(range, area)
        .flat_map(|row| (row.start..=row.end).map(move |x| Position::new(x, row.y)))
}

fn selected_rows(range: Range, area: Rect) -> impl Iterator<Item = SelectedRow> {
    let (start, end) = ordered(range.anchor.position, range.head.position);
    (start.y..=end.y).map(move |y| SelectedRow {
        y,
        start: if y == start.y { start.x } else { area.x },
        end: if y == end.y {
            end.x
        } else {
            area.right().saturating_sub(1)
        },
    })
}

fn ordered(left: Position, right: Position) -> (Position, Position) {
    if (left.y, left.x) <= (right.y, right.x) {
        (left, right)
    } else {
        (right, left)
    }
}

fn clamp(position: Position, area: Rect) -> Position {
    Position::new(
        position.x.clamp(area.x, area.right().saturating_sub(1)),
        position.y.clamp(area.y, area.bottom().saturating_sub(1)),
    )
}

#[cfg(test)]
mod tests {
    use super::{Selection, Surface};
    use ratatui::{
        buffer::Buffer,
        layout::{Position, Rect},
        style::{Color, Modifier, Style},
    };

    #[test]
    fn selection_extracts_forward_and_reverse_visual_ranges() {
        for (anchor, head) in [
            (Position::new(1, 0), Position::new(2, 1)),
            (Position::new(2, 1), Position::new(1, 0)),
        ] {
            let area = Rect::new(0, 0, 5, 2);
            let mut buffer = Buffer::empty(area);
            buffer.set_string(0, 0, "alpha", Style::default());
            buffer.set_string(0, 1, "beta ", Style::default());
            let mut selection = Selection::default();
            selection.begin(Surface::Transcript, anchor);
            selection.drag(head, area);
            selection.capture_and_render(&mut buffer, area);

            assert_eq!(selection.take_text().as_deref(), Some("lpha\nbet"));
        }
    }

    #[test]
    fn pending_click_does_not_create_copyable_text() {
        let mut selection = Selection::default();
        selection.begin(Surface::Composer, Position::new(1, 1));

        assert!(selection.is_pending());
        assert!(!selection.finish(Position::new(1, 1), Rect::new(0, 0, 3, 3)));
        assert!(selection.take_text().is_none());
    }

    #[test]
    fn displaced_release_finishes_without_a_drag_event() {
        let area = Rect::new(0, 0, 7, 1);
        let mut buffer = Buffer::empty(area);
        buffer.set_string(0, 0, "copy me", Style::default());
        let mut selection = Selection::default();
        selection.begin(Surface::Composer, Position::new(0, 0));
        selection.capture_and_render(&mut buffer, area);

        assert!(selection.finish(Position::new(6, 0), area));
        assert_eq!(selection.take_text().as_deref(), Some("copy me"));
    }

    #[test]
    fn wide_graphemes_do_not_add_continuation_spaces() {
        let area = Rect::new(0, 0, 4, 1);
        let mut buffer = Buffer::empty(area);
        buffer.set_string(0, 0, "a界b", Style::default());
        let mut selection = Selection::default();
        selection.begin(Surface::Composer, Position::new(0, 0));
        selection.drag(Position::new(3, 0), area);
        selection.capture_and_render(&mut buffer, area);

        assert_eq!(selection.take_text().as_deref(), Some("a界b"));
    }

    #[test]
    fn selection_is_always_black_on_yellow() {
        let area = Rect::new(0, 0, 4, 1);
        let mut buffer = Buffer::empty(area);
        buffer.set_string(
            0,
            0,
            "text",
            Style::default()
                .fg(Color::Red)
                .bg(Color::Blue)
                .add_modifier(Modifier::BOLD),
        );
        let mut selection = Selection::default();
        selection.begin(Surface::Transcript, Position::new(1, 0));
        selection.drag(Position::new(2, 0), area);

        selection.capture_and_render(&mut buffer, area);

        for x in 1..=2 {
            assert_eq!(buffer[(x, 0)].fg, Color::Black);
            assert_eq!(buffer[(x, 0)].bg, Color::Yellow);
            assert!(buffer[(x, 0)].modifier.is_empty());
        }
        assert_eq!(buffer[(0, 0)].fg, Color::Red);
        assert_eq!(buffer[(0, 0)].bg, Color::Blue);
    }
}