selection.rs raw

//! Selecting text across a sequence of paragraphs.
//!
//! A selection is not a property of one paragraph. A reader who drags from the
//! middle of one paragraph into the next means the text between, and a widget
//! that can only highlight within a single layout cannot express that. So the
//! model here is stated over a *sequence* of laid-out paragraphs — a
//! [`ProseRun`] each — and a widget with one paragraph is the degenerate case
//! of a widget with many. That is why [`Text`] and a document view share this
//! file rather than each carrying its own idea of what a selection is.
//!
//! # Byte offsets, not cursors
//!
//! A position here is a paragraph index and a byte offset ([`ProsePoint`]),
//! and the parley [`Cursor`]s a highlight needs are derived from it at the
//! moment they are needed. Cursors cache positions within a layout, so a
//! stored one has to be refreshed every time the text re-wraps; a byte offset
//! means the same character at every width. Re-wrapping therefore costs the
//! selection nothing, and only a change to the *characters* invalidates it —
//! which is a thing the widget already knows about, because it is the one
//! making the change.
//!
//! [`Text`]: crate::widget::Text

use std::ops::Range;

use guiduck_scene::Fragment;
use guiduck_scene::geom::{Point, Rect};
use guiduck_scene::paint::Brush;
use parley::editing::{Cursor, Selection as ParleySelection};
use parley::{Affinity, Layout};

use super::Paragraph;
use crate::clipboard::{Clipboard, Selection as ClipboardSelection};
use crate::event::{EventKind, Key, KeyInput, PointerButton, PointerEvent};

/// One paragraph a selection may run through: the laid-out text, where it sits
/// in the widget's coordinates, and what separates it from the paragraph
/// before it when copied text spans both.
///
/// The separator belongs to the run rather than to the selection because only
/// the widget knows what its paragraphs are to each other: a blank line
/// between two blocks of prose, a tab between two cells of the same table row.
#[derive(Clone, Copy)]
pub struct ProseRun<'a> {
    paragraph: &'a Paragraph,
    origin: Point,
    lead: &'a str,
}

impl<'a> ProseRun<'a> {
    /// A run at `origin`, joined to the run before it by a blank line - the
    /// separation between two paragraphs of prose.
    pub fn new(paragraph: &'a Paragraph, origin: Point) -> Self {
        ProseRun {
            paragraph,
            origin,
            lead: "\n\n",
        }
    }

    /// Join this run to the one before it with `lead` instead of a blank line.
    pub fn led_by(mut self, lead: &'a str) -> Self {
        self.lead = lead;
        self
    }

    pub fn text(&self) -> &'a str {
        self.paragraph.text()
    }

    /// What joins this run to the one before it in copied or read-out text.
    pub fn lead(&self) -> &'a str {
        self.lead
    }

    fn layout(&self) -> Option<&'a Layout<Brush>> {
        self.paragraph.layout()
    }

    /// The run's box in the widget's coordinates, if it has been laid out.
    fn bounds(&self) -> Option<Rect> {
        let layout = self.layout()?;
        Some(Rect::new(
            self.origin.x,
            self.origin.y,
            self.origin.x + f64::from(layout.width()),
            self.origin.y + f64::from(layout.height()),
        ))
    }

    /// A point in the widget's coordinates, in this run's own.
    fn localize(&self, point: Point) -> (f32, f32) {
        (
            (point.x - self.origin.x) as f32,
            (point.y - self.origin.y) as f32,
        )
    }

    /// The byte range of this run a parley selection covers.
    fn byte_range(&self, selection: ParleySelection) -> Range<usize> {
        let range = selection.text_range();
        let text = self.text();
        let start = text.floor_char_boundary(range.start.min(text.len()));
        let end = text.floor_char_boundary(range.end.min(text.len()));
        start..end.max(start)
    }
}

/// A position within a sequence of runs: which run, and which byte of it.
///
/// Ordered by run and then by byte, which is document order, so comparing two
/// positions is how the model knows which end of a drag came first.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub struct ProsePoint {
    pub run: usize,
    pub byte: usize,
}

impl ProsePoint {
    pub fn new(run: usize, byte: usize) -> Self {
        ProsePoint { run, byte }
    }
}

