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

            
8
use alloc::collections::BTreeMap;
9
use alloc::vec::Vec;
10
use core::time::Duration;
11

            
12
use 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
};
21
use azul_css::{impl_option, impl_option_inner, AzString, OptionString};
22

            
23
/// Click state for detecting double/triple clicks
24
#[derive(Debug, Clone, PartialEq)]
25
pub 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

            
36
impl Default for ClickState {
37
175
    fn default() -> Self {
38
175
        Self {
39
175
            last_node: None,
40
175
            last_position: LogicalPosition { x: 0.0, y: 0.0 },
41
175
            last_time_ms: 0,
42
175
            click_count: 0,
43
175
        }
44
175
    }
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)]
52
pub 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

            
66
impl Default for SelectionManager {
67
    fn default() -> Self {
68
        Self::new()
69
    }
70
}
71

            
72
impl 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
5
    pub fn new() -> Self {
80
5
        Self {
81
5
            selections: BTreeMap::new(),
82
5
            text_selections: BTreeMap::new(),
83
5
            click_state: ClickState::default(),
84
5
        }
85
5
    }
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
770
    pub fn get_selection(&self, dom_id: &DomId) -> Option<&SelectionState> {
158
770
        self.selections.get(dom_id)
159
770
    }
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
490
    pub fn set_selection(&mut self, dom_id: DomId, selection: SelectionState) {
168
490
        self.selections.insert(dom_id, selection);
169
490
    }
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
210
    pub fn clear_all(&mut self) {
207
210
        self.selections.clear();
208
210
    }
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
6
    pub fn has_any_selection(&self) -> bool {
217
6
        !self.selections.is_empty()
218
6
    }
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)]
436
pub 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

            
451
azul_css::impl_option!(StyledTextRun, OptionStyledTextRun, copy = false, [Debug, Clone, PartialEq]);
452
azul_css::impl_vec!(StyledTextRun, StyledTextRunVec, StyledTextRunVecDestructor, StyledTextRunVecDestructorType, StyledTextRunVecSlice, OptionStyledTextRun);
453
azul_css::impl_vec_debug!(StyledTextRun, StyledTextRunVec);
454
azul_css::impl_vec_clone!(StyledTextRun, StyledTextRunVec, StyledTextRunVecDestructor);
455
azul_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)]
460
pub 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

            
467
impl_option!(
468
    ClipboardContent,
469
    OptionClipboardContent,
470
    copy = false,
471
    [Debug, Clone, PartialEq]
472
);
473

            
474
impl 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

            
519
impl 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

            
556
impl 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
}