azul_core/selection.rs
1//! Text selection and cursor positioning for inline content.
2//!
3//! This module provides data structures for managing text cursors and selection ranges
4//! in a bidirectional (Bidi) and line-breaking aware manner. It handles:
5//!
6//! - **Grapheme cluster identification**: Unicode-aware character boundaries
7//! - **Bidi support**: Cursor movement in mixed LTR/RTL text
8//! - **Stable positions**: Selection anchors survive layout changes
9//! - **Affinity tracking**: Cursor position at leading/trailing edges
10//! - **Multi-node selection**: Browser-style selection spanning multiple DOM nodes
11//!
12//! # Architecture
13//!
14//! Text positions are represented as:
15//! - `ContentIndex`: Logical position in the original inline content array
16//! - `GraphemeClusterId`: Stable identifier for a grapheme cluster (survives reordering)
17//! - `TextCursor`: Precise cursor location with leading/trailing affinity
18//! - `SelectionRange`: Start and end cursors defining a selection
19//!
20//! Multi-node selection uses an Anchor/Focus model (W3C Selection API):
21//! - `SelectionAnchor`: Fixed point where user started selection (mousedown)
22//! - `SelectionFocus`: Movable point where selection currently ends (drag position)
23//! - `TextSelection`: Complete selection state spanning potentially multiple IFC roots
24//!
25//! # Use Cases
26//!
27//! - Text editing: Insert/delete at cursor position
28//! - Selection rendering: Highlight selected text across multiple nodes
29//! - Keyboard navigation: Move cursor by grapheme/word/line
30//! - Mouse selection: Convert pixel coordinates to text positions
31//! - Drag selection: Extend selection across multiple DOM nodes
32//!
33//! # Examples
34//!
35//! ```rust,no_run
36//! use azul_core::selection::{CursorAffinity, GraphemeClusterId, TextCursor};
37//!
38//! let cursor = TextCursor {
39//! cluster_id: GraphemeClusterId {
40//! source_run: 0,
41//! start_byte_in_run: 0,
42//! },
43//! affinity: CursorAffinity::Leading,
44//! };
45//! ```
46
47use alloc::collections::BTreeMap;
48use alloc::vec::Vec;
49
50use crate::dom::{DomId, DomNodeId, NodeId};
51use crate::geom::{LogicalPosition, LogicalRect};
52
53/// A stable, logical pointer to an item within the original `InlineContent` array.
54///
55/// This structure eliminates the need for string concatenation and byte-offset math
56/// by tracking both the run index and the item index within that run.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
58pub struct ContentIndex {
59 /// The index of the `InlineContent` run in the original input array.
60 pub run_index: u32,
61 /// The byte index of the character or item *within* that run's string.
62 pub item_index: u32,
63}
64
65/// A stable, logical identifier for a grapheme cluster.
66///
67/// This survives Bidi reordering and line breaking, making it ideal for tracking
68/// text positions for selection and cursor logic.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
70#[repr(C)]
71pub struct GraphemeClusterId {
72 /// The `run_index` from the source `ContentIndex`.
73 pub source_run: u32,
74 /// The byte index of the start of the cluster in its original `StyledRun`.
75 pub start_byte_in_run: u32,
76}
77
78/// Represents the logical position of the cursor *between* two grapheme clusters
79/// or at the start/end of the text.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
81#[repr(C)]
82pub enum CursorAffinity {
83 /// The cursor is at the leading edge of the character (left in LTR, right in RTL).
84 Leading,
85 /// The cursor is at the trailing edge of the character (right in LTR, left in RTL).
86 Trailing,
87}
88
89/// Represents a precise cursor location in the logical text.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
91#[repr(C)]
92pub struct TextCursor {
93 /// The grapheme cluster the cursor is associated with.
94 pub cluster_id: GraphemeClusterId,
95 /// The edge of the cluster the cursor is on.
96 pub affinity: CursorAffinity,
97}
98
99impl_option!(
100 TextCursor,
101 OptionTextCursor,
102 [Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd]
103);
104
105/// Represents a range of selected text. The direction is implicit (start can be
106/// logically after end if selecting backwards).
107#[derive(Debug, PartialOrd, Ord, Clone, Copy, PartialEq, Eq, Hash)]
108#[repr(C)]
109pub struct SelectionRange {
110 pub start: TextCursor,
111 pub end: TextCursor,
112}
113
114impl_option!(
115 SelectionRange,
116 OptionSelectionRange,
117 [Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd]
118);
119
120impl_vec!(
121 SelectionRange,
122 SelectionRangeVec,
123 SelectionRangeVecDestructor,
124 SelectionRangeVecDestructorType
125);
126impl_vec_debug!(SelectionRange, SelectionRangeVec);
127impl_vec_clone!(
128 SelectionRange,
129 SelectionRangeVec,
130 SelectionRangeVecDestructor
131);
132impl_vec_partialeq!(SelectionRange, SelectionRangeVec);
133impl_vec_partialord!(SelectionRange, SelectionRangeVec);
134
135/// A single selection, which can be either a blinking cursor or a highlighted range.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
137#[repr(C, u8)]
138pub enum Selection {
139 Cursor(TextCursor),
140 Range(SelectionRange),
141}
142
143impl_vec!(
144 Selection,
145 SelectionVec,
146 SelectionVecDestructor,
147 SelectionVecDestructorType
148);
149impl_vec_debug!(Selection, SelectionVec);
150impl_vec_clone!(Selection, SelectionVec, SelectionVecDestructor);
151impl_vec_partialeq!(Selection, SelectionVec);
152impl_vec_partialord!(Selection, SelectionVec);
153
154/// The complete selection state for a single text block, supporting multiple cursors/ranges.
155#[derive(Debug, Clone, PartialEq)]
156#[repr(C)]
157pub struct SelectionState {
158 /// A list of all active selections. This list is kept sorted and non-overlapping.
159 pub selections: SelectionVec,
160 /// The DOM node this selection state applies to.
161 pub node_id: DomNodeId,
162}
163
164impl SelectionState {
165 /// Adds a new selection, merging it with any existing selections it overlaps with.
166 pub fn add(&mut self, new_selection: Selection) {
167 // A full implementation would handle merging overlapping ranges.
168 // For now, we simply add and sort for simplicity.
169 let mut selections: Vec<Selection> = self.selections.as_ref().to_vec();
170 selections.push(new_selection);
171 selections.sort_unstable();
172 selections.dedup(); // Removes duplicate cursors
173 self.selections = selections.into();
174 }
175
176 /// Clears all selections and replaces them with a single cursor.
177 pub fn set_cursor(&mut self, cursor: TextCursor) {
178 self.selections = vec![Selection::Cursor(cursor)].into();
179 }
180}
181
182impl_option!(
183 SelectionState,
184 OptionSelectionState,
185 copy = false,
186 clone = false,
187 [Debug, Clone, PartialEq]
188);
189
190// ============================================================================
191// MULTI-NODE SELECTION (Browser-style Anchor/Focus model)
192// ============================================================================
193
194/// The anchor point of a text selection - where the user started selecting.
195///
196/// This is the fixed point during a drag operation. It records:
197/// - The IFC root node (where the `UnifiedLayout` lives)
198/// - The exact cursor position within that layout
199/// - The visual bounds of the anchor character (for logical rectangle calculations)
200///
201/// The anchor remains constant during a drag; only the focus moves.
202#[derive(Debug, Clone, PartialEq)]
203pub struct SelectionAnchor {
204 /// The IFC root node ID where selection started.
205 /// This is the node that has `inline_layout_result` (e.g., `<p>`, `<div>`).
206 pub ifc_root_node_id: NodeId,
207
208 /// The exact cursor position within the IFC's `UnifiedLayout`.
209 pub cursor: TextCursor,
210
211 /// Visual bounds of the anchor character in viewport coordinates.
212 /// Used for computing the logical selection rectangle during multi-line/multi-node selection.
213 pub char_bounds: LogicalRect,
214
215 /// The mouse position when the selection started (viewport coordinates).
216 pub mouse_position: LogicalPosition,
217}
218
219/// The focus point of a text selection - where the selection currently ends.
220///
221/// This is the movable point during a drag operation. It updates on every mouse move.
222#[derive(Debug, Clone, PartialEq)]
223pub struct SelectionFocus {
224 /// The IFC root node ID where selection currently ends.
225 /// May differ from anchor's IFC root during cross-node selection.
226 pub ifc_root_node_id: NodeId,
227
228 /// The exact cursor position within the IFC's `UnifiedLayout`.
229 pub cursor: TextCursor,
230
231 /// Current mouse position in viewport coordinates.
232 pub mouse_position: LogicalPosition,
233}
234
235/// Type of selection for a specific node within a multi-node selection.
236///
237/// This helps the renderer determine how to highlight each node:
238/// - `Anchor`: Selection starts in this node
239/// - `Focus`: Selection ends in this node
240/// - `InBetween`: Entire node is selected (between anchor and focus)
241/// - `AnchorAndFocus`: Both anchor and focus are in this single node
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
243pub enum NodeSelectionType {
244 /// This is the anchor node (selection started here) - partial selection from anchor to end
245 Anchor,
246 /// This is the focus node (selection ends here) - partial selection from start to focus
247 Focus,
248 /// This node is between anchor and focus - fully selected
249 InBetween,
250 /// Anchor and focus are in the same node - partial selection between cursors
251 AnchorAndFocus,
252}
253
254/// Complete selection state spanning potentially multiple DOM nodes.
255///
256/// This implements the W3C Selection API model with anchor/focus endpoints.
257/// The selection can span multiple IFC roots (e.g., multiple `<p>` elements).
258///
259/// ## Storage Model
260///
261/// Uses `BTreeMap<NodeId, SelectionRange>` for O(log N) lookup during rendering.
262/// The key is the **IFC root NodeId**, and the value is the `SelectionRange` for that IFC.
263///
264/// ## Example
265///
266/// ```text
267/// <p id="1">Hello [World</p> <- Anchor in IFC 1, partial selection
268/// <p id="2">Complete line</p> <- InBetween, fully selected
269/// <p id="3">Partial] end</p> <- Focus in IFC 3, partial selection
270/// ```
271#[derive(Debug, Clone, PartialEq)]
272pub struct TextSelection {
273 /// The DOM this selection belongs to.
274 pub dom_id: DomId,
275
276 /// The anchor point - where the selection started (fixed during drag).
277 pub anchor: SelectionAnchor,
278
279 /// The focus point - where the selection currently ends (moves during drag).
280 pub focus: SelectionFocus,
281
282 /// Map from IFC root NodeId to the SelectionRange for that IFC.
283 /// This allows O(log N) lookup during rendering.
284 ///
285 /// The `SelectionRange` contains the actual `TextCursor` positions for that IFC,
286 /// ready to be passed to `UnifiedLayout::get_selection_rects()`.
287 pub affected_nodes: BTreeMap<NodeId, SelectionRange>,
288
289 /// Indicates whether anchor comes before focus in document order.
290 /// True = forward selection (left-to-right), False = backward selection.
291 pub is_forward: bool,
292}
293
294impl TextSelection {
295 /// Create a new collapsed selection (cursor) at the given position.
296 pub fn new_collapsed(
297 dom_id: DomId,
298 ifc_root_node_id: NodeId,
299 cursor: TextCursor,
300 char_bounds: LogicalRect,
301 mouse_position: LogicalPosition,
302 ) -> Self {
303 let anchor = SelectionAnchor {
304 ifc_root_node_id,
305 cursor,
306 char_bounds,
307 mouse_position,
308 };
309
310 let focus = SelectionFocus {
311 ifc_root_node_id,
312 cursor,
313 mouse_position,
314 };
315
316 // For a collapsed selection, the anchor node has a zero-width range
317 let mut affected_nodes = BTreeMap::new();
318 affected_nodes.insert(ifc_root_node_id, SelectionRange {
319 start: cursor,
320 end: cursor,
321 });
322
323 TextSelection {
324 dom_id,
325 anchor,
326 focus,
327 affected_nodes,
328 is_forward: true, // Direction doesn't matter for collapsed selection
329 }
330 }
331
332 /// Check if this is a collapsed selection (cursor with no range).
333 pub fn is_collapsed(&self) -> bool {
334 self.anchor.ifc_root_node_id == self.focus.ifc_root_node_id
335 && self.anchor.cursor == self.focus.cursor
336 }
337
338 /// Get the selection range for a specific IFC root node.
339 /// Returns `None` if this node is not part of the selection.
340 pub fn get_range_for_node(&self, ifc_root_node_id: &NodeId) -> Option<&SelectionRange> {
341 self.affected_nodes.get(ifc_root_node_id)
342 }
343
344 /// Check if a specific IFC root node is part of this selection.
345 pub fn contains_node(&self, ifc_root_node_id: &NodeId) -> bool {
346 self.affected_nodes.contains_key(ifc_root_node_id)
347 }
348
349 /// Get the selection type for a specific node.
350 pub fn get_node_selection_type(&self, ifc_root_node_id: &NodeId) -> Option<NodeSelectionType> {
351 if !self.affected_nodes.contains_key(ifc_root_node_id) {
352 return None;
353 }
354
355 let is_anchor = *ifc_root_node_id == self.anchor.ifc_root_node_id;
356 let is_focus = *ifc_root_node_id == self.focus.ifc_root_node_id;
357
358 Some(match (is_anchor, is_focus) {
359 (true, true) => NodeSelectionType::AnchorAndFocus,
360 (true, false) => NodeSelectionType::Anchor,
361 (false, true) => NodeSelectionType::Focus,
362 (false, false) => NodeSelectionType::InBetween,
363 })
364 }
365}
366
367impl_option!(
368 TextSelection,
369 OptionTextSelection,
370 copy = false,
371 clone = false,
372 [Debug, Clone, PartialEq]
373);