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}