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