Skip to main content

azul_layout/managers/
selection.rs

1//! Text selection state management
2//!
3//! Manages text selection ranges across all DOMs using the browser-style
4//! anchor/focus model for multi-node selection.
5
6use alloc::collections::BTreeMap;
7use alloc::vec::Vec;
8use core::time::Duration;
9
10use azul_core::{
11    dom::{DomId, DomNodeId, NodeId},
12    events::SelectionManagerQuery,
13    geom::{LogicalPosition, LogicalRect},
14    selection::{
15        Selection, SelectionAnchor, SelectionFocus, SelectionRange, SelectionState, SelectionVec,
16        TextCursor, TextSelection,
17    },
18};
19use azul_css::{impl_option, impl_option_inner, AzString, OptionString};
20
21/// Click state for detecting double/triple clicks
22#[derive(Debug, Clone, PartialEq)]
23pub struct ClickState {
24    /// Last clicked node
25    pub last_node: Option<DomNodeId>,
26    /// Last click position
27    pub last_position: LogicalPosition,
28    /// Last click time (as milliseconds since some epoch)
29    pub last_time_ms: u64,
30    /// Current click count (1=single, 2=double, 3=triple)
31    pub click_count: u8,
32}
33
34impl Default for ClickState {
35    fn default() -> Self {
36        Self {
37            last_node: None,
38            last_position: LogicalPosition { x: 0.0, y: 0.0 },
39            last_time_ms: 0,
40            click_count: 0,
41        }
42    }
43}
44
45/// Manager for text selections across all DOMs
46///
47/// This manager supports both the legacy per-node selection model and the new
48/// browser-style anchor/focus model for multi-node selection.
49#[derive(Debug, Clone, PartialEq)]
50pub struct SelectionManager {
51    /// Legacy selection state for each DOM (per-node model)
52    /// Maps DomId -> SelectionState
53    /// TODO: Deprecate once multi-node selection is fully implemented
54    pub selections: BTreeMap<DomId, SelectionState>,
55    
56    /// New multi-node selection state using anchor/focus model
57    /// Maps DomId -> TextSelection
58    pub text_selections: BTreeMap<DomId, TextSelection>,
59    
60    /// Click state for multi-click detection
61    pub click_state: ClickState,
62}
63
64impl Default for SelectionManager {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl SelectionManager {
71    /// Multi-click timeout in milliseconds
72    pub const MULTI_CLICK_TIMEOUT_MS: u64 = 500;
73    /// Multi-click maximum distance in pixels
74    pub const MULTI_CLICK_DISTANCE_PX: f32 = 5.0;
75
76    /// Create a new selection manager
77    pub fn new() -> Self {
78        Self {
79            selections: BTreeMap::new(),
80            text_selections: BTreeMap::new(),
81            click_state: ClickState::default(),
82        }
83    }
84
85    /// Update click count based on position and time
86    /// Returns the new click count (1=single, 2=double, 3=triple)
87    pub fn update_click_count(
88        &mut self,
89        node_id: DomNodeId,
90        position: LogicalPosition,
91        current_time_ms: u64,
92    ) -> u8 {
93        // Check if this is part of multi-click sequence
94        let should_increment = if let Some(last_node) = self.click_state.last_node {
95            if last_node != node_id {
96                return self.reset_click_count(node_id, position, current_time_ms);
97            }
98
99            let time_delta = current_time_ms.saturating_sub(self.click_state.last_time_ms);
100            if time_delta >= Self::MULTI_CLICK_TIMEOUT_MS {
101                return self.reset_click_count(node_id, position, current_time_ms);
102            }
103
104            let dx = position.x - self.click_state.last_position.x;
105            let dy = position.y - self.click_state.last_position.y;
106            let distance = (dx * dx + dy * dy).sqrt();
107            if distance >= Self::MULTI_CLICK_DISTANCE_PX {
108                return self.reset_click_count(node_id, position, current_time_ms);
109            }
110
111            true
112        } else {
113            false
114        };
115
116        let click_count = if should_increment {
117            // Cycle: 1 -> 2 -> 3 -> 1
118            let new_count = self.click_state.click_count + 1;
119            if new_count > 3 {
120                1
121            } else {
122                new_count
123            }
124        } else {
125            1
126        };
127
128        self.click_state = ClickState {
129            last_node: Some(node_id),
130            last_position: position,
131            last_time_ms: current_time_ms,
132            click_count,
133        };
134
135        click_count
136    }
137
138    /// Reset click count to 1 (new click sequence)
139    fn reset_click_count(
140        &mut self,
141        node_id: DomNodeId,
142        position: LogicalPosition,
143        current_time_ms: u64,
144    ) -> u8 {
145        self.click_state = ClickState {
146            last_node: Some(node_id),
147            last_position: position,
148            last_time_ms: current_time_ms,
149            click_count: 1,
150        };
151        1
152    }
153
154    /// Get the selection state for a DOM
155    pub fn get_selection(&self, dom_id: &DomId) -> Option<&SelectionState> {
156        self.selections.get(dom_id)
157    }
158
159    /// Get mutable selection state for a DOM
160    pub fn get_selection_mut(&mut self, dom_id: &DomId) -> Option<&mut SelectionState> {
161        self.selections.get_mut(dom_id)
162    }
163
164    /// Set the selection state for a DOM
165    pub fn set_selection(&mut self, dom_id: DomId, selection: SelectionState) {
166        self.selections.insert(dom_id, selection);
167    }
168
169    /// Set a single cursor for a DOM, replacing all existing selections
170    pub fn set_cursor(&mut self, dom_id: DomId, node_id: DomNodeId, cursor: TextCursor) {
171        let state = SelectionState {
172            selections: vec![Selection::Cursor(cursor)].into(),
173            node_id,
174        };
175        self.selections.insert(dom_id, state);
176    }
177
178    /// Set a selection range for a DOM, replacing all existing selections
179    pub fn set_range(&mut self, dom_id: DomId, node_id: DomNodeId, range: SelectionRange) {
180        let state = SelectionState {
181            selections: vec![Selection::Range(range)].into(),
182            node_id,
183        };
184        self.selections.insert(dom_id, state);
185    }
186
187    /// Add a selection to an existing selection state (for multi-cursor support)
188    pub fn add_selection(&mut self, dom_id: DomId, node_id: DomNodeId, selection: Selection) {
189        self.selections
190            .entry(dom_id)
191            .or_insert_with(|| SelectionState {
192                selections: SelectionVec::from_const_slice(&[]),
193                node_id,
194            })
195            .add(selection);
196    }
197
198    /// Clear the selection for a DOM
199    pub fn clear_selection(&mut self, dom_id: &DomId) {
200        self.selections.remove(dom_id);
201    }
202
203    /// Clear all selections
204    pub fn clear_all(&mut self) {
205        self.selections.clear();
206    }
207
208    /// Get all selections
209    pub fn get_all_selections(&self) -> &BTreeMap<DomId, SelectionState> {
210        &self.selections
211    }
212
213    /// Check if any DOM has an active selection
214    pub fn has_any_selection(&self) -> bool {
215        !self.selections.is_empty()
216    }
217
218    /// Check if a specific DOM has a selection
219    pub fn has_selection(&self, dom_id: &DomId) -> bool {
220        self.selections.contains_key(dom_id)
221    }
222
223    /// Get the primary cursor for a DOM (first cursor in selection list)
224    pub fn get_primary_cursor(&self, dom_id: &DomId) -> Option<TextCursor> {
225        self.selections
226            .get(dom_id)?
227            .selections
228            .as_slice()
229            .first()
230            .and_then(|s| match s {
231                Selection::Cursor(c) => Some(c.clone()),
232                // Primary cursor is at the end of selection
233                Selection::Range(r) => Some(r.end.clone()),
234            })
235    }
236
237    /// Get all selection ranges for a DOM (excludes plain cursors)
238    pub fn get_ranges(&self, dom_id: &DomId) -> alloc::vec::Vec<SelectionRange> {
239        self.selections
240            .get(dom_id)
241            .map(|state| {
242                state
243                    .selections
244                    .as_slice()
245                    .iter()
246                    .filter_map(|s| match s {
247                        Selection::Range(r) => Some(r.clone()),
248                        Selection::Cursor(_) => None,
249                    })
250                    .collect()
251            })
252            .unwrap_or_default()
253    }
254
255    /// Analyze a click event and return what type of text selection should be performed
256    ///
257    /// This is used by the event system to determine if a click should trigger
258    /// text selection (single/double/triple click).
259    ///
260    /// ## Returns
261    ///
262    /// - `Some(1)` - Single click (place cursor)
263    /// - `Some(2)` - Double click (select word)
264    /// - `Some(3)` - Triple click (select paragraph/line)
265    /// - `None` - Not a text selection click (click count > 3 or timeout/distance exceeded)
266    pub fn analyze_click_for_selection(
267        &self,
268        node_id: DomNodeId,
269        position: LogicalPosition,
270        current_time_ms: u64,
271    ) -> Option<u8> {
272        let click_state = &self.click_state;
273
274        // Check if this continues a multi-click sequence
275        if let Some(last_node) = click_state.last_node {
276            if last_node != node_id {
277                return Some(1); // Different node = new single click
278            }
279
280            let time_delta = current_time_ms.saturating_sub(click_state.last_time_ms);
281            if time_delta >= Self::MULTI_CLICK_TIMEOUT_MS {
282                return Some(1); // Timeout = new single click
283            }
284
285            let dx = position.x - click_state.last_position.x;
286            let dy = position.y - click_state.last_position.y;
287            let distance = (dx * dx + dy * dy).sqrt();
288            if distance >= Self::MULTI_CLICK_DISTANCE_PX {
289                return Some(1); // Too far = new single click
290            }
291        } else {
292            return Some(1); // No previous click = single click
293        }
294
295        // Continue multi-click sequence
296        let next_count = click_state.click_count + 1;
297        if next_count > 3 {
298            Some(1) // Cycle back to single click
299        } else {
300            Some(next_count)
301        }
302    }
303    
304    // ========================================================================
305    // NEW: Anchor/Focus model for multi-node selection
306    // ========================================================================
307    
308    /// Start a new text selection with an anchor point.
309    ///
310    /// This is called on MouseDown. It creates a collapsed selection (cursor)
311    /// at the anchor position. The focus will be updated during drag.
312    ///
313    /// ## Parameters
314    /// * `dom_id` - The DOM this selection belongs to
315    /// * `ifc_root_node_id` - The IFC root node where the click occurred
316    /// * `cursor` - The cursor position within the IFC's UnifiedLayout
317    /// * `char_bounds` - Visual bounds of the clicked character
318    /// * `mouse_position` - Mouse position in viewport coordinates
319    pub fn start_selection(
320        &mut self,
321        dom_id: DomId,
322        ifc_root_node_id: NodeId,
323        cursor: TextCursor,
324        char_bounds: LogicalRect,
325        mouse_position: LogicalPosition,
326    ) {
327        let selection = TextSelection::new_collapsed(
328            dom_id,
329            ifc_root_node_id,
330            cursor,
331            char_bounds,
332            mouse_position,
333        );
334        self.text_selections.insert(dom_id, selection);
335    }
336    
337    /// Update the focus point of an ongoing selection.
338    ///
339    /// This is called during MouseMove/Drag. It updates the focus position
340    /// and recomputes the affected nodes between anchor and focus.
341    ///
342    /// ## Parameters
343    /// * `dom_id` - The DOM this selection belongs to
344    /// * `ifc_root_node_id` - The IFC root node where the focus is now
345    /// * `cursor` - The cursor position within the IFC's UnifiedLayout
346    /// * `mouse_position` - Current mouse position in viewport coordinates
347    /// * `affected_nodes` - Pre-computed map of affected IFC roots to their SelectionRanges
348    /// * `is_forward` - Whether anchor comes before focus in document order
349    ///
350    /// ## Returns
351    /// * `true` if the selection was updated
352    /// * `false` if no selection exists for this DOM
353    pub fn update_selection_focus(
354        &mut self,
355        dom_id: &DomId,
356        ifc_root_node_id: NodeId,
357        cursor: TextCursor,
358        mouse_position: LogicalPosition,
359        affected_nodes: BTreeMap<NodeId, SelectionRange>,
360        is_forward: bool,
361    ) -> bool {
362        if let Some(selection) = self.text_selections.get_mut(dom_id) {
363            selection.focus = SelectionFocus {
364                ifc_root_node_id,
365                cursor,
366                mouse_position,
367            };
368            selection.affected_nodes = affected_nodes;
369            selection.is_forward = is_forward;
370            true
371        } else {
372            false
373        }
374    }
375    
376    /// Get the current text selection for a DOM.
377    pub fn get_text_selection(&self, dom_id: &DomId) -> Option<&TextSelection> {
378        self.text_selections.get(dom_id)
379    }
380    
381    /// Get mutable reference to the current text selection for a DOM.
382    pub fn get_text_selection_mut(&mut self, dom_id: &DomId) -> Option<&mut TextSelection> {
383        self.text_selections.get_mut(dom_id)
384    }
385    
386    /// Check if a DOM has an active text selection (new model).
387    pub fn has_text_selection(&self, dom_id: &DomId) -> bool {
388        self.text_selections.contains_key(dom_id)
389    }
390    
391    /// Get the selection range for a specific IFC root node.
392    ///
393    /// This is used by the renderer to quickly look up if a node is selected
394    /// and get its selection range for `get_selection_rects()`.
395    ///
396    /// ## Parameters
397    /// * `dom_id` - The DOM to check
398    /// * `ifc_root_node_id` - The IFC root node to look up
399    ///
400    /// ## Returns
401    /// * `Some(&SelectionRange)` if this node is part of the selection
402    /// * `None` if not selected
403    pub fn get_range_for_ifc_root(
404        &self,
405        dom_id: &DomId,
406        ifc_root_node_id: &NodeId,
407    ) -> Option<&SelectionRange> {
408        self.text_selections
409            .get(dom_id)?
410            .get_range_for_node(ifc_root_node_id)
411    }
412    
413    /// Clear the text selection for a DOM (new model).
414    pub fn clear_text_selection(&mut self, dom_id: &DomId) {
415        self.text_selections.remove(dom_id);
416    }
417    
418    /// Clear all text selections (new model).
419    pub fn clear_all_text_selections(&mut self) {
420        self.text_selections.clear();
421    }
422    
423    /// Get all text selections.
424    pub fn get_all_text_selections(&self) -> &BTreeMap<DomId, TextSelection> {
425        &self.text_selections
426    }
427}
428
429// Clipboard Content Extraction
430
431/// Styled text run for rich clipboard content
432#[derive(Debug, Clone, PartialEq)]
433#[repr(C)]
434pub struct StyledTextRun {
435    /// The actual text content
436    pub text: AzString,
437    /// Font family name
438    pub font_family: OptionString,
439    /// Font size in pixels
440    pub font_size_px: f32,
441    /// Text color
442    pub color: azul_css::props::basic::ColorU,
443    /// Whether text is bold
444    pub is_bold: bool,
445    /// Whether text is italic
446    pub is_italic: bool,
447}
448
449azul_css::impl_vec!(
450    StyledTextRun,
451    StyledTextRunVec,
452    StyledTextRunVecDestructor,
453    StyledTextRunVecDestructorType
454);
455azul_css::impl_vec_debug!(StyledTextRun, StyledTextRunVec);
456azul_css::impl_vec_clone!(StyledTextRun, StyledTextRunVec, StyledTextRunVecDestructor);
457azul_css::impl_vec_partialeq!(StyledTextRun, StyledTextRunVec);
458
459/// Clipboard content with both plain text and styled (HTML) representation
460#[derive(Debug, Clone, PartialEq)]
461#[repr(C)]
462pub struct ClipboardContent {
463    /// Plain text representation (UTF-8)
464    pub plain_text: AzString,
465    /// Rich text runs with styling information
466    pub styled_runs: StyledTextRunVec,
467}
468
469impl_option!(
470    ClipboardContent,
471    OptionClipboardContent,
472    copy = false,
473    [Debug, Clone, PartialEq]
474);
475
476impl ClipboardContent {
477    /// Convert styled runs to HTML for rich clipboard formats
478    pub fn to_html(&self) -> String {
479        let mut html = String::from("<div>");
480
481        for run in self.styled_runs.as_slice() {
482            html.push_str("<span style=\"");
483
484            if let Some(font_family) = run.font_family.as_ref() {
485                html.push_str(&format!("font-family: {}; ", font_family.as_str()));
486            }
487            html.push_str(&format!("font-size: {}px; ", run.font_size_px));
488            html.push_str(&format!(
489                "color: rgba({}, {}, {}, {}); ",
490                run.color.r,
491                run.color.g,
492                run.color.b,
493                run.color.a as f32 / 255.0
494            ));
495            if run.is_bold {
496                html.push_str("font-weight: bold; ");
497            }
498            if run.is_italic {
499                html.push_str("font-style: italic; ");
500            }
501
502            html.push_str("\">");
503            // Escape HTML entities
504            let escaped = run
505                .text
506                .as_str()
507                .replace('&', "&amp;")
508                .replace('<', "&lt;")
509                .replace('>', "&gt;");
510            html.push_str(&escaped);
511            html.push_str("</span>");
512        }
513
514        html.push_str("</div>");
515        html
516    }
517}
518
519// Trait Implementations for Event Filtering
520
521impl SelectionManagerQuery for SelectionManager {
522    fn get_click_count(&self) -> u8 {
523        self.click_state.click_count
524    }
525
526    fn get_drag_start_position(&self) -> Option<LogicalPosition> {
527        // If left mouse button is down and we have a last click position,
528        // that's our drag start position
529        if self.click_state.click_count > 0 {
530            Some(self.click_state.last_position)
531        } else {
532            None
533        }
534    }
535
536    fn has_selection(&self) -> bool {
537        // Check if any selection exists via:
538        //
539        // 1. Click count > 0 (single/double/triple click created selection)
540        // 2. Drag start position exists (drag selection in progress)
541        // 3. Any DOM has non-empty selection state
542
543        if self.click_state.click_count > 0 {
544            return true;
545        }
546
547        // Check if any DOM has an active selection
548        for (_dom_id, selection_state) in &self.selections {
549            if !selection_state.selections.is_empty() {
550                return true;
551            }
552        }
553
554        false
555    }
556}
557
558impl SelectionManager {
559    /// Remap NodeIds after DOM reconciliation
560    ///
561    /// When the DOM is regenerated, NodeIds can change. This method updates all
562    /// internal state to use the new NodeIds based on the provided mapping.
563    pub fn remap_node_ids(
564        &mut self,
565        dom_id: DomId,
566        node_id_map: &std::collections::BTreeMap<azul_core::dom::NodeId, azul_core::dom::NodeId>,
567    ) {
568        use azul_core::styled_dom::NodeHierarchyItemId;
569        
570        // Update legacy selection state
571        if let Some(selection_state) = self.selections.get_mut(&dom_id) {
572            if let Some(old_node_id) = selection_state.node_id.node.into_crate_internal() {
573                if let Some(&new_node_id) = node_id_map.get(&old_node_id) {
574                    selection_state.node_id.node = NodeHierarchyItemId::from_crate_internal(Some(new_node_id));
575                } else {
576                    // Node was removed, clear selection for this DOM
577                    self.selections.remove(&dom_id);
578                    return;
579                }
580            }
581        }
582        
583        // Update text_selections (new multi-node model)
584        if let Some(text_selection) = self.text_selections.get_mut(&dom_id) {
585            // Update anchor ifc_root_node_id
586            let old_anchor_id = text_selection.anchor.ifc_root_node_id;
587            if let Some(&new_node_id) = node_id_map.get(&old_anchor_id) {
588                text_selection.anchor.ifc_root_node_id = new_node_id;
589            } else {
590                // Anchor node removed, clear selection
591                self.text_selections.remove(&dom_id);
592                return;
593            }
594            
595            // Update focus ifc_root_node_id
596            let old_focus_id = text_selection.focus.ifc_root_node_id;
597            if let Some(&new_node_id) = node_id_map.get(&old_focus_id) {
598                text_selection.focus.ifc_root_node_id = new_node_id;
599            } else {
600                // Focus node removed, clear selection
601                self.text_selections.remove(&dom_id);
602                return;
603            }
604            
605            // Update affected_nodes map with remapped NodeIds
606            let old_affected: Vec<_> = text_selection.affected_nodes.keys().cloned().collect();
607            let mut new_affected = std::collections::BTreeMap::new();
608            for old_node_id in old_affected {
609                if let Some(&new_node_id) = node_id_map.get(&old_node_id) {
610                    if let Some(range) = text_selection.affected_nodes.remove(&old_node_id) {
611                        new_affected.insert(new_node_id, range);
612                    }
613                }
614            }
615            text_selection.affected_nodes = new_affected;
616        }
617        
618        // Update click_state last_node if it's in the affected DOM
619        if let Some(last_node) = &mut self.click_state.last_node {
620            if last_node.dom == dom_id {
621                if let Some(old_node_id) = last_node.node.into_crate_internal() {
622                    if let Some(&new_node_id) = node_id_map.get(&old_node_id) {
623                        last_node.node = NodeHierarchyItemId::from_crate_internal(Some(new_node_id));
624                    } else {
625                        // Node removed, reset click state
626                        self.click_state = ClickState::default();
627                    }
628                }
629            }
630        }
631    }
632}