/// A stretch of text across one or more runs: where the drag started, and
/// where it is now. The two are kept in that order rather than sorted, because
/// extending a selection moves the focus and leaves the anchor - which is what
/// makes dragging backwards over the start of a selection behave.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct ProseSelection {
    anchor: ProsePoint,
    focus: ProsePoint,
}

impl ProseSelection {
    pub fn new(anchor: ProsePoint, focus: ProsePoint) -> Self {
        ProseSelection { anchor, focus }
    }

    /// The ends in document order.
    pub fn ends(&self) -> (ProsePoint, ProsePoint) {
        if self.anchor <= self.focus {
            (self.anchor, self.focus)
        } else {
            (self.focus, self.anchor)
        }
    }

    /// Whether the selection covers no characters at all.
    pub fn is_collapsed(&self) -> bool {
        self.anchor == self.focus
    }

    /// The byte range selected within each run the selection touches, in
    /// document order. Runs outside the selection do not appear; a run wholly
    /// inside it appears with its whole text.
    pub fn ranges<'a>(&self, runs: &'a [ProseRun<'a>]) -> Vec<(usize, Range<usize>)> {
        let (start, end) = self.ends();
        (start.run..=end.run)
            .filter(|run| *run < runs.len())
            .filter_map(|run| {
                let text = runs[run].text();
                let from = if run == start.run { start.byte } else { 0 };
                let to = if run == end.run { end.byte } else { text.len() };
                let from = text.floor_char_boundary(from.min(text.len()));
                let to = text.floor_char_boundary(to.min(text.len()));
                (from < to).then_some((run, from..to))
            })
            .collect()
    }
}

/// The selection a text-bearing widget carries, and the pointer and key
/// handling that maintains it.
///
/// One implementation for a paragraph and for a document: what a widget
/// supplies is its [`ProseRun`]s, and everything a reader can do to a
/// selection - drag it, double-click a word, triple-click a line, Ctrl+A,
/// Ctrl+C, middle-click paste from the primary selection - happens here. A
/// widget that answered these events itself would be a second definition of
/// what selecting text means, and the two would drift.
#[derive(Default)]
pub struct SelectionState {
    selection: Option<ProseSelection>,
    /// A left press began a drag and moves are extending the selection.
    dragging: bool,
}

/// What the widget must do about an event the selection has seen.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SelectionResponse {
    /// The event was the selection's business; the widget should consume it
    /// rather than pass it on.
    pub consumed: bool,
    /// The highlight changed, so the widget needs a repaint.
    pub repaint: bool,
}

impl SelectionResponse {
    const IGNORED: Self = SelectionResponse {
        consumed: false,
        repaint: false,
    };

    fn consumed(repaint: bool) -> Self {
        SelectionResponse {
            consumed: true,
            repaint,
        }
    }
}

impl SelectionState {
    pub fn selection(&self) -> Option<ProseSelection> {
        self.selection
    }

    /// Whether anything is highlighted. A collapsed selection - a click that
    /// placed a caret and selected nothing - counts as nothing.
    pub fn is_empty(&self) -> bool {
        self.selection.is_none_or(|s| s.is_collapsed())
    }

    /// Drop the selection, reporting whether there was one to drop. Called
    /// when focus leaves, so at most one widget is highlighted at a time, and
    /// when the text changes, because byte offsets into replaced characters
    /// name nothing.
    pub fn clear(&mut self) -> bool {
        self.dragging = false;
        self.selection.take().is_some()
    }

    /// The selected text, with each run after the first preceded by whatever
    /// its [`ProseRun`] says joins it to the one before.
    pub fn selected_text(&self, runs: &[ProseRun]) -> Option<String> {
        let ranges = self.selection?.ranges(runs);
        let mut text = String::new();
        for (run, range) in ranges {
            if !text.is_empty() {
                text.push_str(runs[run].lead);
            }
            text.push_str(&runs[run].text()[range]);
        }
        (!text.is_empty()).then_some(text)
    }

