Skip to main content

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);