Skip to main content

astrelis_ui/
event.rs

1//! Event handling system for UI interactions.
2
3use crate::tree::{NodeId, UiTree};
4#[cfg(feature = "docking")]
5use crate::widgets::docking::animation::GhostGroupAnimation;
6#[cfg(feature = "docking")]
7use crate::widgets::docking::operations::{
8    DockOperation, MergeTabGroupOperation, MoveTabGroupOperation, SplitContainerOperation,
9    TransferTabOperation,
10};
11#[cfg(feature = "docking")]
12use crate::widgets::docking::{
13    DockSplitter, DockTabs, DragType, DropPreviewAnimation, DropTarget, GhostTabAnimation,
14};
15use crate::widgets::scroll_container::ScrollContainer;
16use astrelis_core::alloc::HashSet;
17use astrelis_core::math::Vec2;
18use astrelis_core::profiling::profile_function;
19use astrelis_winit::event::{ElementState, Event, EventBatch, HandleStatus, PhysicalKey};
20
21/// UI event types.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum UiEvent {
24    /// Mouse entered widget bounds.
25    MouseEnter,
26    /// Mouse left widget bounds.
27    MouseLeave,
28    /// Mouse button pressed on widget.
29    MouseDown,
30    /// Mouse button released on widget.
31    MouseUp,
32    /// Widget was clicked.
33    Click,
34    /// Focus gained.
35    FocusGained,
36    /// Focus lost.
37    FocusLost,
38}
39
40/// Mouse button state.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42pub enum MouseButton {
43    Left,
44    Right,
45    Middle,
46}
47
48/// UI event handling system.
49pub struct UiEventSystem {
50    /// Currently hovered node.
51    hovered: Option<NodeId>,
52    /// Currently focused node.
53    focused: Option<NodeId>,
54    /// Node with active tooltip (planned feature).
55    #[allow(dead_code)]
56    tooltip_node: Option<NodeId>,
57    /// Current mouse position.
58    mouse_pos: Vec2,
59    /// Pressed mouse buttons.
60    mouse_buttons: HashSet<MouseButton>,
61    /// Nodes that were pressed this frame.
62    pressed_nodes: HashSet<NodeId>,
63}
64
65/// Re-export `CrossContainerPreview` from the docking plugin.
66#[cfg(feature = "docking")]
67pub use crate::widgets::docking::plugin::CrossContainerPreview;
68
69impl UiEventSystem {
70    /// Create a new event system.
71    pub fn new() -> Self {
72        Self {
73            hovered: None,
74            focused: None,
75            tooltip_node: None,
76            mouse_pos: Vec2::ZERO,
77            mouse_buttons: HashSet::new(),
78            pressed_nodes: HashSet::new(),
79        }
80    }
81
82    /// Get currently hovered node.
83    pub fn hovered(&self) -> Option<NodeId> {
84        self.hovered
85    }
86
87    /// Get currently focused node.
88    pub fn focused(&self) -> Option<NodeId> {
89        self.focused
90    }
91
92    /// Get current mouse position.
93    pub fn mouse_position(&self) -> Vec2 {
94        self.mouse_pos
95    }
96
97    /// Check if a mouse button is pressed.
98    pub fn is_button_pressed(&self, button: MouseButton) -> bool {
99        self.mouse_buttons.contains(&button)
100    }
101
102    /// Set focus to a specific node.
103    pub fn set_focus(&mut self, node_id: Option<NodeId>) {
104        if self.focused != node_id {
105            self.focused = node_id;
106        }
107    }
108
109    /// Invalidate any event system references to nodes that no longer exist in the tree.
110    ///
111    /// Called after operations that remove nodes (e.g., collapse_empty_container)
112    /// to prevent stale NodeId references from causing lookups on deleted nodes.
113    fn invalidate_removed_nodes(
114        &mut self,
115        tree: &UiTree,
116        plugins: &mut crate::plugin::PluginManager,
117    ) {
118        if let Some(id) = self.hovered
119            && !tree.node_exists(id)
120        {
121            self.hovered = None;
122        }
123        if let Some(id) = self.focused
124            && !tree.node_exists(id)
125        {
126            self.focused = None;
127        }
128        #[cfg(feature = "docking")]
129        if let Some(dp) = plugins.get_mut::<crate::widgets::docking::plugin::DockingPlugin>() {
130            dp.invalidate_removed_nodes(tree);
131        }
132        if let Some(sp) = plugins.get_mut::<crate::scroll_plugin::ScrollPlugin>() {
133            sp.invalidate_removed_nodes(tree);
134        }
135        self.pressed_nodes.retain(|id| tree.node_exists(*id));
136    }
137
138    /// Handle events from the event batch (without plugin access).
139    ///
140    /// Prefer [`handle_events_with_plugins`](Self::handle_events_with_plugins) when a PluginManager is available.
141    pub fn handle_events(&mut self, events: &mut EventBatch, tree: &mut UiTree) {
142        // When called without plugins, create a temporary PluginManager.
143        // Docking features won't work without the DockingPlugin, but core events still function.
144        let mut pm = crate::plugin::PluginManager::new();
145        self.handle_events_with_plugins(events, tree, &mut pm);
146    }
147
148    /// Handle events from the event batch with plugin access.
149    ///
150    /// Docking state is accessed from the [`DockingPlugin`](crate::widgets::docking::plugin::DockingPlugin)
151    /// in the plugin manager.
152    pub fn handle_events_with_plugins(
153        &mut self,
154        events: &mut EventBatch,
155        tree: &mut UiTree,
156        plugins: &mut crate::plugin::PluginManager,
157    ) {
158        profile_function!();
159        events.dispatch(|event| match event {
160            Event::MouseMoved(pos) => {
161                self.mouse_pos = Vec2::new(pos.x as f32, pos.y as f32);
162                self.update_hover(tree, plugins);
163                HandleStatus::consumed()
164            }
165            Event::MouseButtonDown(button) => {
166                self.handle_mouse_input(*button, true, tree, plugins);
167                HandleStatus::consumed()
168            }
169            Event::MouseButtonUp(button) => {
170                self.handle_mouse_input(*button, false, tree, plugins);
171                HandleStatus::consumed()
172            }
173            Event::MouseScrolled(delta) => {
174                self.handle_scroll_event(delta, tree, plugins);
175                HandleStatus::consumed()
176            }
177            Event::PanGesture(gesture) => {
178                self.handle_pan_gesture(gesture, tree, plugins);
179                HandleStatus::consumed()
180            }
181            Event::KeyInput(key_event) => {
182                if key_event.state == ElementState::Pressed {
183                    // Handle text input from key event
184                    if let Some(ref text) = key_event.text {
185                        for c in text.chars() {
186                            self.handle_char_input(c, tree, plugins);
187                        }
188                    }
189                    // Handle special keys
190                    self.handle_key_input(&key_event.physical_key, tree, plugins);
191                }
192                HandleStatus::consumed()
193            }
194            _ => HandleStatus::ignored(),
195        });
196    }
197
198    /// Handle mouse input events.
199    fn handle_mouse_input(
200        &mut self,
201        button: astrelis_winit::event::MouseButton,
202        pressed: bool,
203        tree: &mut UiTree,
204        plugins: &mut crate::plugin::PluginManager,
205    ) {
206        let mouse_button = match button {
207            astrelis_winit::event::MouseButton::Left => MouseButton::Left,
208            astrelis_winit::event::MouseButton::Right => MouseButton::Right,
209            astrelis_winit::event::MouseButton::Middle => MouseButton::Middle,
210            _ => return,
211        };
212
213        if pressed {
214            self.mouse_buttons.insert(mouse_button);
215
216            #[cfg(feature = "docking")]
217            {
218                let mut docking_needs_invalidate = false;
219                if let Some(dp) =
220                    plugins.get_mut::<crate::widgets::docking::plugin::DockingPlugin>()
221                {
222                    // Check for splitter separator press first
223                    if mouse_button == MouseButton::Left {
224                        if let Some(splitter_node) = dp.hovered_splitter {
225                            // Start splitter drag
226                            if let Some(widget) = tree.get_widget(splitter_node)
227                                && let Some(splitter) =
228                                    widget.as_any().downcast_ref::<DockSplitter>()
229                            {
230                                dp.drag_manager.start_splitter_drag(
231                                    splitter_node,
232                                    splitter.direction,
233                                    self.mouse_pos,
234                                    splitter.split_ratio,
235                                );
236                                // Mark the splitter as dragging
237                                if let Some(widget) = tree.get_widget_mut(splitter_node)
238                                    && let Some(splitter) =
239                                        widget.as_any_mut().downcast_mut::<DockSplitter>()
240                                {
241                                    splitter.set_separator_dragging(true);
242                                    tree.mark_dirty_flags(
243                                        splitter_node,
244                                        crate::dirty::DirtyFlags::COLOR,
245                                    );
246                                }
247                                return; // Don't process further
248                            }
249                        }
250
251                        // Check for scrollbar thumb click
252                        if let Some(hovered_id) = self.hovered
253                            && let Some(widget) = tree.get_widget(hovered_id)
254                            && let Some(tabs) = widget.as_any().downcast_ref::<DockTabs>()
255                            && tabs.should_show_scrollbar()
256                        {
257                            let layout = tree.get_layout(hovered_id).unwrap();
258                            let abs_layout = self.get_absolute_layout(tree, hovered_id, layout);
259
260                            if tabs.hit_test_scrollbar_thumb(self.mouse_pos, &abs_layout) {
261                                // Start scrollbar drag
262                                if let Some(widget) = tree.get_widget_mut(hovered_id)
263                                    && let Some(tabs) =
264                                        widget.as_any_mut().downcast_mut::<DockTabs>()
265                                {
266                                    tabs.start_scrollbar_drag(self.mouse_pos.x, &abs_layout);
267                                    dp.scrollbar_drag_node = Some(hovered_id);
268                                    tree.mark_dirty_flags(
269                                        hovered_id,
270                                        crate::dirty::DirtyFlags::GEOMETRY,
271                                    );
272                                }
273                                return;
274                            }
275                        }
276
277                        // Check for tab click
278                        if let Some(hovered_id) = self.hovered
279                            && let Some(widget) = tree.get_widget(hovered_id)
280                            && let Some(tabs) = widget.as_any().downcast_ref::<DockTabs>()
281                        {
282                            let layout = tree.get_layout(hovered_id).unwrap();
283                            let abs_layout = self.get_absolute_layout(tree, hovered_id, layout);
284
285                            // Check for close button click first
286                            if let Some(close_idx) =
287                                tabs.hit_test_close_button(self.mouse_pos, &abs_layout)
288                            {
289                                // Close the tab
290                                if let Some(widget) = tree.get_widget_mut(hovered_id)
291                                    && let Some(tabs) =
292                                        widget.as_any_mut().downcast_mut::<DockTabs>()
293                                {
294                                    tabs.close_tab(close_idx);
295                                    tree.mark_dirty_flags(
296                                        hovered_id,
297                                        crate::dirty::DirtyFlags::LAYOUT,
298                                    );
299                                }
300
301                                // Collapse empty container if last tab was closed
302                                if let Err(e) =
303                                    crate::widgets::docking::operations::collapse_empty_container(
304                                        tree, hovered_id,
305                                    )
306                                {
307                                    tracing::warn!("Failed to collapse empty container: {}", e);
308                                }
309
310                                // Invalidate references to removed nodes
311                                docking_needs_invalidate = true;
312                                dp.docking_context.invalidate();
313
314                                // dp scope will end, invalidate_removed_nodes called below
315                            } else {
316                                // Check for tab click - start potential drag
317                                if let Some(tab_idx) =
318                                    tabs.hit_test_tab(self.mouse_pos, &abs_layout)
319                                {
320                                    // Start potential tab drag (will become active after threshold)
321                                    dp.drag_manager.start_tab_drag(
322                                        hovered_id,
323                                        tab_idx,
324                                        self.mouse_pos,
325                                    );
326                                    return;
327                                }
328
329                                // Check for tab bar background click - start potential group drag
330                                if tabs.hit_test_tab_bar_background(self.mouse_pos, &abs_layout) {
331                                    dp.drag_manager
332                                        .start_tab_group_drag(hovered_id, self.mouse_pos);
333                                    return;
334                                }
335                            }
336                        }
337                    }
338                }
339                if docking_needs_invalidate {
340                    self.invalidate_removed_nodes(tree, plugins);
341                    return;
342                }
343            }
344
345            // Check for ScrollContainer scrollbar thumb click
346            if mouse_button == MouseButton::Left
347                && let Some(hovered_id) = self.hovered
348                && let Some(widget) = tree.get_widget(hovered_id)
349                && let Some(sc) = widget.as_any().downcast_ref::<ScrollContainer>()
350                && (sc.should_show_v_scrollbar() || sc.should_show_h_scrollbar())
351            {
352                let layout = tree.get_layout(hovered_id).unwrap();
353                let abs_layout = self.get_absolute_layout(tree, hovered_id, layout);
354
355                if sc.hit_test_v_thumb(self.mouse_pos, &abs_layout) {
356                    if let Some(widget) = tree.get_widget_mut(hovered_id)
357                        && let Some(sc) = widget.as_any_mut().downcast_mut::<ScrollContainer>()
358                    {
359                        sc.start_v_drag(self.mouse_pos.y, &abs_layout);
360                        if let Some(sp) = plugins.get_mut::<crate::scroll_plugin::ScrollPlugin>() {
361                            sp.scroll_container_drag = Some((hovered_id, true));
362                        }
363                        tree.mark_dirty_flags(hovered_id, crate::dirty::DirtyFlags::GEOMETRY);
364                    }
365                    return;
366                }
367
368                if sc.hit_test_h_thumb(self.mouse_pos, &abs_layout) {
369                    if let Some(widget) = tree.get_widget_mut(hovered_id)
370                        && let Some(sc) = widget.as_any_mut().downcast_mut::<ScrollContainer>()
371                    {
372                        sc.start_h_drag(self.mouse_pos.x, &abs_layout);
373                        if let Some(sp) = plugins.get_mut::<crate::scroll_plugin::ScrollPlugin>() {
374                            sp.scroll_container_drag = Some((hovered_id, false));
375                        }
376                        tree.mark_dirty_flags(hovered_id, crate::dirty::DirtyFlags::GEOMETRY);
377                    }
378                    return;
379                }
380            }
381
382            // Handle press on hovered widget
383            if let Some(hovered_id) = self.hovered {
384                self.pressed_nodes.insert(hovered_id);
385
386                // Update widget press state via registry
387                let on_press = tree
388                    .get_widget(hovered_id)
389                    .map(|w| w.as_any().type_id())
390                    .and_then(|tid| plugins.widget_registry().get(tid))
391                    .and_then(|desc| desc.on_press);
392                if let Some(on_press) = on_press
393                    && let Some(widget) = tree.get_widget_mut(hovered_id)
394                {
395                    on_press(widget.as_any_mut(), true);
396                    tree.mark_dirty_flags(hovered_id, crate::dirty::DirtyFlags::COLOR);
397                }
398            }
399        } else {
400            self.mouse_buttons.remove(&mouse_button);
401
402            // Handle scrollbar drag end
403            #[cfg(feature = "docking")]
404            {
405                if let Some(dp) =
406                    plugins.get_mut::<crate::widgets::docking::plugin::DockingPlugin>()
407                    && mouse_button == MouseButton::Left
408                    && let Some(node) = dp.scrollbar_drag_node.take()
409                {
410                    if let Some(widget) = tree.get_widget_mut(node)
411                        && let Some(tabs) = widget.as_any_mut().downcast_mut::<DockTabs>()
412                    {
413                        tabs.end_scrollbar_drag();
414                        tree.mark_dirty_flags(node, crate::dirty::DirtyFlags::GEOMETRY);
415                    }
416                    return;
417                }
418            }
419
420            // Handle ScrollContainer scrollbar drag end
421            if mouse_button == MouseButton::Left {
422                let drag = plugins
423                    .get_mut::<crate::scroll_plugin::ScrollPlugin>()
424                    .and_then(|sp| sp.scroll_container_drag.take());
425                if let Some((node, is_vertical)) = drag {
426                    if let Some(widget) = tree.get_widget_mut(node)
427                        && let Some(sc) = widget.as_any_mut().downcast_mut::<ScrollContainer>()
428                    {
429                        if is_vertical {
430                            sc.end_v_drag();
431                        } else {
432                            sc.end_h_drag();
433                        }
434                        tree.mark_dirty_flags(node, crate::dirty::DirtyFlags::GEOMETRY);
435                    }
436                    return;
437                }
438            }
439
440            // Handle drag end
441            #[cfg(feature = "docking")]
442            {
443                let mut drag_needs_invalidate = false;
444                let mut drag_handled = false;
445                if let Some(dp) =
446                    plugins.get_mut::<crate::widgets::docking::plugin::DockingPlugin>()
447                    && mouse_button == MouseButton::Left
448                    && (dp.drag_manager.is_dragging() || dp.drag_manager.has_pending_drag())
449                {
450                    drag_handled = true;
451                    let (drag_state_opt, drop_target) = dp.drag_manager.end_drag();
452                    if let Some(drag_state) = drag_state_opt {
453                        match drag_state.drag_type {
454                            DragType::SplitterResize { splitter_node, .. } => {
455                                // Clear dragging state
456                                if let Some(widget) = tree.get_widget_mut(splitter_node)
457                                    && let Some(splitter) =
458                                        widget.as_any_mut().downcast_mut::<DockSplitter>()
459                                {
460                                    splitter.set_separator_dragging(false);
461                                    tree.mark_dirty_flags(
462                                        splitter_node,
463                                        crate::dirty::DirtyFlags::COLOR,
464                                    );
465                                }
466                            }
467                            DragType::TabGroupDrag { tabs_node } => {
468                                if drag_state.is_active
469                                    && let Some(drop_target) = drop_target
470                                {
471                                    if drop_target.container_id != tabs_node {
472                                        if drop_target.is_center_drop() {
473                                            // Merge all tabs into target
474                                            let insert_index =
475                                                drop_target.insert_index.unwrap_or(0);
476                                            let mut op = MergeTabGroupOperation::new(
477                                                tabs_node,
478                                                drop_target.container_id,
479                                                insert_index,
480                                            );
481                                            if let Err(e) = op.execute(tree) {
482                                                tracing::warn!("Tab group merge failed: {}", e);
483                                            } else {
484                                                tracing::debug!(
485                                                    "Merged tab group from {:?} into {:?} at index {}",
486                                                    tabs_node,
487                                                    drop_target.container_id,
488                                                    insert_index
489                                                );
490                                            }
491
492                                            // Collapse empty source container
493                                            if let Err(e) =
494                                                    crate::widgets::docking::operations::collapse_empty_container(
495                                                        tree, tabs_node,
496                                                    )
497                                                {
498                                                    tracing::warn!(
499                                                        "Failed to collapse empty container: {}",
500                                                        e
501                                                    );
502                                                }
503                                        } else if drop_target.is_edge_drop() {
504                                            // Move entire group to edge
505                                            let mut op = MoveTabGroupOperation::new(
506                                                tabs_node,
507                                                drop_target.container_id,
508                                                drop_target.zone,
509                                            );
510                                            if let Err(e) = op.execute(tree) {
511                                                tracing::warn!("Tab group move failed: {}", e);
512                                            } else {
513                                                tracing::debug!(
514                                                    "Moved tab group {:?} to {:?} edge {:?}",
515                                                    tabs_node,
516                                                    drop_target.container_id,
517                                                    drop_target.zone
518                                                );
519                                            }
520                                        }
521
522                                        drag_needs_invalidate = true;
523                                        dp.docking_context.invalidate();
524                                    }
525
526                                    // Clear cross-container preview
527                                    if let Some(old_preview) = dp.cross_container_preview.take() {
528                                        tree.mark_dirty_flags(
529                                            old_preview.target_node,
530                                            crate::dirty::DirtyFlags::GEOMETRY,
531                                        );
532                                    }
533                                }
534                                // If not active, it was a click on tab bar background — no action needed
535                            }
536                            DragType::TabDrag {
537                                tabs_node,
538                                tab_index,
539                            } => {
540                                if drag_state.is_active {
541                                    // Check for cross-container drop first
542                                    if let Some(drop_target) = drop_target {
543                                        if drop_target.container_id != tabs_node
544                                            && drop_target.is_center_drop()
545                                        {
546                                            // Cancel drag state BEFORE execute (node may be removed by collapse)
547                                            if let Some(widget) = tree.get_widget_mut(tabs_node)
548                                                && let Some(tabs) =
549                                                    widget.as_any_mut().downcast_mut::<DockTabs>()
550                                            {
551                                                tabs.cancel_tab_drag();
552                                            }
553
554                                            // Execute cross-container transfer
555                                            let insert_index =
556                                                drop_target.insert_index.unwrap_or(0);
557                                            let mut op = TransferTabOperation::new(
558                                                tabs_node,
559                                                drop_target.container_id,
560                                                tab_index,
561                                                insert_index,
562                                            );
563
564                                            if let Err(e) = op.execute(tree) {
565                                                tracing::warn!(
566                                                    "Cross-container tab transfer failed: {}",
567                                                    e
568                                                );
569                                            } else {
570                                                tracing::debug!(
571                                                    "Transferred tab {} from {:?} to {:?} at index {}",
572                                                    tab_index,
573                                                    tabs_node,
574                                                    drop_target.container_id,
575                                                    insert_index
576                                                );
577
578                                                // Make the transferred tab active in the target
579                                                if let Some(widget) =
580                                                    tree.get_widget_mut(drop_target.container_id)
581                                                    && let Some(target_tabs) = widget
582                                                        .as_any_mut()
583                                                        .downcast_mut::<DockTabs>(
584                                                    )
585                                                {
586                                                    target_tabs.set_active_tab(insert_index);
587                                                }
588                                            }
589
590                                            // Collapse empty source container after transfer
591                                            if let Err(e) =
592                                                    crate::widgets::docking::operations::collapse_empty_container(
593                                                        tree, tabs_node,
594                                                    )
595                                                {
596                                                    tracing::warn!(
597                                                        "Failed to collapse empty container: {}",
598                                                        e
599                                                    );
600                                                }
601
602                                            // Invalidate references to removed nodes
603                                            drag_needs_invalidate = true;
604                                            dp.docking_context.invalidate();
605
606                                            // Mark both containers dirty
607                                            tree.mark_dirty_flags(
608                                                drop_target.container_id,
609                                                crate::dirty::DirtyFlags::LAYOUT,
610                                            );
611                                            // Only mark source if it still exists
612                                            if tree.node_exists(tabs_node) {
613                                                tree.mark_dirty_flags(
614                                                    tabs_node,
615                                                    crate::dirty::DirtyFlags::LAYOUT,
616                                                );
617                                            }
618                                        } else if drop_target.is_edge_drop() {
619                                            // Edge drop: create split (same or different container)
620                                            // Cancel drag state BEFORE execute (node may be removed by collapse)
621                                            if let Some(widget) = tree.get_widget_mut(tabs_node)
622                                                && let Some(tabs) =
623                                                    widget.as_any_mut().downcast_mut::<DockTabs>()
624                                            {
625                                                tabs.cancel_tab_drag();
626                                            }
627
628                                            let mut op = SplitContainerOperation::new(
629                                                tabs_node,
630                                                drop_target.container_id,
631                                                tab_index,
632                                                drop_target.zone,
633                                            );
634
635                                            if let Err(e) = op.execute(tree) {
636                                                tracing::warn!("Edge-zone split failed: {}", e);
637                                            } else {
638                                                tracing::debug!(
639                                                    "Split container: tab {} from {:?} to {:?} edge {:?}",
640                                                    tab_index,
641                                                    tabs_node,
642                                                    drop_target.container_id,
643                                                    drop_target.zone
644                                                );
645                                            }
646
647                                            // Collapse empty source container after split
648                                            // (no-op for same-container splits since source keeps N-1 >= 1 tabs)
649                                            if tree.node_exists(tabs_node)
650                                                    && let Err(e) =
651                                                        crate::widgets::docking::operations::collapse_empty_container(
652                                                            tree, tabs_node,
653                                                        )
654                                                {
655                                                    tracing::warn!(
656                                                        "Failed to collapse empty container: {}",
657                                                        e
658                                                    );
659                                                }
660
661                                            // Invalidate references to removed nodes
662                                            drag_needs_invalidate = true;
663                                            dp.docking_context.invalidate();
664                                        } else {
665                                            // Fallback: cancel drag
666                                            if let Some(widget) = tree.get_widget_mut(tabs_node)
667                                                && let Some(tabs) =
668                                                    widget.as_any_mut().downcast_mut::<DockTabs>()
669                                            {
670                                                tabs.cancel_tab_drag();
671                                            }
672                                            tree.mark_dirty_flags(
673                                                tabs_node,
674                                                crate::dirty::DirtyFlags::LAYOUT,
675                                            );
676                                        }
677                                    } else {
678                                        // No cross-container target: complete within-container reordering
679                                        let all_children = if let Some(widget) =
680                                            tree.get_widget_mut(tabs_node)
681                                            && let Some(tabs) =
682                                                widget.as_any_mut().downcast_mut::<DockTabs>()
683                                        {
684                                            let children = tabs.children.clone();
685                                            tabs.finish_tab_drag();
686                                            children
687                                        } else {
688                                            Vec::new()
689                                        };
690
691                                        let mut batch: Vec<(
692                                            crate::tree::NodeId,
693                                            crate::dirty::DirtyFlags,
694                                        )> = Vec::with_capacity(1 + all_children.len());
695                                        batch.push((tabs_node, crate::dirty::DirtyFlags::LAYOUT));
696                                        for child in all_children {
697                                            batch.push((child, crate::dirty::DirtyFlags::LAYOUT));
698                                        }
699                                        tree.mark_dirty_batch(&batch);
700                                    }
701
702                                    // Clear cross-container preview
703                                    if let Some(old_preview) = dp.cross_container_preview.take() {
704                                        tree.mark_dirty_flags(
705                                            old_preview.target_node,
706                                            crate::dirty::DirtyFlags::GEOMETRY,
707                                        );
708                                    }
709                                } else {
710                                    // Treat as click (switch active tab)
711                                    let (old_active_child, new_active_child) = if let Some(widget) =
712                                        tree.get_widget_mut(tabs_node)
713                                        && let Some(tabs) =
714                                            widget.as_any_mut().downcast_mut::<DockTabs>()
715                                    {
716                                        let old_child = tabs.children.get(tabs.active_tab).copied();
717                                        tabs.set_active_tab(tab_index);
718                                        let new_child = tabs.children.get(tabs.active_tab).copied();
719                                        (old_child, new_child)
720                                    } else {
721                                        (None, None)
722                                    };
723
724                                    tree.mark_dirty_flags(
725                                        tabs_node,
726                                        crate::dirty::DirtyFlags::LAYOUT,
727                                    );
728
729                                    if let Some(old_child) = old_active_child {
730                                        tree.mark_dirty_flags(
731                                            old_child,
732                                            crate::dirty::DirtyFlags::LAYOUT,
733                                        );
734                                    }
735
736                                    if let Some(new_child) = new_active_child {
737                                        tree.mark_dirty_flags(
738                                            new_child,
739                                            crate::dirty::DirtyFlags::LAYOUT,
740                                        );
741                                    }
742                                }
743                            }
744                            _ => {}
745                        }
746                    }
747
748                    // Fade out docking animations on drag end
749                    if let Some(ref mut ghost) = dp.dock_animations.ghost_tab {
750                        ghost.fade_out();
751                    }
752                    if let Some(ref mut ghost) = dp.dock_animations.ghost_group {
753                        ghost.fade_out();
754                    }
755                    if let Some(ref mut preview) = dp.dock_animations.drop_preview {
756                        preview.fade_out();
757                    }
758                }
759                if drag_needs_invalidate {
760                    self.invalidate_removed_nodes(tree, plugins);
761                }
762                if drag_handled {
763                    return;
764                }
765            }
766
767            // Handle release - check if it's a click
768            if let Some(hovered_id) = self.hovered
769                && self.pressed_nodes.contains(&hovered_id)
770            {
771                // This is a click!
772                self.dispatch_click(hovered_id, tree, plugins);
773            }
774
775            // Clear pressed state on ALL previously pressed nodes
776            // (handles release outside button, drag-away scenarios)
777            let mut dirty_batch: Vec<(crate::tree::NodeId, crate::dirty::DirtyFlags)> = Vec::new();
778            for &pressed_node_id in &self.pressed_nodes {
779                let on_press = tree
780                    .get_widget(pressed_node_id)
781                    .map(|w| w.as_any().type_id())
782                    .and_then(|tid| plugins.widget_registry().get(tid))
783                    .and_then(|desc| desc.on_press);
784                if let Some(on_press) = on_press
785                    && let Some(widget) = tree.get_widget_mut(pressed_node_id)
786                {
787                    on_press(widget.as_any_mut(), false);
788                    dirty_batch.push((pressed_node_id, crate::dirty::DirtyFlags::COLOR));
789                }
790            }
791            tree.mark_dirty_batch(&dirty_batch);
792
793            self.pressed_nodes.clear();
794        }
795    }
796
797    /// Get absolute layout for a node.
798    fn get_absolute_layout(
799        &self,
800        tree: &UiTree,
801        node_id: NodeId,
802        layout: crate::tree::LayoutRect,
803    ) -> crate::tree::LayoutRect {
804        // Calculate absolute position by traversing parents
805        let mut abs_x = layout.x;
806        let mut abs_y = layout.y;
807
808        if let Some(node) = tree.get_node(node_id) {
809            let mut parent = node.parent;
810            while let Some(parent_id) = parent {
811                if let Some(parent_layout) = tree.get_layout(parent_id) {
812                    abs_x += parent_layout.x;
813                    abs_y += parent_layout.y;
814                }
815                // Subtract scroll offset if parent is a ScrollContainer
816                if let Some(parent_widget) = tree.get_widget(parent_id)
817                    && let Some(sc) = parent_widget.as_any().downcast_ref::<ScrollContainer>()
818                {
819                    abs_x -= sc.scroll_offset.x;
820                    abs_y -= sc.scroll_offset.y;
821                }
822                if let Some(parent_node) = tree.get_node(parent_id) {
823                    parent = parent_node.parent;
824                } else {
825                    break;
826                }
827            }
828        }
829
830        crate::tree::LayoutRect {
831            x: abs_x,
832            y: abs_y,
833            width: layout.width,
834            height: layout.height,
835        }
836    }
837
838    /// Update hover state based on current mouse position.
839    fn update_hover(&mut self, tree: &mut UiTree, plugins: &mut crate::plugin::PluginManager) {
840        // If we're dragging the scrollbar thumb, update scroll offset
841        #[cfg(feature = "docking")]
842        {
843            if let Some(dp) = plugins.get_mut::<crate::widgets::docking::plugin::DockingPlugin>()
844                && let Some(node) = dp.scrollbar_drag_node
845            {
846                if let Some(layout) = tree.get_layout(node) {
847                    let abs_layout = self.get_absolute_layout(tree, node, layout);
848                    if let Some(widget) = tree.get_widget_mut(node)
849                        && let Some(tabs) = widget.as_any_mut().downcast_mut::<DockTabs>()
850                    {
851                        tabs.update_scrollbar_drag(self.mouse_pos.x, &abs_layout);
852                        tree.mark_dirty_flags(node, crate::dirty::DirtyFlags::GEOMETRY);
853                    }
854                }
855                return;
856            }
857        }
858
859        // If we're dragging a ScrollContainer scrollbar thumb, update scroll offset
860        let sc_drag = plugins
861            .get::<crate::scroll_plugin::ScrollPlugin>()
862            .and_then(|sp| sp.scroll_container_drag);
863        if let Some((node, is_vertical)) = sc_drag {
864            if let Some(layout) = tree.get_layout(node) {
865                let abs_layout = self.get_absolute_layout(tree, node, layout);
866                if let Some(widget) = tree.get_widget_mut(node)
867                    && let Some(sc) = widget.as_any_mut().downcast_mut::<ScrollContainer>()
868                {
869                    if is_vertical {
870                        sc.update_v_drag(self.mouse_pos.y, &abs_layout);
871                    } else {
872                        sc.update_h_drag(self.mouse_pos.x, &abs_layout);
873                    }
874                    tree.mark_dirty_flags(node, crate::dirty::DirtyFlags::GEOMETRY);
875                }
876            }
877            return;
878        }
879
880        // If we're dragging, update the drag state instead of hover
881        #[cfg(feature = "docking")]
882        {
883            if let Some(dp) = plugins.get_mut::<crate::widgets::docking::plugin::DockingPlugin>()
884                && (dp.drag_manager.is_dragging() || dp.drag_manager.has_pending_drag())
885            {
886                dp.drag_manager.update(self.mouse_pos);
887
888                // Apply drag if active
889                if let Some(drag_state) = dp.drag_manager.drag_state() {
890                    if drag_state.is_active {
891                        match drag_state.drag_type {
892                            DragType::SplitterResize {
893                                splitter_node,
894                                direction: _,
895                            } => {
896                                let delta = drag_state.delta();
897                                let original_ratio = drag_state.original_value;
898
899                                if let Some(layout) = tree.get_layout(splitter_node) {
900                                    let abs_layout =
901                                        self.get_absolute_layout(tree, splitter_node, layout);
902                                    if let Some(widget) = tree.get_widget_mut(splitter_node)
903                                        && let Some(splitter) =
904                                            widget.as_any_mut().downcast_mut::<DockSplitter>()
905                                    {
906                                        // Apply delta to the original ratio, not the current one
907                                        splitter.apply_drag_delta_from_original(
908                                            delta,
909                                            &abs_layout,
910                                            original_ratio,
911                                        );
912                                        tree.mark_dirty_flags(
913                                            splitter_node,
914                                            crate::dirty::DirtyFlags::LAYOUT,
915                                        );
916                                    }
917                                }
918                            }
919                            DragType::TabGroupDrag { tabs_node } => {
920                                // Tab group drag: always look for cross-container targets
921                                // (no within-container reorder path for groups)
922
923                                // Create ghost group animation if not already active
924                                if dp.dock_animations.ghost_group.is_none() {
925                                    let labels: Vec<String> = tree
926                                        .get_widget(tabs_node)
927                                        .and_then(|w| w.as_any().downcast_ref::<DockTabs>())
928                                        .map(|t| t.tab_labels.clone())
929                                        .unwrap_or_default();
930                                    let total_width: f32 =
931                                        labels.iter().map(|l| l.len() as f32 * 8.0 + 20.0).sum();
932                                    let group_size = Vec2::new(total_width.min(300.0), 28.0);
933                                    let mut ghost = GhostGroupAnimation::new(
934                                        self.mouse_pos,
935                                        group_size,
936                                        labels,
937                                    );
938                                    ghost.set_target(self.mouse_pos);
939                                    dp.dock_animations.ghost_group = Some(ghost);
940                                } else if let Some(ref mut ghost) = dp.dock_animations.ghost_group {
941                                    ghost.set_target(self.mouse_pos);
942                                }
943
944                                // Find cross-container drop targets (same logic as TabDrag but skipping source)
945                                if dp.docking_context.is_dirty() {
946                                    dp.docking_context.rebuild_cache(tree);
947                                }
948                                let all_tabs: Vec<_> = dp
949                                    .docking_context
950                                    .find_tab_containers(tree)
951                                    .iter()
952                                    .map(|(&id, info)| (id, info.layout, info.tab_count))
953                                    .collect();
954
955                                let mut found_target = false;
956                                for (candidate_id, candidate_layout, _) in &all_tabs {
957                                    // Skip source container entirely
958                                    if *candidate_id == tabs_node {
959                                        continue;
960                                    }
961
962                                    if let Some(mut zone) = dp
963                                        .drop_zone_detector
964                                        .detect_zone(self.mouse_pos, *candidate_layout)
965                                    {
966                                        // Remap to Center if cursor is in the tab bar area
967                                        // (dropping on tab bar = merge, not edge split)
968                                        if let Some(widget) = tree.get_widget(*candidate_id)
969                                            && let Some(tabs) =
970                                                widget.as_any().downcast_ref::<DockTabs>()
971                                        {
972                                            let tab_bar_bottom =
973                                                candidate_layout.y + tabs.theme.tab_bar_height;
974                                            if self.mouse_pos.y < tab_bar_bottom {
975                                                zone = crate::widgets::docking::DockZone::Center;
976                                            }
977                                        }
978
979                                        let preview_bounds = dp
980                                            .drop_zone_detector
981                                            .preview_bounds(zone, *candidate_layout);
982
983                                        let insert_index = if matches!(
984                                            zone,
985                                            crate::widgets::docking::DockZone::Center
986                                        ) {
987                                            if let Some(widget) = tree.get_widget(*candidate_id)
988                                                && let Some(target_tabs) =
989                                                    widget.as_any().downcast_ref::<DockTabs>()
990                                            {
991                                                Some(target_tabs.tab_count())
992                                            } else {
993                                                None
994                                            }
995                                        } else {
996                                            None
997                                        };
998
999                                        let preview = CrossContainerPreview {
1000                                            target_node: *candidate_id,
1001                                            target_layout: *candidate_layout,
1002                                            zone,
1003                                            preview_bounds,
1004                                            insert_index,
1005                                        };
1006
1007                                        if let Some(old_preview) = &dp.cross_container_preview
1008                                            && old_preview.target_node != *candidate_id
1009                                        {
1010                                            tree.mark_dirty_flags(
1011                                                old_preview.target_node,
1012                                                crate::dirty::DirtyFlags::GEOMETRY,
1013                                            );
1014                                        }
1015
1016                                        dp.cross_container_preview = Some(preview);
1017                                        dp.drag_manager.set_drop_target(
1018                                            DropTarget::new(*candidate_id, zone)
1019                                                .with_insert_index(insert_index.unwrap_or(0)),
1020                                        );
1021                                        tree.mark_dirty_flags(
1022                                            *candidate_id,
1023                                            crate::dirty::DirtyFlags::GEOMETRY,
1024                                        );
1025
1026                                        if let Some(ref mut anim) = dp.dock_animations.drop_preview
1027                                        {
1028                                            anim.set_target(preview_bounds);
1029                                        } else {
1030                                            dp.dock_animations.drop_preview =
1031                                                Some(DropPreviewAnimation::new(preview_bounds));
1032                                        }
1033                                        found_target = true;
1034                                        break;
1035                                    }
1036                                }
1037
1038                                if !found_target {
1039                                    if let Some(old_preview) = dp.cross_container_preview.take() {
1040                                        tree.mark_dirty_flags(
1041                                            old_preview.target_node,
1042                                            crate::dirty::DirtyFlags::GEOMETRY,
1043                                        );
1044                                    }
1045                                    dp.drag_manager.clear_drop_target();
1046
1047                                    if let Some(ref mut anim) = dp.dock_animations.drop_preview {
1048                                        anim.fade_out();
1049                                    }
1050                                }
1051                            }
1052                            DragType::TabDrag {
1053                                tabs_node,
1054                                tab_index,
1055                            } => {
1056                                // Check if cursor is still within the source DockTabs
1057                                let source_contains_cursor =
1058                                    if let Some(layout) = tree.get_layout(tabs_node) {
1059                                        let abs_layout =
1060                                            self.get_absolute_layout(tree, tabs_node, layout);
1061                                        // Only check tab bar bounds for within-container reordering
1062                                        let tab_bar = crate::tree::LayoutRect {
1063                                            x: abs_layout.x,
1064                                            y: abs_layout.y,
1065                                            width: abs_layout.width,
1066                                            height: if let Some(widget) = tree.get_widget(tabs_node)
1067                                                && let Some(tabs) =
1068                                                    widget.as_any().downcast_ref::<DockTabs>()
1069                                            {
1070                                                tabs.theme.tab_bar_height
1071                                            } else {
1072                                                28.0
1073                                            },
1074                                        };
1075                                        tab_bar.contains(self.mouse_pos)
1076                                    } else {
1077                                        false
1078                                    };
1079
1080                                if source_contains_cursor {
1081                                    // Within source container: do within-container reordering
1082                                    dp.cross_container_preview = None;
1083                                    dp.drag_manager.clear_drop_target();
1084
1085                                    // Fade out ghost and drop preview when back in source container
1086                                    if let Some(ref mut ghost) = dp.dock_animations.ghost_tab {
1087                                        ghost.fade_out();
1088                                    }
1089                                    if let Some(ref mut preview) = dp.dock_animations.drop_preview {
1090                                        preview.fade_out();
1091                                    }
1092
1093                                    if let Some(layout) = tree.get_layout(tabs_node) {
1094                                        let abs_layout =
1095                                            self.get_absolute_layout(tree, tabs_node, layout);
1096                                        if let Some(widget) = tree.get_widget_mut(tabs_node)
1097                                            && let Some(tabs) =
1098                                                widget.as_any_mut().downcast_mut::<DockTabs>()
1099                                        {
1100                                            tabs.update_drop_target(self.mouse_pos, &abs_layout);
1101                                            tree.mark_dirty_flags(
1102                                                tabs_node,
1103                                                crate::dirty::DirtyFlags::GEOMETRY,
1104                                            );
1105                                        }
1106                                    }
1107                                } else {
1108                                    // Outside source container: look for cross-container drop targets
1109                                    // Clear within-container drop target
1110                                    if let Some(widget) = tree.get_widget_mut(tabs_node)
1111                                        && let Some(tabs) =
1112                                            widget.as_any_mut().downcast_mut::<DockTabs>()
1113                                    {
1114                                        tabs.drag.drag_drop_target = None;
1115                                        tabs.drag.drag_cursor_pos = None;
1116                                        tree.mark_dirty_flags(
1117                                            tabs_node,
1118                                            crate::dirty::DirtyFlags::GEOMETRY,
1119                                        );
1120                                    }
1121
1122                                    // Create ghost tab animation if not already active
1123                                    if dp.dock_animations.ghost_tab.is_none() {
1124                                        let label = tree
1125                                            .get_widget(tabs_node)
1126                                            .and_then(|w| w.as_any().downcast_ref::<DockTabs>())
1127                                            .and_then(|t| t.tab_labels.get(tab_index))
1128                                            .cloned()
1129                                            .unwrap_or_default();
1130                                        let tab_size = Vec2::new(
1131                                            label.len() as f32 * 8.0 + 20.0, // approximate tab width
1132                                            28.0,
1133                                        );
1134                                        let mut ghost =
1135                                            GhostTabAnimation::new(self.mouse_pos, tab_size, label);
1136                                        ghost.set_target(self.mouse_pos);
1137                                        dp.dock_animations.ghost_tab = Some(ghost);
1138                                    } else if let Some(ref mut ghost) = dp.dock_animations.ghost_tab
1139                                    {
1140                                        ghost.set_target(self.mouse_pos);
1141                                    }
1142
1143                                    // Find all DockTabs containers using cached registry
1144                                    // Rebuild cache if needed, then collect to avoid borrow conflicts
1145                                    if dp.docking_context.is_dirty() {
1146                                        dp.docking_context.rebuild_cache(tree);
1147                                    }
1148                                    let all_tabs: Vec<_> = dp
1149                                        .docking_context
1150                                        .find_tab_containers(tree)
1151                                        .iter()
1152                                        .map(|(&id, info)| (id, info.layout, info.tab_count))
1153                                        .collect();
1154
1155                                    let source_tab_count = all_tabs
1156                                        .iter()
1157                                        .find(|(id, _, _)| *id == tabs_node)
1158                                        .map(|(_, _, count)| *count)
1159                                        .unwrap_or(0);
1160
1161                                    let mut found_target = false;
1162                                    for (candidate_id, candidate_layout, _) in &all_tabs {
1163                                        // Check if cursor is over this container
1164                                        if let Some(mut zone) = dp
1165                                            .drop_zone_detector
1166                                            .detect_zone(self.mouse_pos, *candidate_layout)
1167                                        {
1168                                            // Remap to Center if cursor is in the tab bar area
1169                                            // (dropping on tab bar = merge, not edge split)
1170                                            if let Some(widget) = tree.get_widget(*candidate_id)
1171                                                && let Some(tabs) =
1172                                                    widget.as_any().downcast_ref::<DockTabs>()
1173                                            {
1174                                                let tab_bar_bottom =
1175                                                    candidate_layout.y + tabs.theme.tab_bar_height;
1176                                                if self.mouse_pos.y < tab_bar_bottom {
1177                                                    zone =
1178                                                        crate::widgets::docking::DockZone::Center;
1179                                                }
1180                                            }
1181
1182                                            // For the source container, only allow edge zones
1183                                            // (center = reorder, handled by within-container path)
1184                                            // and only when the source has 2+ tabs (can't split a single tab)
1185                                            if *candidate_id == tabs_node
1186                                                && (matches!(
1187                                                    zone,
1188                                                    crate::widgets::docking::DockZone::Center
1189                                                ) || source_tab_count < 2)
1190                                            {
1191                                                continue;
1192                                            }
1193                                            let preview_bounds = dp
1194                                                .drop_zone_detector
1195                                                .preview_bounds(zone, *candidate_layout);
1196
1197                                            // For center zone, compute insertion index
1198                                            let insert_index = if matches!(
1199                                                zone,
1200                                                crate::widgets::docking::DockZone::Center
1201                                            ) {
1202                                                if let Some(widget) = tree.get_widget(*candidate_id)
1203                                                    && let Some(target_tabs) =
1204                                                        widget.as_any().downcast_ref::<DockTabs>()
1205                                                {
1206                                                    let idx = target_tabs
1207                                                        .hit_test_tab(
1208                                                            self.mouse_pos,
1209                                                            candidate_layout,
1210                                                        )
1211                                                        .map(|i| i + 1)
1212                                                        .unwrap_or(target_tabs.tab_count());
1213                                                    Some(idx)
1214                                                } else {
1215                                                    None
1216                                                }
1217                                            } else {
1218                                                None
1219                                            };
1220
1221                                            let preview = CrossContainerPreview {
1222                                                target_node: *candidate_id,
1223                                                target_layout: *candidate_layout,
1224                                                zone,
1225                                                preview_bounds,
1226                                                insert_index,
1227                                            };
1228
1229                                            // Mark old target dirty if it changed
1230                                            if let Some(old_preview) = &dp.cross_container_preview
1231                                                && old_preview.target_node != *candidate_id
1232                                            {
1233                                                tree.mark_dirty_flags(
1234                                                    old_preview.target_node,
1235                                                    crate::dirty::DirtyFlags::GEOMETRY,
1236                                                );
1237                                            }
1238
1239                                            dp.cross_container_preview = Some(preview);
1240                                            dp.drag_manager.set_drop_target(
1241                                                DropTarget::new(*candidate_id, zone)
1242                                                    .with_insert_index(insert_index.unwrap_or(0)),
1243                                            );
1244                                            tree.mark_dirty_flags(
1245                                                *candidate_id,
1246                                                crate::dirty::DirtyFlags::GEOMETRY,
1247                                            );
1248
1249                                            // Update drop preview animation
1250                                            if let Some(ref mut anim) =
1251                                                dp.dock_animations.drop_preview
1252                                            {
1253                                                anim.set_target(preview_bounds);
1254                                            } else {
1255                                                dp.dock_animations.drop_preview =
1256                                                    Some(DropPreviewAnimation::new(preview_bounds));
1257                                            }
1258                                            found_target = true;
1259                                            break;
1260                                        }
1261                                    }
1262
1263                                    if !found_target {
1264                                        // Clear preview if cursor isn't over any target
1265                                        if let Some(old_preview) = dp.cross_container_preview.take()
1266                                        {
1267                                            tree.mark_dirty_flags(
1268                                                old_preview.target_node,
1269                                                crate::dirty::DirtyFlags::GEOMETRY,
1270                                            );
1271                                        }
1272                                        dp.drag_manager.clear_drop_target();
1273
1274                                        // Fade out drop preview animation
1275                                        if let Some(ref mut anim) = dp.dock_animations.drop_preview
1276                                        {
1277                                            anim.fade_out();
1278                                        }
1279                                    }
1280                                }
1281                            }
1282                            _ => {}
1283                        }
1284                    } else {
1285                        // Check if threshold exceeded to activate drag
1286                        if let DragType::TabDrag {
1287                            tabs_node,
1288                            tab_index,
1289                        } = drag_state.drag_type
1290                            && let Some(widget) = tree.get_widget_mut(tabs_node)
1291                            && let Some(tabs) = widget.as_any_mut().downcast_mut::<DockTabs>()
1292                        {
1293                            tabs.start_tab_drag(tab_index);
1294                            // Use GEOMETRY to ensure ghost rendering starts
1295                            tree.mark_dirty_flags(tabs_node, crate::dirty::DirtyFlags::GEOMETRY);
1296                        }
1297                    }
1298                }
1299                return;
1300            }
1301        }
1302
1303        let new_hovered = self.hit_test(tree, self.mouse_pos);
1304
1305        // Check for splitter separator hover
1306        #[cfg(feature = "docking")]
1307        {
1308            if let Some(dp) = plugins.get_mut::<crate::widgets::docking::plugin::DockingPlugin>() {
1309                let new_splitter_hover = self.find_hovered_splitter(tree, self.mouse_pos, dp);
1310
1311                // Update splitter hover state
1312                if new_splitter_hover != dp.hovered_splitter {
1313                    // Clear old splitter hover
1314                    if let Some(old_id) = dp.hovered_splitter
1315                        && let Some(widget) = tree.get_widget_mut(old_id)
1316                        && let Some(splitter) = widget.as_any_mut().downcast_mut::<DockSplitter>()
1317                    {
1318                        splitter.set_separator_hovered(false);
1319                        tree.mark_dirty_flags(old_id, crate::dirty::DirtyFlags::COLOR);
1320                    }
1321
1322                    // Set new splitter hover
1323                    if let Some(new_id) = new_splitter_hover
1324                        && let Some(widget) = tree.get_widget_mut(new_id)
1325                        && let Some(splitter) = widget.as_any_mut().downcast_mut::<DockSplitter>()
1326                    {
1327                        splitter.set_separator_hovered(true);
1328                        tree.mark_dirty_flags(new_id, crate::dirty::DirtyFlags::COLOR);
1329                    }
1330
1331                    dp.hovered_splitter = new_splitter_hover;
1332                }
1333
1334                // Update tab hover state
1335                if let Some(hovered_id) = new_hovered
1336                    && let Some(widget) = tree.get_widget(hovered_id)
1337                    && let Some(tabs) = widget.as_any().downcast_ref::<DockTabs>()
1338                {
1339                    let layout = tree.get_layout(hovered_id).unwrap();
1340                    let abs_layout = self.get_absolute_layout(tree, hovered_id, layout);
1341                    let new_tab_hover = tabs.hit_test_tab(self.mouse_pos, &abs_layout);
1342
1343                    // Read current hovered tab
1344                    let current_hover = tabs.hovered_tab;
1345
1346                    // Detect scrollbar thumb hover
1347                    let new_scrollbar_hover =
1348                        tabs.hit_test_scrollbar_thumb(self.mouse_pos, &abs_layout);
1349                    let current_scrollbar_hover = tabs.scrollbar_thumb_hovered;
1350
1351                    let tab_hover_changed = new_tab_hover != current_hover;
1352                    let scrollbar_hover_changed = new_scrollbar_hover != current_scrollbar_hover;
1353
1354                    if (tab_hover_changed || scrollbar_hover_changed)
1355                        && let Some(widget) = tree.get_widget_mut(hovered_id)
1356                        && let Some(tabs) = widget.as_any_mut().downcast_mut::<DockTabs>()
1357                    {
1358                        if tab_hover_changed {
1359                            tabs.set_hovered_tab(new_tab_hover);
1360                        }
1361                        if scrollbar_hover_changed {
1362                            tabs.scrollbar_thumb_hovered = new_scrollbar_hover;
1363                        }
1364                        tree.mark_dirty_flags(hovered_id, crate::dirty::DirtyFlags::COLOR);
1365                    }
1366                }
1367            }
1368        }
1369
1370        // Update ScrollContainer scrollbar thumb hover state
1371        if let Some(hovered_id) = new_hovered
1372            && let Some(widget) = tree.get_widget(hovered_id)
1373            && let Some(sc) = widget.as_any().downcast_ref::<ScrollContainer>()
1374        {
1375            let layout = tree.get_layout(hovered_id).unwrap();
1376            let abs_layout = self.get_absolute_layout(tree, hovered_id, layout);
1377            let new_v_hover = sc.hit_test_v_thumb(self.mouse_pos, &abs_layout);
1378            let new_h_hover = sc.hit_test_h_thumb(self.mouse_pos, &abs_layout);
1379            let old_v_hover = sc.v_thumb_hovered;
1380            let old_h_hover = sc.h_thumb_hovered;
1381
1382            if (new_v_hover != old_v_hover || new_h_hover != old_h_hover)
1383                && let Some(widget) = tree.get_widget_mut(hovered_id)
1384                && let Some(sc) = widget.as_any_mut().downcast_mut::<ScrollContainer>()
1385            {
1386                sc.v_thumb_hovered = new_v_hover;
1387                sc.h_thumb_hovered = new_h_hover;
1388                tree.mark_dirty_flags(hovered_id, crate::dirty::DirtyFlags::COLOR);
1389            }
1390        }
1391
1392        if new_hovered != self.hovered {
1393            // Clear old hover state
1394            if let Some(old_id) = self.hovered {
1395                let on_hover = tree
1396                    .get_widget(old_id)
1397                    .map(|w| w.as_any().type_id())
1398                    .and_then(|tid| plugins.widget_registry().get(tid))
1399                    .and_then(|desc| desc.on_hover);
1400                if let Some(on_hover) = on_hover
1401                    && let Some(widget) = tree.get_widget_mut(old_id)
1402                {
1403                    on_hover(widget.as_any_mut(), false);
1404                    tree.mark_dirty_flags(old_id, crate::dirty::DirtyFlags::COLOR);
1405                }
1406                // Clear ScrollContainer thumb hover when leaving
1407                if let Some(widget) = tree.get_widget_mut(old_id)
1408                    && let Some(sc) = widget.as_any_mut().downcast_mut::<ScrollContainer>()
1409                    && (sc.v_thumb_hovered || sc.h_thumb_hovered)
1410                {
1411                    sc.v_thumb_hovered = false;
1412                    sc.h_thumb_hovered = false;
1413                    tree.mark_dirty_flags(old_id, crate::dirty::DirtyFlags::COLOR);
1414                }
1415            }
1416
1417            // Set new hover state
1418            if let Some(new_id) = new_hovered {
1419                let on_hover = tree
1420                    .get_widget(new_id)
1421                    .map(|w| w.as_any().type_id())
1422                    .and_then(|tid| plugins.widget_registry().get(tid))
1423                    .and_then(|desc| desc.on_hover);
1424                if let Some(on_hover) = on_hover
1425                    && let Some(widget) = tree.get_widget_mut(new_id)
1426                {
1427                    on_hover(widget.as_any_mut(), true);
1428                    tree.mark_dirty_flags(new_id, crate::dirty::DirtyFlags::COLOR);
1429                }
1430            }
1431
1432            self.hovered = new_hovered;
1433        }
1434    }
1435
1436    /// Find a splitter node whose separator is under the mouse.
1437    #[cfg(feature = "docking")]
1438    fn find_hovered_splitter(
1439        &self,
1440        tree: &UiTree,
1441        point: Vec2,
1442        dp: &crate::widgets::docking::plugin::DockingPlugin,
1443    ) -> Option<NodeId> {
1444        let default_tolerance = dp.docking_context.style().separator_tolerance;
1445        let root = tree.root()?;
1446        self.find_splitter_at_point(tree, root, point, Vec2::ZERO, default_tolerance)
1447    }
1448
1449    /// Recursively find a splitter with separator at the given point.
1450    #[cfg(feature = "docking")]
1451    #[allow(clippy::only_used_in_recursion)]
1452    fn find_splitter_at_point(
1453        &self,
1454        tree: &UiTree,
1455        node_id: NodeId,
1456        point: Vec2,
1457        parent_offset: Vec2,
1458        default_tolerance: f32,
1459    ) -> Option<NodeId> {
1460        let layout = tree.get_layout(node_id)?;
1461        let abs_x = parent_offset.x + layout.x;
1462        let abs_y = parent_offset.y + layout.y;
1463        let mut abs_offset = Vec2::new(abs_x, abs_y);
1464
1465        let abs_layout = crate::tree::LayoutRect {
1466            x: abs_x,
1467            y: abs_y,
1468            width: layout.width,
1469            height: layout.height,
1470        };
1471
1472        // Check if this is a splitter with separator at point
1473        if let Some(widget) = tree.get_widget(node_id)
1474            && let Some(splitter) = widget.as_any().downcast_ref::<DockSplitter>()
1475        {
1476            let tolerance = splitter.separator_tolerance.unwrap_or(default_tolerance);
1477            if splitter.is_point_in_separator(&abs_layout, point, tolerance) {
1478                return Some(node_id);
1479            }
1480        }
1481
1482        // If this node is a ScrollContainer, subtract scroll offset for children
1483        if let Some(widget) = tree.get_widget(node_id)
1484            && let Some(sc) = widget.as_any().downcast_ref::<ScrollContainer>()
1485        {
1486            abs_offset -= sc.scroll_offset;
1487        }
1488
1489        // Check children
1490        if let Some(widget) = tree.get_widget(node_id) {
1491            for &child_id in widget.children() {
1492                if let Some(found) = self.find_splitter_at_point(
1493                    tree,
1494                    child_id,
1495                    point,
1496                    abs_offset,
1497                    default_tolerance,
1498                ) {
1499                    return Some(found);
1500                }
1501            }
1502        }
1503
1504        None
1505    }
1506
1507    /// Perform hit testing to find which node is under the mouse.
1508    fn hit_test(&self, tree: &UiTree, point: Vec2) -> Option<NodeId> {
1509        profile_function!();
1510        // Start from root and traverse depth-first
1511        let root = tree.root()?;
1512        self.hit_test_node(tree, root, point, Vec2::ZERO)
1513    }
1514
1515    /// Recursively hit test a node and its children with position offset.
1516    #[allow(clippy::only_used_in_recursion)]
1517    fn hit_test_node(
1518        &self,
1519        tree: &UiTree,
1520        node_id: NodeId,
1521        point: Vec2,
1522        parent_offset: Vec2,
1523    ) -> Option<NodeId> {
1524        // Skip invisible nodes and their entire subtree
1525        let widget = tree.get_widget(node_id)?;
1526        if !widget.style().visible {
1527            return None;
1528        }
1529
1530        // Skip nodes with pointer_events: None (click-through)
1531        if widget.style().pointer_events == crate::style::PointerEvents::None {
1532            return None;
1533        }
1534
1535        let layout = tree.get_layout(node_id)?;
1536
1537        // Calculate absolute position
1538        let abs_x = parent_offset.x + layout.x;
1539        let abs_y = parent_offset.y + layout.y;
1540
1541        // Create absolute layout rect
1542        let abs_layout = crate::tree::LayoutRect {
1543            x: abs_x,
1544            y: abs_y,
1545            width: layout.width,
1546            height: layout.height,
1547        };
1548
1549        // Check if point is within this node
1550        if !abs_layout.contains(point) {
1551            return None;
1552        }
1553
1554        let mut abs_offset = Vec2::new(abs_x, abs_y);
1555
1556        // If this node is a ScrollContainer, subtract scroll offset for children hit testing.
1557        // Also check if the point hits a scrollbar (scrollbars are not scrolled).
1558        if let Some(widget) = tree.get_widget(node_id)
1559            && let Some(sc) = widget.as_any().downcast_ref::<ScrollContainer>()
1560        {
1561            // Scrollbar hit test first — scrollbars are above content
1562            if sc.hit_test_v_thumb(point, &abs_layout)
1563                || sc.hit_test_v_track(point, &abs_layout)
1564                || sc.hit_test_h_thumb(point, &abs_layout)
1565                || sc.hit_test_h_track(point, &abs_layout)
1566            {
1567                return Some(node_id);
1568            }
1569            abs_offset -= sc.scroll_offset;
1570        }
1571
1572        // Check children front-to-back, sorted by z-index (highest first)
1573        if let Some(widget) = tree.get_widget(node_id) {
1574            let children = widget.children();
1575
1576            if children.len() <= 1 {
1577                // Fast path: 0-1 children, no sorting needed
1578                for &child_id in children.iter().rev() {
1579                    if let Some(hit) = self.hit_test_node(tree, child_id, point, abs_offset) {
1580                        return Some(hit);
1581                    }
1582                }
1583            } else {
1584                // Collect (child_id, render_layer, computed_z_index) and sort by
1585                // render_layer descending then z_index descending.
1586                // Overlay nodes are tested before base nodes; within each layer,
1587                // higher z-index nodes are tested first.
1588                // Stable sort preserves tree order for equal values (last child = frontmost).
1589                let mut sorted: Vec<(NodeId, crate::draw_list::RenderLayer, u16)> = children
1590                    .iter()
1591                    .filter_map(|&cid| {
1592                        let node = tree.get_node(cid)?;
1593                        let rl = tree
1594                            .get_widget(cid)
1595                            .map(|w| w.style().render_layer)
1596                            .unwrap_or_default();
1597                        Some((cid, rl, node.computed_z_index))
1598                    })
1599                    .collect();
1600
1601                sorted.sort_by(|a, b| b.1.cmp(&a.1).then(b.2.cmp(&a.2)));
1602
1603                for (child_id, _, _) in sorted {
1604                    if let Some(hit) = self.hit_test_node(tree, child_id, point, abs_offset) {
1605                        return Some(hit);
1606                    }
1607                }
1608            }
1609        }
1610
1611        // If no children hit, this node is the hit target
1612        Some(node_id)
1613    }
1614
1615    /// Dispatch a click event to a node.
1616    fn dispatch_click(
1617        &mut self,
1618        node_id: NodeId,
1619        tree: &mut UiTree,
1620        plugins: &mut crate::plugin::PluginManager,
1621    ) {
1622        let on_click = tree
1623            .get_widget(node_id)
1624            .map(|w| w.as_any().type_id())
1625            .and_then(|tid| plugins.widget_registry().get(tid))
1626            .and_then(|desc| desc.on_click);
1627        if let Some(on_click) = on_click
1628            && let Some(widget) = tree.get_widget_mut(node_id)
1629        {
1630            let response = on_click(widget.as_any_mut());
1631            match response {
1632                crate::plugin::registry::EventResponse::RequestFocus => {
1633                    self.focused = Some(node_id);
1634                }
1635                crate::plugin::registry::EventResponse::ReleaseFocus => {
1636                    self.focused = None;
1637                }
1638                crate::plugin::registry::EventResponse::None => {}
1639            }
1640        }
1641    }
1642
1643    /// Handle keyboard input for focused widgets.
1644    fn handle_key_input(
1645        &mut self,
1646        key: &PhysicalKey,
1647        tree: &mut UiTree,
1648        plugins: &mut crate::plugin::PluginManager,
1649    ) {
1650        let Some(focused_id) = self.focused else {
1651            return;
1652        };
1653        let on_key_input = tree
1654            .get_widget(focused_id)
1655            .map(|w| w.as_any().type_id())
1656            .and_then(|tid| plugins.widget_registry().get(tid))
1657            .and_then(|desc| desc.on_key_input);
1658        if let Some(on_key_input) = on_key_input
1659            && let Some(widget) = tree.get_widget_mut(focused_id)
1660        {
1661            let response = on_key_input(widget.as_any_mut(), key);
1662            match response {
1663                crate::plugin::registry::EventResponse::RequestFocus => {
1664                    self.focused = Some(focused_id);
1665                }
1666                crate::plugin::registry::EventResponse::ReleaseFocus => {
1667                    self.focused = None;
1668                }
1669                crate::plugin::registry::EventResponse::None => {}
1670            }
1671        }
1672    }
1673
1674    /// Handle mouse scroll events.
1675    fn handle_scroll_event(
1676        &mut self,
1677        delta: &astrelis_winit::event::MouseScrollDelta,
1678        tree: &mut UiTree,
1679        plugins: &mut crate::plugin::PluginManager,
1680    ) {
1681        let _ = plugins; // used only with docking feature
1682        let (dx, dy) = match delta {
1683            astrelis_winit::event::MouseScrollDelta::LineDelta(x, y) => (*x * 30.0, *y * 30.0),
1684            astrelis_winit::event::MouseScrollDelta::PixelDelta(pos) => {
1685                (pos.x as f32, pos.y as f32)
1686            }
1687        };
1688
1689        // Try docking scroll first
1690        #[cfg(feature = "docking")]
1691        self.handle_dock_scroll(dx - dy, tree);
1692
1693        // Try ScrollContainer scroll: walk from hovered node up to find a ScrollContainer
1694        self.handle_scroll_container_scroll(dx, dy, tree);
1695    }
1696
1697    /// Handle pan gesture events.
1698    fn handle_pan_gesture(
1699        &mut self,
1700        gesture: &astrelis_winit::event::PanGesture,
1701        tree: &mut UiTree,
1702        plugins: &mut crate::plugin::PluginManager,
1703    ) {
1704        let _ = plugins; // used only with docking feature
1705        let dx = gesture.delta.x as f32;
1706        let dy = gesture.delta.y as f32;
1707
1708        #[cfg(feature = "docking")]
1709        self.handle_dock_scroll(-dx - dy, tree);
1710
1711        self.handle_scroll_container_scroll(dx, dy, tree);
1712    }
1713
1714    /// Walk from the hovered node up through ancestors to find the nearest
1715    /// ScrollContainer that can accept the given scroll delta.
1716    fn handle_scroll_container_scroll(&mut self, dx: f32, dy: f32, tree: &mut UiTree) {
1717        let Some(hovered_id) = self.hovered else {
1718            return;
1719        };
1720
1721        // Walk from hovered up to find the nearest ScrollContainer
1722        let mut candidate = Some(hovered_id);
1723        while let Some(node_id) = candidate {
1724            if let Some(widget) = tree.get_widget(node_id)
1725                && widget.as_any().downcast_ref::<ScrollContainer>().is_some()
1726            {
1727                // Found a ScrollContainer — apply delta
1728                if let Some(widget) = tree.get_widget_mut(node_id)
1729                    && let Some(sc) = widget.as_any_mut().downcast_mut::<ScrollContainer>()
1730                {
1731                    let old_offset = sc.scroll_offset;
1732                    // Vertical scroll: negative dy = scroll down (content moves up)
1733                    sc.scroll_by(Vec2::new(-dx, -dy));
1734                    if sc.scroll_offset != old_offset {
1735                        tree.mark_dirty_flags(node_id, crate::dirty::DirtyFlags::SCROLL);
1736                    }
1737                }
1738                return;
1739            }
1740            // Move to parent
1741            candidate = tree.get_node(node_id).and_then(|n| n.parent);
1742        }
1743    }
1744
1745    /// Handle scroll for DockTabs tab bar scrolling.
1746    #[cfg(feature = "docking")]
1747    fn handle_dock_scroll(&mut self, dx: f32, tree: &mut UiTree) {
1748        // Check if the hovered widget is a DockTabs with scrollable tab bar
1749        if let Some(hovered_id) = self.hovered
1750            && let Some(widget) = tree.get_widget(hovered_id)
1751            && let Some(tabs) = widget.as_any().downcast_ref::<DockTabs>()
1752            && tabs.tab_bar_scrollable
1753        {
1754            let layout = tree.get_layout(hovered_id).unwrap();
1755            let abs_layout = self.get_absolute_layout(tree, hovered_id, layout);
1756            let bar = tabs.tab_bar_bounds(&abs_layout);
1757            let available_width = abs_layout.width;
1758
1759            // Only scroll if cursor is in the tab bar area
1760            if self.mouse_pos.y >= bar.y
1761                && self.mouse_pos.y <= bar.y + bar.height
1762                && let Some(widget) = tree.get_widget_mut(hovered_id)
1763                && let Some(tabs) = widget.as_any_mut().downcast_mut::<DockTabs>()
1764            {
1765                tabs.scroll_tab_bar_by(-dx, available_width);
1766                tree.mark_dirty_flags(hovered_id, crate::dirty::DirtyFlags::GEOMETRY);
1767            }
1768        }
1769    }
1770
1771    /// Handle character input for focused widgets.
1772    fn handle_char_input(
1773        &mut self,
1774        c: char,
1775        tree: &mut UiTree,
1776        plugins: &mut crate::plugin::PluginManager,
1777    ) {
1778        let Some(focused_id) = self.focused else {
1779            return;
1780        };
1781        let on_char_input = tree
1782            .get_widget(focused_id)
1783            .map(|w| w.as_any().type_id())
1784            .and_then(|tid| plugins.widget_registry().get(tid))
1785            .and_then(|desc| desc.on_char_input);
1786        if let Some(on_char_input) = on_char_input
1787            && let Some(widget) = tree.get_widget_mut(focused_id)
1788        {
1789            on_char_input(widget.as_any_mut(), c);
1790        }
1791    }
1792}
1793
1794impl Default for UiEventSystem {
1795    fn default() -> Self {
1796        Self::new()
1797    }
1798}