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_option!(StyledTextRun, OptionStyledTextRun, copy = false, [Debug, Clone, PartialEq]);
450azul_css::impl_vec!(StyledTextRun, StyledTextRunVec, StyledTextRunVecDestructor, StyledTextRunVecDestructorType, StyledTextRunVecSlice, OptionStyledTextRun);
451azul_css::impl_vec_debug!(StyledTextRun, StyledTextRunVec);
452azul_css::impl_vec_clone!(StyledTextRun, StyledTextRunVec, StyledTextRunVecDestructor);
453azul_css::impl_vec_partialeq!(StyledTextRun, StyledTextRunVec);
454
455/// Clipboard content with both plain text and styled (HTML) representation
456#[derive(Debug, Clone, PartialEq)]
457#[repr(C)]
458pub struct ClipboardContent {
459    /// Plain text representation (UTF-8)
460    pub plain_text: AzString,
461    /// Rich text runs with styling information
462    pub styled_runs: StyledTextRunVec,
463}
464
465impl_option!(
466    ClipboardContent,
467    OptionClipboardContent,
468    copy = false,
469    [Debug, Clone, PartialEq]
470);
471
472impl ClipboardContent {
473    /// Convert styled runs to HTML for rich clipboard formats
474    pub fn to_html(&self) -> String {
475        let mut html = String::from("<div>");
476
477        for run in self.styled_runs.as_slice() {
478            html.push_str("<span style=\"");
479
480            if let Some(font_family) = run.font_family.as_ref() {
481                html.push_str(&format!("font-family: {}; ", font_family.as_str()));
482            }
483            html.push_str(&format!("font-size: {}px; ", run.font_size_px));
484            html.push_str(&format!(
485                "color: rgba({}, {}, {}, {}); ",
486                run.color.r,
487                run.color.g,
488                run.color.b,
489                run.color.a as f32 / 255.0
490            ));
491            if run.is_bold {
492                html.push_str("font-weight: bold; ");
493            }
494            if run.is_italic {
495                html.push_str("font-style: italic; ");
496            }
497
498            html.push_str("\">");
499            // Escape HTML entities
500            let escaped = run
501                .text
502                .as_str()
503                .replace('&', "&amp;")
504                .replace('<', "&lt;")
505                .replace('>', "&gt;");
506            html.push_str(&escaped);
507            html.push_str("</span>");
508        }
509
510        html.push_str("</div>");
511        html
512    }
513}
514
515// Trait Implementations for Event Filtering
516
517impl SelectionManagerQuery for SelectionManager {
518    fn get_click_count(&self) -> u8 {
519        self.click_state.click_count
520    }
521
522    fn get_drag_start_position(&self) -> Option<LogicalPosition> {
523        // If left mouse button is down and we have a last click position,
524        // that's our drag start position
525        if self.click_state.click_count > 0 {
526            Some(self.click_state.last_position)
527        } else {
528            None
529        }
530    }
531
532    fn has_selection(&self) -> bool {
533        // Check if any selection exists via:
534        //
535        // 1. Click count > 0 (single/double/triple click created selection)
536        // 2. Drag start position exists (drag selection in progress)
537        // 3. Any DOM has non-empty selection state
538
539        if self.click_state.click_count > 0 {
540            return true;
541        }
542
543        // Check if any DOM has an active selection
544        for (_dom_id, selection_state) in &self.selections {
545            if !selection_state.selections.is_empty() {
546                return true;
547            }
548        }
549
550        false
551    }
552}
553
554impl SelectionManager {
555    /// Remap NodeIds after DOM reconciliation
556    ///
557    /// When the DOM is regenerated, NodeIds can change. This method updates all
558    /// internal state to use the new NodeIds based on the provided mapping.
559    pub fn remap_node_ids(
560        &mut self,
561        dom_id: DomId,
562        node_id_map: &std::collections::BTreeMap<azul_core::dom::NodeId, azul_core::dom::NodeId>,
563    ) {
564        use azul_core::styled_dom::NodeHierarchyItemId;
565        
566        // Update legacy selection state
567        if let Some(selection_state) = self.selections.get_mut(&dom_id) {
568            if let Some(old_node_id) = selection_state.node_id.node.into_crate_internal() {
569                if let Some(&new_node_id) = node_id_map.get(&old_node_id) {
570                    selection_state.node_id.node = NodeHierarchyItemId::from_crate_internal(Some(new_node_id));
571                } else {
572                    // Node was removed, clear selection for this DOM
573                    self.selections.remove(&dom_id);
574                    return;
575                }
576            }
577        }
578        
579        // Update text_selections (new multi-node model)
580        if let Some(text_selection) = self.text_selections.get_mut(&dom_id) {
581            // Update anchor ifc_root_node_id
582            let old_anchor_id = text_selection.anchor.ifc_root_node_id;
583            if let Some(&new_node_id) = node_id_map.get(&old_anchor_id) {
584                text_selection.anchor.ifc_root_node_id = new_node_id;
585            } else {
586                // Anchor node removed, clear selection
587                self.text_selections.remove(&dom_id);
588                return;
589            }
590            
591            // Update focus ifc_root_node_id
592            let old_focus_id = text_selection.focus.ifc_root_node_id;
593            if let Some(&new_node_id) = node_id_map.get(&old_focus_id) {
594                text_selection.focus.ifc_root_node_id = new_node_id;
595            } else {
596                // Focus node removed, clear selection
597                self.text_selections.remove(&dom_id);
598                return;
599            }
600            
601            // Update affected_nodes map with remapped NodeIds
602            let old_affected: Vec<_> = text_selection.affected_nodes.keys().cloned().collect();
603            let mut new_affected = std::collections::BTreeMap::new();
604            for old_node_id in old_affected {
605                if let Some(&new_node_id) = node_id_map.get(&old_node_id) {
606                    if let Some(range) = text_selection.affected_nodes.remove(&old_node_id) {
607                        new_affected.insert(new_node_id, range);
608                    }
609                }
610            }
611            text_selection.affected_nodes = new_affected;
612        }
613        
614        // Update click_state last_node if it's in the affected DOM
615        if let Some(last_node) = &mut self.click_state.last_node {
616            if last_node.dom == dom_id {
617                if let Some(old_node_id) = last_node.node.into_crate_internal() {
618                    if let Some(&new_node_id) = node_id_map.get(&old_node_id) {
619                        last_node.node = NodeHierarchyItemId::from_crate_internal(Some(new_node_id));
620                    } else {
621                        // Node removed, reset click state
622                        self.click_state = ClickState::default();
623                    }
624                }
625            }
626        }
627    }
628}