    /// Publish the selection as the primary selection, the Wayland/X11
    /// convention that middle-click then pastes - through
    /// [`crate::widget::sync_primary_selection`], which is that convention's
    /// one definition, shared with the text input, so it cannot come to mean
    /// two things. An empty selection leaves the primary alone.
    pub fn sync_primary(&self, runs: &[ProseRun], clipboard: &mut dyn Clipboard) {
        let selected = self.selected_text(runs);
        crate::widget::sync_primary_selection(clipboard, selected.as_deref());
    }

    /// Select everything, from the first byte of the first run to the last of
    /// the last.
    pub fn select_all(&mut self, runs: &[ProseRun]) -> bool {
        let Some(last) = runs.len().checked_sub(1) else {
            return false;
        };
        self.selection = Some(ProseSelection::new(
            ProsePoint::new(0, 0),
            ProsePoint::new(last, runs[last].text().len()),
        ));
        true
    }

    /// Paint the highlight, under the glyphs so the text reads over it.
    pub fn paint(&self, fragment: &mut Fragment, runs: &[ProseRun], brush: &Brush) {
        let Some(selection) = self.selection else {
            return;
        };
        for (run, range) in selection.ranges(runs) {
            let run = runs[run];
            let Some(layout) = run.layout() else {
                continue;
            };
            parley_selection(layout, range).geometry_with(layout, |rect, _line| {
                fragment.fill(
                    Rect::new(
                        rect.x0 + run.origin.x,
                        rect.y0 + run.origin.y,
                        rect.x1 + run.origin.x,
                        rect.y1 + run.origin.y,
                    ),
                    brush.clone(),
                );
            });
        }
    }

    /// Ctrl+C copies the selection; Ctrl+A selects everything. Other keys are
    /// declined, so they bubble on to shortcuts and Tab traversal.
    pub fn on_key(
        &mut self,
        key: &KeyInput,
        runs: &[ProseRun],
        clipboard: &mut dyn Clipboard,
    ) -> SelectionResponse {
        if !key.modifiers.ctrl {
            return SelectionResponse::IGNORED;
        }
        let Key::Character(c) = &key.key else {
            return SelectionResponse::IGNORED;
        };
        match c.as_str() {
            "c" | "C" => {
                if let Some(selected) = self.selected_text(runs) {
                    clipboard.set_text(ClipboardSelection::Clipboard, &selected);
                }
                SelectionResponse::consumed(false)
            }
            "a" | "A" => {
                if !self.select_all(runs) {
                    return SelectionResponse::IGNORED;
                }
                self.sync_primary(runs, clipboard);
                SelectionResponse::consumed(true)
            }
            _ => SelectionResponse::IGNORED,
        }
    }

    /// Pointer selection: a left press anchors it and moves extend it, a
    /// double press takes the word under the pointer and a triple the line,
    /// and the release publishes the result to the primary selection.
    ///
    /// A press alone has to stay free to become a drag, so nothing here
    /// consumes a *click*; a widget with links decides that for itself, on the
    /// evidence of whether the drag selected anything.
    pub fn on_pointer(
        &mut self,
        kind: EventKind,
        event: &PointerEvent,
        runs: &[ProseRun],
        clipboard: &mut dyn Clipboard,
    ) -> SelectionResponse {
        match kind {
            EventKind::PointerDown if event.button == Some(PointerButton::Left) => {
                let Some(point) = position_at(runs, event.local) else {
                    return SelectionResponse::IGNORED;
                };
                self.selection = Some(match event.click_count {
                    0 | 1 => ProseSelection::new(point, point),
                    2 => word_at(runs, event.local).unwrap_or(ProseSelection::new(point, point)),
                    _ => line_at(runs, event.local).unwrap_or(ProseSelection::new(point, point)),
                });
                // A single click may grow into a drag; a multi-click selection
                // is complete at the press, so it publishes now and does not
                // drag (extending it would be by character, not by word).
                self.dragging = event.click_count <= 1;
                if event.click_count >= 2 {
                    self.sync_primary(runs, clipboard);
                }
                SelectionResponse::consumed(true)
            }
            EventKind::PointerMove if self.dragging => {
                let (Some(selection), Some(focus)) =
                    (self.selection, position_at(runs, event.local))
                else {
                    return SelectionResponse::consumed(false);
                };
                let extended = ProseSelection::new(selection.anchor, focus);
                let repaint = extended != selection;
                self.selection = Some(extended);
                SelectionResponse::consumed(repaint)
            }
            EventKind::PointerUp if event.button == Some(PointerButton::Left) => {
                let was_dragging = self.dragging;
                self.dragging = false;
                if was_dragging {
                    self.sync_primary(runs, clipboard);
                }
                SelectionResponse::consumed(false)
            }
            _ => SelectionResponse::IGNORED,
        }
    }
}

