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!(SelectionRange, SelectionRangeVec, SelectionRangeVecDestructor, SelectionRangeVecDestructorType, SelectionRangeVecSlice, OptionSelectionRange);
121impl_vec_debug!(SelectionRange, SelectionRangeVec);
122impl_vec_clone!(
123 SelectionRange,
124 SelectionRangeVec,
125 SelectionRangeVecDestructor
126);
127impl_vec_partialeq!(SelectionRange, SelectionRangeVec);
128impl_vec_partialord!(SelectionRange, SelectionRangeVec);
129
130/// A single selection, which can be either a blinking cursor or a highlighted range.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
132#[repr(C, u8)]
133pub enum Selection {
134 Cursor(TextCursor),
135 Range(SelectionRange),
136}
137
138impl_option!(
139 Selection,
140 OptionSelection,
141 [Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord]
142);
143
144impl_vec!(Selection, SelectionVec, SelectionVecDestructor, SelectionVecDestructorType, SelectionVecSlice, OptionSelection);
145impl_vec_debug!(Selection, SelectionVec);
146impl_vec_clone!(Selection, SelectionVec, SelectionVecDestructor);
147impl_vec_partialeq!(Selection, SelectionVec);
148impl_vec_partialord!(Selection, SelectionVec);
149
150/// The complete selection state for a single text block, supporting multiple cursors/ranges.
151#[derive(Debug, Clone, PartialEq)]
152#[repr(C)]
153pub struct SelectionState {
154 /// A list of all active selections. This list is kept sorted and non-overlapping.
155 pub selections: SelectionVec,
156 /// The DOM node this selection state applies to.
157 pub node_id: DomNodeId,
158}
159
160impl SelectionState {
161 /// Adds a new selection, merging it with any existing selections it overlaps with.
162 pub fn add(&mut self, new_selection: Selection) {
163 // A full implementation would handle merging overlapping ranges.
164 // For now, we simply add and sort for simplicity.
165 let mut selections: Vec<Selection> = self.selections.as_ref().to_vec();
166 selections.push(new_selection);
167 selections.sort_unstable();
168 selections.dedup(); // Removes duplicate cursors
169 self.selections = selections.into();
170 }
171
172 /// Clears all selections and replaces them with a single cursor.
173 pub fn set_cursor(&mut self, cursor: TextCursor) {
174 self.selections = vec![Selection::Cursor(cursor)].into();
175 }
176}
177
178impl_option!(
179 SelectionState,
180 OptionSelectionState,
181 copy = false,
182 clone = false,
183 [Debug, Clone, PartialEq]
184);
185
186// ============================================================================
187// MULTI-NODE SELECTION (Browser-style Anchor/Focus model)
188// ============================================================================
189
190/// The anchor point of a text selection - where the user started selecting.
191///
192/// This is the fixed point during a drag operation. It records:
193/// - The IFC root node (where the `UnifiedLayout` lives)
194/// - The exact cursor position within that layout
195/// - The visual bounds of the anchor character (for logical rectangle calculations)
196///
197/// The anchor remains constant during a drag; only the focus moves.
198#[derive(Debug, Clone, PartialEq)]
199pub struct SelectionAnchor {
200 /// The IFC root node ID where selection started.
201 /// This is the node that has `inline_layout_result` (e.g., `<p>`, `<div>`).
202 pub ifc_root_node_id: NodeId,
203
204 /// The exact cursor position within the IFC's `UnifiedLayout`.
205 pub cursor: TextCursor,
206
207 /// Visual bounds of the anchor character in viewport coordinates.
208 /// Used for computing the logical selection rectangle during multi-line/multi-node selection.
209 pub char_bounds: LogicalRect,
210
211 /// The mouse position when the selection started (viewport coordinates).
212 pub mouse_position: LogicalPosition,
213}
214
215/// The focus point of a text selection - where the selection currently ends.
216///
217/// This is the movable point during a drag operation. It updates on every mouse move.
218#[derive(Debug, Clone, PartialEq)]
219pub struct SelectionFocus {
220 /// The IFC root node ID where selection currently ends.
221 /// May differ from anchor's IFC root during cross-node selection.
222 pub ifc_root_node_id: NodeId,
223
224 /// The exact cursor position within the IFC's `UnifiedLayout`.
225 pub cursor: TextCursor,
226
227 /// Current mouse position in viewport coordinates.
228 pub mouse_position: LogicalPosition,
229}
230
231/// Type of selection for a specific node within a multi-node selection.
232///
233/// This helps the renderer determine how to highlight each node:
234/// - `Anchor`: Selection starts in this node
235/// - `Focus`: Selection ends in this node
236/// - `InBetween`: Entire node is selected (between anchor and focus)
237/// - `AnchorAndFocus`: Both anchor and focus are in this single node
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
239pub enum NodeSelectionType {
240 /// This is the anchor node (selection started here) - partial selection from anchor to end
241 Anchor,
242 /// This is the focus node (selection ends here) - partial selection from start to focus
243 Focus,
244 /// This node is between anchor and focus - fully selected
245 InBetween,
246 /// Anchor and focus are in the same node - partial selection between cursors
247 AnchorAndFocus,
248}
249
250/// Complete selection state spanning potentially multiple DOM nodes.
251///
252/// This implements the W3C Selection API model with anchor/focus endpoints.
253/// The selection can span multiple IFC roots (e.g., multiple `<p>` elements).
254///
255/// ## Storage Model
256///
257/// Uses `BTreeMap<NodeId, SelectionRange>` for O(log N) lookup during rendering.
258/// The key is the **IFC root NodeId**, and the value is the `SelectionRange` for that IFC.
259///
260/// ## Example
261///
262/// ```text
263/// <p id="1">Hello [World</p> <- Anchor in IFC 1, partial selection
264/// <p id="2">Complete line</p> <- InBetween, fully selected
265/// <p id="3">Partial] end</p> <- Focus in IFC 3, partial selection
266/// ```
267#[derive(Debug, Clone, PartialEq)]
268pub struct TextSelection {
269 /// The DOM this selection belongs to.
270 pub dom_id: DomId,
271
272 /// The anchor point - where the selection started (fixed during drag).
273 pub anchor: SelectionAnchor,
274
275 /// The focus point - where the selection currently ends (moves during drag).
276 pub focus: SelectionFocus,
277
278 /// Map from IFC root NodeId to the SelectionRange for that IFC.
279 /// This allows O(log N) lookup during rendering.
280 ///
281 /// The `SelectionRange` contains the actual `TextCursor` positions for that IFC,
282 /// ready to be passed to `UnifiedLayout::get_selection_rects()`.
283 pub affected_nodes: BTreeMap<NodeId, SelectionRange>,
284
285 /// Indicates whether anchor comes before focus in document order.
286 /// True = forward selection (left-to-right), False = backward selection.
287 pub is_forward: bool,
288}
289
290impl TextSelection {
291 /// Create a new collapsed selection (cursor) at the given position.
292 pub fn new_collapsed(
293 dom_id: DomId,
294 ifc_root_node_id: NodeId,
295 cursor: TextCursor,
296 char_bounds: LogicalRect,
297 mouse_position: LogicalPosition,
298 ) -> Self {
299 let anchor = SelectionAnchor {
300 ifc_root_node_id,
301 cursor,
302 char_bounds,
303 mouse_position,
304 };
305
306 let focus = SelectionFocus {
307 ifc_root_node_id,
308 cursor,
309 mouse_position,
310 };
311
312 // For a collapsed selection, the anchor node has a zero-width range
313 let mut affected_nodes = BTreeMap::new();
314 affected_nodes.insert(ifc_root_node_id, SelectionRange {
315 start: cursor,
316 end: cursor,
317 });
318
319 TextSelection {
320 dom_id,
321 anchor,
322 focus,
323 affected_nodes,
324 is_forward: true, // Direction doesn't matter for collapsed selection
325 }
326 }
327
328 /// Check if this is a collapsed selection (cursor with no range).
329 pub fn is_collapsed(&self) -> bool {
330 self.anchor.ifc_root_node_id == self.focus.ifc_root_node_id
331 && self.anchor.cursor == self.focus.cursor
332 }
333
334 /// Get the selection range for a specific IFC root node.
335 /// Returns `None` if this node is not part of the selection.
336 pub fn get_range_for_node(&self, ifc_root_node_id: &NodeId) -> Option<&SelectionRange> {
337 self.affected_nodes.get(ifc_root_node_id)
338 }
339
340 /// Check if a specific IFC root node is part of this selection.
341 pub fn contains_node(&self, ifc_root_node_id: &NodeId) -> bool {
342 self.affected_nodes.contains_key(ifc_root_node_id)
343 }
344
345 /// Get the selection type for a specific node.
346 pub fn get_node_selection_type(&self, ifc_root_node_id: &NodeId) -> Option<NodeSelectionType> {
347 if !self.affected_nodes.contains_key(ifc_root_node_id) {
348 return None;
349 }
350
351 let is_anchor = *ifc_root_node_id == self.anchor.ifc_root_node_id;
352 let is_focus = *ifc_root_node_id == self.focus.ifc_root_node_id;
353
354 Some(match (is_anchor, is_focus) {
355 (true, true) => NodeSelectionType::AnchorAndFocus,
356 (true, false) => NodeSelectionType::Anchor,
357 (false, true) => NodeSelectionType::Focus,
358 (false, false) => NodeSelectionType::InBetween,
359 })
360 }
361}
362
363impl_option!(
364 TextSelection,
365 OptionTextSelection,
366 copy = false,
367 clone = false,
368 [Debug, Clone, PartialEq]
369);