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}