/// The parley selection covering `range` of a laid-out paragraph. Derived
/// where it is needed rather than stored; see the module docs.
fn parley_selection(layout: &Layout<Brush>, range: Range<usize>) -> ParleySelection {
    ParleySelection::new(
        Cursor::from_byte_index(layout, range.start, Affinity::Downstream),
        Cursor::from_byte_index(layout, range.end, Affinity::Upstream),
    )
}

/// Which run a point in the widget's coordinates falls in, and where in it.
///
/// A point outside every run still resolves to one: a drag that leaves the
/// text on its way to somewhere else should keep selecting, and a reader who
/// drags past the last line means the end of it. The run that contains the
/// point wins; failing that, the nearest one does.
fn position_at(runs: &[ProseRun], point: Point) -> Option<ProsePoint> {
    let (index, run) = nearest_run(runs, point)?;
    let layout = run.layout()?;
    let (x, y) = run.localize(point);
    let cursor = Cursor::from_point(layout, x, y);
    let text = run.text();
    let byte = text.floor_char_boundary(cursor.index().min(text.len()));
    Some(ProsePoint::new(index, byte))
}

/// The word under a point, as a selection.
fn word_at(runs: &[ProseRun], point: Point) -> Option<ProseSelection> {
    unit_at(runs, point, ParleySelection::word_from_point)
}

/// The line under a point, as a selection.
fn line_at(runs: &[ProseRun], point: Point) -> Option<ProseSelection> {
    unit_at(runs, point, ParleySelection::line_from_point)
}

/// The word or line under a point, as a selection within the run it landed in.
/// A multi-click selects within one paragraph: the unit it names does not
/// straddle two, whatever the pointer is over.
fn unit_at(
    runs: &[ProseRun],
    point: Point,
    unit: fn(&Layout<Brush>, f32, f32) -> ParleySelection,
) -> Option<ProseSelection> {
    let (index, run) = nearest_run(runs, point)?;
    let layout = run.layout()?;
    let (x, y) = run.localize(point);
    let range = run.byte_range(unit(layout, x, y));
    Some(ProseSelection::new(
        ProsePoint::new(index, range.start),
        ProsePoint::new(index, range.end),
    ))
}

/// The run a point is in, or the closest one to it.
fn nearest_run<'a>(runs: &'a [ProseRun<'a>], point: Point) -> Option<(usize, ProseRun<'a>)> {
    let mut nearest: Option<(usize, ProseRun, f64)> = None;
    for (index, run) in runs.iter().enumerate() {
        let Some(bounds) = run.bounds() else {
            continue;
        };
        let distance = rect_distance(bounds, point);
        if distance == 0.0 {
            return Some((index, *run));
        }
        if nearest.is_none_or(|(_, _, best)| distance < best) {
            nearest = Some((index, *run, distance));
        }
    }
    nearest.map(|(index, run, _)| (index, run))
}

