rdom_core/selection.rs
1//! `Selection`, `Range`, `Position` — DOM-native text-selection
2//! primitives, browser-faithful.
3//!
4//! A `Position` is an `(node, offset)` pair. The node is typically
5//! a Text node and the offset is a byte index into the text's
6//! data. Positions with an Element node are conceptually valid
7//! (offset = "N children in") but v1 selection lives inside text.
8//!
9//! A `Range` is an ordered pair of positions — `start` precedes or
10//! equals `end` in document order. Used for paint + copy.
11//!
12//! A `Selection` is the document-level interaction state: an
13//! `anchor` (where the user started selecting) and a `focus`
14//! (where the cursor is now). The pair **preserves direction** —
15//! `anchor` may come after `focus` — so shrinking a selection by
16//! dragging backward works.
17//!
18//! ## Why node+offset, not screen coordinates?
19//!
20//! Layout changes (resize, content insertion, scroll) invalidate
21//! screen-coordinate selections on the next frame. Node+offset
22//! survives re-layout: a selection between byte 3 and byte 15 of
23//! a given text node stays at "bytes 3..15" regardless of where
24//! those bytes land on screen. This matches the browser's
25//! `Selection` API and is the reason browsers use this model.
26//!
27//! The runtime derives screen rectangles for paint from
28//! node+offset via the `InlineLayout` fragments on each IFC block.
29
30use crate::node_id::NodeId;
31
32/// A position within the DOM's text content.
33///
34/// - `node`: the Text node the position falls inside. v1 restricts
35/// positions to Text nodes; future work may generalize to
36/// Element positions (before/after a child).
37/// - `offset`: byte offset into the text node's `data` string.
38/// **Bytes** (not codepoints, not graphemes) — consistent with
39/// the browser `Range` API and with how slicing works in Rust.
40/// Callers that need grapheme-level indexing convert via
41/// `unicode-segmentation`.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub struct Position {
44 pub node: NodeId,
45 pub offset: usize,
46}
47
48impl Position {
49 /// Construct a position at `offset` bytes into `node`.
50 pub fn new(node: NodeId, offset: usize) -> Self {
51 Self { node, offset }
52 }
53}
54
55/// A range of text in document order — `start` precedes or equals
56/// `end`. Built in document order by
57/// [`Dom::selection_range`](crate::Dom::selection_range), which accepts
58/// any two positions and sorts them; [`Range::ordered_unchecked`] trusts
59/// the caller's order.
60///
61/// Use [`Range::is_collapsed`] to detect zero-length ranges (the
62/// "caret" case).
63///
64/// v1 note: sorting requires document-order comparison, which
65/// needs the Dom. If you already know your two positions are in
66/// order, use the struct literal directly (or
67/// [`Range::ordered_unchecked`]); otherwise call
68/// [`Dom::selection_range`](crate::Dom::selection_range) to get
69/// the normalized range for the current `Selection`.
70#[derive(Debug, Clone, PartialEq, Eq, Hash)]
71pub struct Range {
72 pub start: Position,
73 pub end: Position,
74}
75
76impl Range {
77 /// Construct a range assuming the caller already knows the
78 /// positions are in document order. Never fails; if you pass
79 /// positions in the wrong order the resulting `Range` is
80 /// unusable for paint / copy but will still compile.
81 ///
82 /// For a DOM-ordered normalization, use
83 /// [`Dom::selection_range`](crate::Dom::selection_range).
84 pub fn ordered_unchecked(start: Position, end: Position) -> Self {
85 Self { start, end }
86 }
87
88 /// `true` iff `start == end` — zero-length range (caret
89 /// position; no highlighted text).
90 pub fn is_collapsed(&self) -> bool {
91 self.start == self.end
92 }
93}
94
95/// A reading of [`Dom::selection_serial`](crate::Dom::selection_serial):
96/// a counter that advances on every actual selection change. Two equal
97/// readings mean the selection was not touched in between; a later
98/// reading compares greater (the counter is a `u64`, so it does not
99/// wrap in practice). An opaque bookkeeping value — no web API exposes
100/// it; [`new`](Self::new) / [`get`](Self::get) exist for tests and for
101/// backends that persist a reading.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
103pub struct SelectionSerial(u64);
104
105impl SelectionSerial {
106 /// The serial with raw value `n`.
107 pub const fn new(n: u64) -> Self {
108 Self(n)
109 }
110
111 /// The raw counter value.
112 pub const fn get(self) -> u64 {
113 self.0
114 }
115
116 /// The next reading, after one more selection change.
117 pub(crate) const fn next(self) -> Self {
118 Self(self.0.wrapping_add(1))
119 }
120}
121
122/// Document-level selection. Two positions — `anchor` at the
123/// start of the interaction (e.g., mousedown, Shift+Click origin)
124/// and `focus` at the current cursor position.
125///
126/// **Direction-preserving**: `anchor` may come before OR after
127/// `focus`. Dragging backward shrinks / inverts the selection
128/// without losing the original anchor.
129///
130/// Use [`Selection::caret`] for a collapsed selection (cursor
131/// between two graphemes, no text highlighted).
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
133pub struct Selection {
134 pub anchor: Position,
135 pub focus: Position,
136}
137
138impl Selection {
139 /// Construct with explicit anchor + focus. Can be inverted
140 /// (focus before anchor in document order).
141 pub fn new(anchor: Position, focus: Position) -> Self {
142 Self { anchor, focus }
143 }
144
145 /// Construct a collapsed selection (caret) at `pos`.
146 /// `anchor == focus == pos`.
147 pub fn caret(pos: Position) -> Self {
148 Self {
149 anchor: pos,
150 focus: pos,
151 }
152 }
153
154 /// `true` iff `anchor == focus` — no text is highlighted; the
155 /// selection represents a caret position only.
156 pub fn is_collapsed(&self) -> bool {
157 self.anchor == self.focus
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::Dom;
165
166 fn node() -> (Dom, NodeId) {
167 let mut dom: Dom = Dom::new();
168 let t = dom.create_text_node("hello world");
169 (dom, t)
170 }
171
172 #[test]
173 fn position_construct() {
174 let (_, n) = node();
175 let p = Position::new(n, 3);
176 assert_eq!(p.node, n);
177 assert_eq!(p.offset, 3);
178 }
179
180 #[test]
181 fn selection_caret_is_collapsed() {
182 let (_, n) = node();
183 let sel = Selection::caret(Position::new(n, 5));
184 assert!(sel.is_collapsed());
185 assert_eq!(sel.anchor, sel.focus);
186 }
187
188 #[test]
189 fn selection_with_different_anchor_focus_is_not_collapsed() {
190 let (_, n) = node();
191 let sel = Selection::new(Position::new(n, 2), Position::new(n, 7));
192 assert!(!sel.is_collapsed());
193 }
194
195 #[test]
196 fn selection_preserves_direction() {
197 let (_, n) = node();
198 // Inverted: focus before anchor.
199 let sel = Selection::new(Position::new(n, 8), Position::new(n, 2));
200 assert_eq!(sel.anchor.offset, 8);
201 assert_eq!(sel.focus.offset, 2);
202 assert!(!sel.is_collapsed());
203 }
204
205 #[test]
206 fn range_is_collapsed_when_start_eq_end() {
207 let (_, n) = node();
208 let p = Position::new(n, 4);
209 let r = Range::ordered_unchecked(p, p);
210 assert!(r.is_collapsed());
211 }
212}