Skip to main content

rotulus_layout/
select.rs

1//! Selection: hit-testing, the selection range, and text extraction.
2//!
3//! Selection is a *document* position pair, not a pixel pair. A
4//! [`Caret`] names `(message, source-within-message, byte offset)`, so
5//! the selection survives everything that moves pixels around — a
6//! resize re-wraps, a zoom rescales, an image finishes decoding and
7//! grows its row, a history batch prepends above. xtext stored
8//! `mark_start` / `mark_end` as byte offsets on each `textentry` plus
9//! `select_start_x/y` in widget coordinates, which is why its selection
10//! behaves oddly across a resize mid-drag.
11//!
12//! The engine deliberately owns none of the *interaction* — no notion of
13//! button state, drag, or double-click. It answers three questions and
14//! the widget does the rest:
15//!
16//! - where in the document is this pixel? ([`ChatBuffer::hit_test`])
17//! - what does the region between two carets look like? ([`Selection`])
18//! - what text does that region contain? ([`ChatBuffer::selected_text`])
19
20use crate::message::MessageId;
21use crate::wrap::LineSource;
22
23/// A document position.
24#[derive(Clone, Copy, PartialEq, Eq, Debug)]
25pub struct Caret {
26    pub message: MessageId,
27    /// Which of the message's texts the offset indexes.
28    pub source: LineSource,
29    /// Byte offset into that text. Always on a `char` boundary.
30    pub offset: usize,
31}
32
33/// An ordered selection range.
34///
35/// `anchor` is where the drag started and `focus` where it is now;
36/// either may be the earlier one in document order, which is what makes
37/// dragging upwards work.
38#[derive(Clone, Copy, PartialEq, Eq, Debug)]
39pub struct Selection {
40    pub anchor: Caret,
41    pub focus: Caret,
42}
43
44/// Visual order of a row's texts.
45///
46/// A row is not one string: it is a gutter followed by body blocks, and
47/// a selection boundary can land in any of them. Ranking them is what
48/// lets carets in *different* sources be ordered against each other —
49/// comparing their byte offsets, which is what the first version did,
50/// is meaningless, because the two offsets index unrelated strings.
51pub fn source_rank(s: LineSource) -> usize {
52    match s {
53        LineSource::Gutter => 0,
54        LineSource::Block(i) => i + 1,
55    }
56}
57
58/// How far a selection extends within one row.
59#[derive(Clone, Copy, PartialEq, Eq, Debug)]
60pub enum RowSelection {
61    /// Not selected at all.
62    None,
63    /// Every source in the row is fully selected.
64    All,
65    /// Runs from `start` to `end`, which may be in *different* sources.
66    ///
67    /// This is the shape the first version got wrong: it could name only
68    /// one source, so a boundary landing in the gutter left the rest of
69    /// that row unselected. Dragging left and then up — which puts the
70    /// start of the selection in a gutter — highlighted the nick and not
71    /// the message, which reads as the selection being inverted.
72    Partial {
73        start: (LineSource, usize),
74        end: (LineSource, usize),
75    },
76}
77
78impl Selection {
79    pub fn new(anchor: Caret, focus: Caret) -> Selection {
80        Selection { anchor, focus }
81    }
82
83    pub fn is_empty(&self) -> bool {
84        self.anchor == self.focus
85    }
86
87    /// `(earlier, later)` in document order, given a row resolver.
88    ///
89    /// Ordering is on `(row, source rank, offset)`, in that order, and
90    /// all three parts matter:
91    ///
92    /// - **row**, not `MessageId`: ids are allocated in creation order,
93    ///   but a chat-history batch inserts *older* messages with *higher*
94    ///   ids, so comparing ids inverts the selection after a backfill.
95    /// - **source rank** before offset: within a row the gutter precedes
96    ///   the body, and their offsets index unrelated strings, so
97    ///   comparing offsets across sources is meaningless. Skipping this
98    ///   is what made a same-row gutter→body drag select the whole row.
99    /// - **offset** last, within one source.
100    pub fn ordered(&self, row_of: impl Fn(MessageId) -> Option<usize>) -> (Caret, Caret) {
101        let key = |c: &Caret| {
102            (
103                row_of(c.message).unwrap_or(usize::MAX),
104                source_rank(c.source),
105                c.offset,
106            )
107        };
108        if key(&self.anchor) <= key(&self.focus) {
109            (self.anchor, self.focus)
110        } else {
111            (self.focus, self.anchor)
112        }
113    }
114}