/// How far a point lies outside a rectangle; zero when it is inside.
///
/// Vertical distance dominates: the runs of a document are stacked, so a
/// pointer to the left of one line is in that line, not in a shorter line
/// above it that happens to end nearer the pointer.
fn rect_distance(rect: Rect, point: Point) -> f64 {
    let dx = (rect.x0 - point.x).max(point.x - rect.x1).max(0.0);
    let dy = (rect.y0 - point.y).max(point.y - rect.y1).max(0.0);
    dy * 1024.0 + dx
}

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

    /// Paragraphs with content but no layout: the model's ranges and the text
    /// it yields are byte arithmetic over the characters, which is exactly the
    /// property that makes a re-wrap cost the selection nothing.
    fn paragraphs(texts: &[&str]) -> Vec<Paragraph> {
        texts
            .iter()
            .map(|text| {
                let mut paragraph = Paragraph::default();
                paragraph.set_content(RichText::new(*text));
                paragraph
            })
            .collect()
    }

    fn runs<'a>(paragraphs: &'a [Paragraph], lead: &'a str) -> Vec<ProseRun<'a>> {
        paragraphs
            .iter()
            .map(|p| ProseRun::new(p, Point::ORIGIN).led_by(lead))
            .collect()
    }

    fn selecting(selection: ProseSelection) -> SelectionState {
        SelectionState {
            selection: Some(selection),
            dragging: false,
        }
    }

    #[test]
    fn a_selection_within_one_run_names_one_range() {
        let paragraphs = paragraphs(&["First block.", "Second block."]);
        let runs = runs(&paragraphs, "\n");
        let selection = ProseSelection::new(ProsePoint::new(0, 0), ProsePoint::new(0, 5));
        assert_eq!(selection.ranges(&runs), vec![(0, 0..5)]);
        assert_eq!(
            selecting(selection).selected_text(&runs).as_deref(),
            Some("First")
        );
    }

    #[test]
    fn a_selection_across_runs_takes_all_of_the_ones_between() {
        let paragraphs = paragraphs(&["First.", "Middle.", "Last."]);
        let runs = runs(&paragraphs, "\n");
        let selection = ProseSelection::new(ProsePoint::new(0, 3), ProsePoint::new(2, 4));
        assert_eq!(
            selection.ranges(&runs),
            vec![(0, 3..6), (1, 0..7), (2, 0..4)]
        );
        assert_eq!(
            selecting(selection).selected_text(&runs).as_deref(),
            Some("st.\nMiddle.\nLast")
        );
    }

    #[test]
    fn a_backwards_drag_selects_the_same_text() {
        let paragraphs = paragraphs(&["First.", "Last."]);
        let runs = runs(&paragraphs, "\n");
        let forwards = ProseSelection::new(ProsePoint::new(0, 3), ProsePoint::new(1, 4));
        let backwards = ProseSelection::new(ProsePoint::new(1, 4), ProsePoint::new(0, 3));
        assert_eq!(forwards.ranges(&runs), backwards.ranges(&runs));
    }

    #[test]
    fn each_run_brings_its_own_separator() {
        let paragraphs = paragraphs(&["Name", "Score", "Next row"]);
        // A table row: the cells after the first are tab-joined, the next row
        // starts a line.
        let runs = vec![
            ProseRun::new(&paragraphs[0], Point::ORIGIN).led_by("\n"),
            ProseRun::new(&paragraphs[1], Point::ORIGIN).led_by("\t"),
            ProseRun::new(&paragraphs[2], Point::ORIGIN).led_by("\n"),
        ];
        let mut state = SelectionState::default();
        assert!(state.select_all(&runs));
        assert_eq!(
            state.selected_text(&runs).as_deref(),
            Some("Name\tScore\nNext row")
        );
    }

    #[test]
    fn a_collapsed_selection_is_no_selection() {
        let paragraphs = paragraphs(&["Something."]);
        let runs = runs(&paragraphs, "\n");
        let point = ProsePoint::new(0, 4);
        let state = selecting(ProseSelection::new(point, point));
        assert!(state.is_empty());
        assert_eq!(state.selected_text(&runs), None);
    }

    #[test]
    fn selecting_all_of_nothing_selects_nothing() {
        let mut state = SelectionState::default();
        assert!(!state.select_all(&[]));
        assert!(state.is_empty());
    }

    #[test]
    fn a_shrunken_document_does_not_take_ranges_with_it() {
        // The widget clears the selection when its text changes; if one
        // survived anyway, it names bytes and runs that are gone, and asking
        // for them is not a panic.
        let paragraphs = paragraphs(&["Short."]);
        let runs = runs(&paragraphs, "\n");
        let selection = ProseSelection::new(ProsePoint::new(0, 2), ProsePoint::new(4, 900));
        assert_eq!(selection.ranges(&runs), vec![(0, 2..6)]);
    }
}