Skip to main content

azul_layout/managers/
selection.rs

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