Skip to main content

astrelis_ui/widgets/docking/
plugin.rs

1//! Docking plugin providing DockSplitter and DockTabs widget types.
2//!
3//! This plugin registers render, traversal, and overflow handlers for
4//! the docking widgets. It also owns cross-widget docking state
5//! (drag management, drop zone detection, animations).
6
7use crate::clip::ClipRect;
8use crate::draw_list::{DrawCommand, QuadCommand, TextCommand};
9use crate::plugin::UiPlugin;
10use crate::plugin::registry::{
11    TraversalBehavior, WidgetOverflow, WidgetRenderContext, WidgetTypeDescriptor,
12    WidgetTypeRegistry,
13};
14use crate::style::Overflow;
15use crate::tree::{NodeId, UiTree};
16use crate::widgets::docking::tabs::{CHAR_WIDTH_FACTOR, CLOSE_BUTTON_MARGIN};
17use crate::widgets::docking::{
18    DEFAULT_CLOSE_BUTTON_SIZE, DEFAULT_TAB_PADDING, DockAnimationState, DockSplitter, DockTabs,
19    DockingContext, DragManager, DropZoneDetector,
20};
21use astrelis_core::math::Vec2;
22use astrelis_render::Color;
23use std::any::Any;
24
25// ---------------------------------------------------------------------------
26// DockingPlugin
27// ---------------------------------------------------------------------------
28
29/// Plugin providing docking widget types and cross-widget docking state.
30///
31/// Owns drag management, drop zone detection, animations, and the
32/// docking context (container registry cache).
33pub struct DockingPlugin {
34    /// Manages drag state for splitter resizing and tab operations.
35    pub drag_manager: DragManager,
36    /// Tracks which splitter separator is under the mouse.
37    pub hovered_splitter: Option<NodeId>,
38    /// Detects drop zones for tab drag operations.
39    pub drop_zone_detector: DropZoneDetector,
40    /// Active cross-container drop preview state.
41    pub cross_container_preview: Option<CrossContainerPreview>,
42    /// Container registry cache for efficient drag lookups.
43    pub docking_context: DockingContext,
44    /// Animation state for ghost tabs, groups, and drop previews.
45    pub dock_animations: DockAnimationState,
46    /// DockTabs node whose scrollbar thumb is being dragged.
47    pub scrollbar_drag_node: Option<NodeId>,
48}
49
50/// State for a cross-container drop preview.
51#[derive(Debug, Clone, Copy)]
52pub struct CrossContainerPreview {
53    /// The target DockTabs container.
54    pub target_node: NodeId,
55    /// Absolute layout of the target container.
56    pub target_layout: crate::tree::LayoutRect,
57    /// The detected drop zone within the target.
58    pub zone: crate::widgets::docking::DockZone,
59    /// Preview bounds (where the tab will be inserted).
60    pub preview_bounds: crate::tree::LayoutRect,
61    /// Insertion index for center zone (tab bar position).
62    pub insert_index: Option<usize>,
63}
64
65impl DockingPlugin {
66    /// Create a new docking plugin with default state.
67    pub fn new() -> Self {
68        Self {
69            drag_manager: DragManager::new(),
70            hovered_splitter: None,
71            drop_zone_detector: DropZoneDetector::new(),
72            cross_container_preview: None,
73            docking_context: DockingContext::new(),
74            dock_animations: DockAnimationState::new(),
75            scrollbar_drag_node: None,
76        }
77    }
78
79    /// Invalidate the docking container cache.
80    pub fn invalidate_cache(&mut self) {
81        self.docking_context.invalidate();
82    }
83
84    /// Update all docking animations with the given delta time.
85    /// Returns `true` if any animation is still active.
86    pub fn update_animations(&mut self, dt: f32) -> bool {
87        self.dock_animations.update(dt)
88    }
89
90    /// Check if there is an active drag operation.
91    pub fn is_dragging(&self) -> bool {
92        self.drag_manager.is_dragging()
93    }
94
95    /// Invalidate any references to nodes that no longer exist.
96    pub fn invalidate_removed_nodes(&mut self, tree: &UiTree) {
97        if let Some(id) = self.hovered_splitter
98            && !tree.node_exists(id)
99        {
100            self.hovered_splitter = None;
101        }
102        if let Some(ref p) = self.cross_container_preview
103            && !tree.node_exists(p.target_node)
104        {
105            self.cross_container_preview = None;
106        }
107        if let Some(id) = self.scrollbar_drag_node
108            && !tree.node_exists(id)
109        {
110            self.scrollbar_drag_node = None;
111        }
112    }
113}
114
115impl Default for DockingPlugin {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121impl UiPlugin for DockingPlugin {
122    fn name(&self) -> &str {
123        "docking"
124    }
125
126    fn register_widgets(&self, registry: &mut WidgetTypeRegistry) {
127        registry.register::<DockSplitter>(
128            WidgetTypeDescriptor::new("DockSplitter").with_render(render_dock_splitter),
129        );
130        registry.register::<DockTabs>(
131            WidgetTypeDescriptor::new("DockTabs")
132                .with_render(render_dock_tabs)
133                .with_traversal(dock_tabs_traversal)
134                .with_overflow(dock_tabs_overflow),
135        );
136    }
137
138    fn post_layout(&mut self, _tree: &mut UiTree) {
139        // Docking post-layout processing (splitter ratios, tab sizing)
140        // is currently handled in tree.rs post_process_docking_layouts.
141        // Will be migrated in a future phase.
142    }
143
144    fn update(&mut self, dt: f32, _tree: &mut UiTree) {
145        self.update_animations(dt);
146    }
147
148    fn as_any(&self) -> &dyn Any {
149        self
150    }
151
152    fn as_any_mut(&mut self) -> &mut dyn Any {
153        self
154    }
155}
156
157// ---------------------------------------------------------------------------
158// Render: DockSplitter
159// ---------------------------------------------------------------------------
160
161pub fn render_dock_splitter(
162    widget: &dyn Any,
163    ctx: &mut WidgetRenderContext<'_>,
164) -> Vec<DrawCommand> {
165    let splitter = widget.downcast_ref::<DockSplitter>().unwrap();
166    let mut commands = Vec::new();
167
168    let sep_bounds = splitter.separator_bounds(&crate::tree::LayoutRect {
169        x: ctx.abs_position.x,
170        y: ctx.abs_position.y,
171        width: ctx.layout_size.x,
172        height: ctx.layout_size.y,
173    });
174
175    let sep_color = splitter.current_separator_color();
176
177    commands.push(DrawCommand::Quad(
178        QuadCommand::filled(
179            Vec2::new(sep_bounds.x, sep_bounds.y),
180            Vec2::new(sep_bounds.width, sep_bounds.height),
181            sep_color,
182            ctx.parent_z_index,
183        )
184        .with_clip(ctx.clip_rect),
185    ));
186
187    commands
188}
189
190// ---------------------------------------------------------------------------
191// Render: DockTabs
192// ---------------------------------------------------------------------------
193
194pub fn render_dock_tabs(widget: &dyn Any, ctx: &mut WidgetRenderContext<'_>) -> Vec<DrawCommand> {
195    let tabs = widget.downcast_ref::<DockTabs>().unwrap();
196    let mut commands = Vec::new();
197
198    let abs_layout = crate::tree::LayoutRect {
199        x: ctx.abs_position.x,
200        y: ctx.abs_position.y,
201        width: ctx.layout_size.x,
202        height: ctx.layout_size.y,
203    };
204
205    // Tab bar background at tabs' depth
206    let bar_bounds = tabs.tab_bar_bounds(&abs_layout);
207    commands.push(DrawCommand::Quad(
208        QuadCommand::filled(
209            Vec2::new(bar_bounds.x, bar_bounds.y),
210            Vec2::new(bar_bounds.width, bar_bounds.height),
211            tabs.theme.tab_bar_color,
212            ctx.parent_z_index,
213        )
214        .with_clip(ctx.clip_rect),
215    ));
216
217    // Compute clip rect for the tab row area (excludes scrollbar strip).
218    let tab_row = tabs.tab_row_bounds(&abs_layout);
219    let tab_row_clip = ClipRect::from_bounds(tab_row.x, tab_row.y, tab_row.width, tab_row.height);
220    let tab_clip = ctx.clip_rect.intersect(&tab_row_clip);
221
222    // Render individual tabs
223    for i in 0..tabs.tab_count() {
224        if let Some(tab_rect) = tabs.tab_bounds(i, &abs_layout) {
225            // Skip tabs entirely outside the visible tab row area
226            let tab_right = tab_rect.x + tab_rect.width;
227            let bar_right = tab_row.x + tab_row.width;
228            if tab_right < tab_row.x || tab_rect.x > bar_right {
229                continue;
230            }
231
232            let tab_color = tabs.tab_background_color(i);
233
234            // Tab background
235            commands.push(DrawCommand::Quad(
236                QuadCommand::rounded(
237                    Vec2::new(tab_rect.x, tab_rect.y),
238                    Vec2::new(tab_rect.width, tab_rect.height),
239                    tab_color,
240                    4.0,
241                    ctx.parent_z_index,
242                )
243                .with_clip(tab_clip),
244            ));
245
246            // Tab label text (one layer above background)
247            if let Some(label) = tabs.tab_label(i) {
248                let request_id = ctx.text_pipeline.request_shape(
249                    label.to_string(),
250                    0,
251                    tabs.theme.tab_font_size,
252                    None,
253                );
254
255                if let Some(shaped) = ctx.text_pipeline.get_completed(request_id) {
256                    let text_height = shaped.bounds().1;
257                    let text_x = tab_rect.x + DEFAULT_TAB_PADDING;
258                    let text_y = tab_rect.y + (tab_rect.height - text_height) * 0.5;
259
260                    commands.push(DrawCommand::Text(
261                        TextCommand::new(
262                            Vec2::new(text_x, text_y),
263                            shaped,
264                            tabs.theme.tab_text_color,
265                            ctx.parent_z_index.saturating_add(1),
266                        )
267                        .with_clip(tab_clip),
268                    ));
269                }
270
271                // Close button if closable
272                if tabs.theme.closable
273                    && let Some(close_rect) = tabs.close_button_bounds(i, &abs_layout)
274                {
275                    commands.push(DrawCommand::Quad(
276                        QuadCommand::rounded(
277                            Vec2::new(close_rect.x, close_rect.y),
278                            Vec2::new(close_rect.width, close_rect.height),
279                            Color::rgba(1.0, 1.0, 1.0, 0.1),
280                            close_rect.width / 2.0,
281                            ctx.parent_z_index,
282                        )
283                        .with_clip(tab_clip),
284                    ));
285
286                    // Render X for close button (two layers above background)
287                    let x_request = ctx.text_pipeline.request_shape(
288                        "×".to_string(),
289                        0,
290                        tabs.theme.tab_font_size * 0.9,
291                        None,
292                    );
293
294                    if let Some(x_shaped) = ctx.text_pipeline.get_completed(x_request) {
295                        let x_width = x_shaped.bounds().0;
296                        let x_height = x_shaped.bounds().1;
297                        let x_x = close_rect.x + (close_rect.width - x_width) * 0.5;
298                        let x_y = close_rect.y + (close_rect.height - x_height) * 0.5;
299
300                        commands.push(DrawCommand::Text(
301                            TextCommand::new(
302                                Vec2::new(x_x, x_y),
303                                x_shaped,
304                                tabs.theme.tab_text_color,
305                                ctx.parent_z_index.saturating_add(2),
306                            )
307                            .with_clip(tab_clip),
308                        ));
309                    }
310                }
311            }
312        }
313    }
314
315    // Render scrollbar track + thumb when scrollbar mode is active
316    if tabs.should_show_scrollbar() {
317        let track = tabs.scrollbar_track_bounds(&abs_layout);
318        commands.push(DrawCommand::Quad(
319            QuadCommand::filled(
320                Vec2::new(track.x, track.y),
321                Vec2::new(track.width, track.height),
322                tabs.theme.scrollbar_theme.track_color,
323                ctx.parent_z_index.saturating_add(2),
324            )
325            .with_clip(ctx.clip_rect),
326        ));
327
328        let thumb = tabs.scrollbar_thumb_bounds(&abs_layout);
329        let thumb_color = tabs.scrollbar_thumb_color();
330        commands.push(DrawCommand::Quad(
331            QuadCommand::rounded(
332                Vec2::new(thumb.x, thumb.y),
333                Vec2::new(thumb.width, thumb.height),
334                thumb_color,
335                tabs.theme.scrollbar_theme.thumb_border_radius,
336                ctx.parent_z_index.saturating_add(3),
337            )
338            .with_clip(ctx.clip_rect),
339        ));
340    }
341
342    // Render arrow scroll indicators when arrows mode is active
343    if tabs.should_show_arrows() {
344        let arrow_color = Color::from_rgba_u8(180, 180, 180, 200);
345        let arrow_size = tabs.theme.tab_font_size;
346        let arrow_row = tabs.tab_row_bounds(&abs_layout);
347
348        // Left arrow (visible when scrolled past start)
349        if tabs.tab_scroll_offset > 0.0 {
350            let arrow_request = ctx.text_pipeline.request_shape(
351                "\u{25C0}".to_string(), // ◀
352                0,
353                arrow_size,
354                None,
355            );
356            if let Some(shaped) = ctx.text_pipeline.get_completed(arrow_request) {
357                let arrow_h = shaped.bounds().1;
358                let ax = arrow_row.x + 2.0;
359                let ay = arrow_row.y + (arrow_row.height - arrow_h) * 0.5;
360                commands.push(DrawCommand::Text(
361                    TextCommand::new(
362                        Vec2::new(ax, ay),
363                        shaped,
364                        arrow_color,
365                        ctx.parent_z_index.saturating_add(3),
366                    )
367                    .with_clip(ctx.clip_rect),
368                ));
369            }
370        }
371
372        // Right arrow (visible when more tabs are off-screen right)
373        let max_offset = tabs.max_tab_scroll_offset(abs_layout.width);
374        if tabs.tab_scroll_offset < max_offset {
375            let arrow_request = ctx.text_pipeline.request_shape(
376                "\u{25B6}".to_string(), // ▶
377                0,
378                arrow_size,
379                None,
380            );
381            if let Some(shaped) = ctx.text_pipeline.get_completed(arrow_request) {
382                let arrow_w = shaped.bounds().0;
383                let arrow_h = shaped.bounds().1;
384                let ax = arrow_row.x + arrow_row.width - arrow_w - 2.0;
385                let ay = arrow_row.y + (arrow_row.height - arrow_h) * 0.5;
386                commands.push(DrawCommand::Text(
387                    TextCommand::new(
388                        Vec2::new(ax, ay),
389                        shaped,
390                        arrow_color,
391                        ctx.parent_z_index.saturating_add(3),
392                    )
393                    .with_clip(ctx.clip_rect),
394                ));
395            }
396        }
397    }
398
399    // Render drop indicator
400    if let Some(indicator_bounds) = tabs.drop_indicator_bounds(&abs_layout) {
401        let indicator_color = Color::from_rgba_u8(100, 150, 255, 200);
402        commands.push(DrawCommand::Quad(
403            QuadCommand::filled(
404                Vec2::new(indicator_bounds.x, indicator_bounds.y),
405                Vec2::new(indicator_bounds.width, indicator_bounds.height),
406                indicator_color,
407                ctx.parent_z_index.saturating_add(3),
408            )
409            .with_clip(ctx.clip_rect),
410        ));
411    }
412
413    // Render ghost tab at cursor
414    if let Some(dragging_index) = tabs.drag.dragging_tab_index
415        && let Some(cursor_pos) = tabs.drag.drag_cursor_pos
416    {
417        let ghost_label = tabs.tab_label(dragging_index).unwrap_or("");
418
419        let char_width = tabs.theme.tab_font_size * CHAR_WIDTH_FACTOR;
420        let text_width = ghost_label.len() as f32 * char_width;
421        let close_width = if tabs.theme.closable {
422            DEFAULT_CLOSE_BUTTON_SIZE + CLOSE_BUTTON_MARGIN
423        } else {
424            0.0
425        };
426        let tab_width = text_width + DEFAULT_TAB_PADDING * 2.0 + close_width;
427
428        let ghost_pos = cursor_pos - Vec2::new(tab_width / 2.0, tabs.theme.tab_bar_height / 2.0);
429        let ghost_size = Vec2::new(tab_width, tabs.theme.tab_bar_height);
430        let ghost_color = Color::from_rgba_u8(80, 100, 140, 180);
431
432        commands.push(DrawCommand::Quad(
433            QuadCommand::rounded(
434                ghost_pos,
435                ghost_size,
436                ghost_color,
437                4.0,
438                ctx.parent_z_index.saturating_add(3),
439            )
440            .with_clip(ctx.clip_rect),
441        ));
442
443        // Ghost text
444        let request_id = ctx.text_pipeline.request_shape(
445            ghost_label.to_string(),
446            0,
447            tabs.theme.tab_font_size,
448            None,
449        );
450
451        if let Some(shaped) = ctx.text_pipeline.get_completed(request_id) {
452            let text_height = shaped.bounds().1;
453            let text_x = ghost_pos.x + DEFAULT_TAB_PADDING;
454            let text_y = ghost_pos.y + (tabs.theme.tab_bar_height - text_height) * 0.5;
455            let ghost_text_color = Color::from_rgba_u8(200, 200, 200, 180);
456
457            commands.push(DrawCommand::Text(
458                TextCommand::new(
459                    Vec2::new(text_x, text_y),
460                    shaped,
461                    ghost_text_color,
462                    ctx.parent_z_index.saturating_add(4),
463                )
464                .with_clip(ctx.clip_rect),
465            ));
466        }
467    }
468
469    commands
470}
471
472// ---------------------------------------------------------------------------
473// Traversal: DockTabs
474// ---------------------------------------------------------------------------
475
476/// DockTabs only renders the active tab's children.
477pub fn dock_tabs_traversal(widget: &dyn Any) -> TraversalBehavior {
478    let tabs = widget.downcast_ref::<DockTabs>().unwrap();
479    TraversalBehavior::OnlyChild(tabs.active_tab)
480}
481
482// ---------------------------------------------------------------------------
483// Overflow: DockTabs
484// ---------------------------------------------------------------------------
485
486pub fn dock_tabs_overflow(_widget: &dyn Any) -> WidgetOverflow {
487    WidgetOverflow {
488        overflow_x: Overflow::Hidden,
489        overflow_y: Overflow::Hidden,
490    }
491}