Skip to main content

egui_cha_ds/molecules/
node_layout.rs

1//! Node Layout module - Infinite canvas pane layout
2//!
3//! A free-form pane layout system with infinite canvas, pan/zoom support,
4//! and optional locking.
5//!
6//! # Features
7//! - Infinite canvas with pan/zoom
8//! - Free positioning of panes
9//! - Lock mode to prevent changes
10//! - Custom pane content via closure
11//!
12//! # Example
13//! ```ignore
14//! // In your Model
15//! struct Model {
16//!     layout: NodeLayout,
17//! }
18//!
19//! // In view
20//! NodeLayoutArea::new(&mut model.layout, |ui, pane| {
21//!     match pane.id.as_str() {
22//!         "effects" => render_effects(ui),
23//!         "layers" => render_layers(ui),
24//!         _ => {}
25//!     }
26//! })
27//! .locked(false)
28//! .show(ui);
29//! ```
30
31use crate::atoms::icons;
32use crate::Theme;
33use egui::{
34    emath::TSTransform, Color32, CornerRadius, FontFamily, Pos2, Rect, Scene, Sense, Stroke, Ui,
35    Vec2,
36};
37use std::collections::HashMap;
38
39/// A pane in the node layout
40#[derive(Clone, Debug)]
41pub struct LayoutPane {
42    /// Unique identifier
43    pub id: String,
44    /// Display title
45    pub title: String,
46    /// Optional title icon (Phosphor icon codepoint)
47    pub title_icon: Option<&'static str>,
48    /// Position in graph space
49    pub position: Pos2,
50    /// Desired size (width, height)
51    pub size: Vec2,
52    /// Size before maximize (for restore)
53    pub pre_maximize_size: Option<Vec2>,
54    /// Position before maximize (for restore)
55    pub pre_maximize_position: Option<Pos2>,
56    /// Whether the pane can be closed
57    pub closable: bool,
58    /// Whether the pane is currently collapsed (title bar only)
59    pub collapsed: bool,
60    /// Whether the pane is maximized (fills canvas)
61    pub maximized: bool,
62    /// Whether the pane can be resized
63    pub resizable: bool,
64    /// Minimum size constraint
65    pub min_size: Vec2,
66    /// Lock level (None, Light, Full)
67    pub lock_level: LockLevel,
68}
69
70impl LayoutPane {
71    /// Create a new pane
72    pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
73        Self {
74            id: id.into(),
75            title: title.into(),
76            title_icon: None,
77            position: Pos2::ZERO,
78            size: Vec2::new(300.0, 200.0),
79            pre_maximize_size: None,
80            pre_maximize_position: None,
81            closable: false,
82            collapsed: false,
83            maximized: false,
84            resizable: true,
85            min_size: Vec2::new(100.0, 60.0),
86            lock_level: LockLevel::None,
87        }
88    }
89
90    /// Set the size
91    pub fn with_size(mut self, width: f32, height: f32) -> Self {
92        self.size = Vec2::new(width, height);
93        self
94    }
95
96    /// Set initial position
97    pub fn with_position(mut self, x: f32, y: f32) -> Self {
98        self.position = Pos2::new(x, y);
99        self
100    }
101
102    /// Set closable
103    pub fn closable(mut self, closable: bool) -> Self {
104        self.closable = closable;
105        self
106    }
107
108    /// Set resizable
109    pub fn resizable(mut self, resizable: bool) -> Self {
110        self.resizable = resizable;
111        self
112    }
113
114    /// Set minimum size
115    pub fn min_size(mut self, width: f32, height: f32) -> Self {
116        self.min_size = Vec2::new(width, height);
117        self
118    }
119
120    /// Set lock level
121    pub fn lock_level(mut self, level: LockLevel) -> Self {
122        self.lock_level = level;
123        self
124    }
125
126    /// Set title icon (Phosphor icon codepoint)
127    pub fn with_icon(mut self, icon: &'static str) -> Self {
128        self.title_icon = Some(icon);
129        self
130    }
131}
132
133/// Lock level for panes and canvas
134#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
135pub enum LockLevel {
136    /// No lock - all operations allowed
137    #[default]
138    None,
139    /// Light lock - no move/resize, but collapse/maximize/close allowed
140    Light,
141    /// Full lock - all operations disabled
142    Full,
143}
144
145impl LockLevel {
146    /// Cycle to next lock level (None -> Light -> Full -> None)
147    pub fn cycle(self) -> Self {
148        match self {
149            LockLevel::None => LockLevel::Light,
150            LockLevel::Light => LockLevel::Full,
151            LockLevel::Full => LockLevel::None,
152        }
153    }
154
155    /// Check if move/resize is allowed
156    pub fn allows_move_resize(self) -> bool {
157        matches!(self, LockLevel::None)
158    }
159
160    /// Check if collapse/maximize/close is allowed
161    pub fn allows_window_controls(self) -> bool {
162        !matches!(self, LockLevel::Full)
163    }
164}
165
166/// Strategy for auto-arranging panes
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
168pub enum ArrangeStrategy {
169    /// Just resolve overlaps with minimal movement
170    #[default]
171    ResolveOverlaps,
172    /// Arrange in a grid layout (like WorkspaceCanvas Tile mode)
173    Grid { columns: Option<usize> },
174    /// Cascade windows diagonally
175    Cascade,
176    /// Stack horizontally
177    Horizontal,
178    /// Stack vertically
179    Vertical,
180}
181
182/// Events emitted by NodeLayoutArea
183#[derive(Debug, Clone)]
184pub enum NodeLayoutEvent {
185    /// Pane was moved
186    PaneMoved { id: String, position: Pos2 },
187    /// Pane was resized
188    PaneResized { id: String, size: Vec2 },
189    /// Pane was collapsed/expanded (title bar only)
190    PaneCollapsed { id: String, collapsed: bool },
191    /// Pane was maximized/restored
192    PaneMaximized { id: String, maximized: bool },
193    /// Pane lock level changed
194    PaneLockChanged { id: String, lock_level: LockLevel },
195    /// Pane was closed
196    PaneClosed(String),
197    /// Panes were auto-arranged
198    AutoArranged {
199        strategy: ArrangeStrategy,
200        moved_pane_ids: Vec<String>,
201    },
202    /// Canvas lock level changed (from menu bar)
203    CanvasLockChanged(LockLevel),
204    /// Zoom to fit all panes
205    ZoomToFit,
206    /// Reset zoom to 100%
207    ZoomReset,
208}
209
210/// Resize direction
211#[derive(Clone, Copy, Debug, PartialEq)]
212enum ResizeEdge {
213    Left,
214    Right,
215    Top,
216    Bottom,
217    TopLeft,
218    TopRight,
219    BottomLeft,
220    BottomRight,
221}
222
223/// Internal state persisted across frames
224#[derive(Clone)]
225struct LayoutState {
226    /// Transform from graph space to screen space
227    to_screen: TSTransform,
228    /// Whether the transform has been initialized with the rect position
229    initialized: bool,
230    /// Currently dragging pane (by title)
231    dragging: Option<String>,
232    /// Currently resizing pane
233    resizing: Option<(String, ResizeEdge)>,
234    /// Draw order (front to back)
235    draw_order: Vec<String>,
236}
237
238impl Default for LayoutState {
239    fn default() -> Self {
240        Self {
241            to_screen: TSTransform::IDENTITY,
242            initialized: false,
243            dragging: None,
244            resizing: None,
245            draw_order: Vec::new(),
246        }
247    }
248}
249
250/// Node layout container
251pub struct NodeLayout {
252    panes: Vec<LayoutPane>,
253    /// Map from pane id to index for quick lookup
254    id_to_index: HashMap<String, usize>,
255}
256
257impl Default for NodeLayout {
258    fn default() -> Self {
259        Self::new()
260    }
261}
262
263impl NodeLayout {
264    /// Create a new empty layout
265    pub fn new() -> Self {
266        Self {
267            panes: Vec::new(),
268            id_to_index: HashMap::new(),
269        }
270    }
271
272    /// Add a pane at a position
273    pub fn add_pane(&mut self, pane: LayoutPane, position: Pos2) -> &mut Self {
274        let mut pane = pane;
275        pane.position = position;
276        let id = pane.id.clone();
277        let index = self.panes.len();
278        self.panes.push(pane);
279        self.id_to_index.insert(id, index);
280        self
281    }
282
283    /// Remove a pane by id
284    pub fn remove_pane(&mut self, id: &str) -> Option<LayoutPane> {
285        if let Some(index) = self.id_to_index.remove(id) {
286            let pane = self.panes.remove(index);
287            // Rebuild index map
288            self.id_to_index.clear();
289            for (i, p) in self.panes.iter().enumerate() {
290                self.id_to_index.insert(p.id.clone(), i);
291            }
292            Some(pane)
293        } else {
294            None
295        }
296    }
297
298    /// Get a pane by id
299    pub fn get_pane(&self, id: &str) -> Option<&LayoutPane> {
300        self.id_to_index.get(id).map(|&i| &self.panes[i])
301    }
302
303    /// Get a mutable pane by id
304    pub fn get_pane_mut(&mut self, id: &str) -> Option<&mut LayoutPane> {
305        if let Some(&i) = self.id_to_index.get(id) {
306            Some(&mut self.panes[i])
307        } else {
308            None
309        }
310    }
311
312    /// Iterate over all panes
313    pub fn panes(&self) -> impl Iterator<Item = &LayoutPane> {
314        self.panes.iter()
315    }
316
317    /// Iterate over all panes mutably
318    pub fn panes_mut(&mut self) -> impl Iterator<Item = &mut LayoutPane> {
319        self.panes.iter_mut()
320    }
321
322    /// Check if any panes are overlapping (considering gap)
323    pub fn has_overlaps(&self, gap: f32) -> bool {
324        let rects: Vec<Rect> = self
325            .panes
326            .iter()
327            .filter(|p| !p.collapsed && !p.maximized)
328            .map(|p| Rect::from_min_size(p.position, p.size))
329            .collect();
330        super::layout_helpers::has_overlaps(&rects, gap)
331    }
332
333    /// Resolve overlapping panes by pushing them apart.
334    ///
335    /// Returns the IDs of panes that were moved.
336    pub fn resolve_overlaps(&mut self, gap: f32) -> Vec<String> {
337        // Collect non-collapsed, non-maximized panes with their indices
338        let pane_data: Vec<(usize, Rect)> = self
339            .panes
340            .iter()
341            .enumerate()
342            .filter(|(_, p)| !p.collapsed && !p.maximized)
343            .map(|(i, p)| (i, Rect::from_min_size(p.position, p.size)))
344            .collect();
345
346        if pane_data.is_empty() {
347            return Vec::new();
348        }
349
350        let rects: Vec<Rect> = pane_data.iter().map(|(_, r)| *r).collect();
351        let result = super::layout_helpers::resolve_overlaps(&rects, gap, 100);
352
353        if !result.changed {
354            return Vec::new();
355        }
356
357        // Apply new positions
358        let mut moved_ids = Vec::new();
359        for (i, new_pos) in result.positions.iter().enumerate() {
360            let pane_idx = pane_data[i].0;
361            let pane = &mut self.panes[pane_idx];
362            if pane.position != *new_pos {
363                pane.position = *new_pos;
364                moved_ids.push(pane.id.clone());
365            }
366        }
367
368        moved_ids
369    }
370
371    /// Resolve overlaps while keeping panes close to their original positions.
372    ///
373    /// `anchor_strength` controls how strongly panes are pulled back (0.0 - 1.0).
374    /// Returns the IDs of panes that were moved.
375    pub fn resolve_overlaps_anchored(&mut self, gap: f32, anchor_strength: f32) -> Vec<String> {
376        let pane_data: Vec<(usize, Rect)> = self
377            .panes
378            .iter()
379            .enumerate()
380            .filter(|(_, p)| !p.collapsed && !p.maximized)
381            .map(|(i, p)| (i, Rect::from_min_size(p.position, p.size)))
382            .collect();
383
384        if pane_data.is_empty() {
385            return Vec::new();
386        }
387
388        let rects: Vec<Rect> = pane_data.iter().map(|(_, r)| *r).collect();
389        let result =
390            super::layout_helpers::resolve_overlaps_with_anchors(&rects, gap, anchor_strength, 100);
391
392        if !result.changed {
393            return Vec::new();
394        }
395
396        let mut moved_ids = Vec::new();
397        for (i, new_pos) in result.positions.iter().enumerate() {
398            let pane_idx = pane_data[i].0;
399            let pane = &mut self.panes[pane_idx];
400            if pane.position != *new_pos {
401                pane.position = *new_pos;
402                moved_ids.push(pane.id.clone());
403            }
404        }
405
406        moved_ids
407    }
408
409    /// Count overlapping pane pairs
410    pub fn count_overlaps(&self, gap: f32) -> usize {
411        let rects: Vec<Rect> = self
412            .panes
413            .iter()
414            .filter(|p| !p.collapsed && !p.maximized)
415            .map(|p| Rect::from_min_size(p.position, p.size))
416            .collect();
417        super::layout_helpers::count_overlaps(&rects, gap)
418    }
419
420    /// Auto-arrange panes using the specified strategy.
421    ///
422    /// # Arguments
423    /// * `strategy` - The arrangement strategy to use
424    /// * `gap` - Gap between panes
425    /// * `origin` - Optional origin point (defaults to current bounding box min or (0,0))
426    /// * `z_order_ids` - Optional Z-order for Cascade (back-to-front pane IDs).
427    ///                   First ID = back (top-left), last ID = front (bottom-right).
428    ///
429    /// # Returns
430    /// IDs of panes that were moved
431    pub fn auto_arrange(
432        &mut self,
433        strategy: ArrangeStrategy,
434        gap: f32,
435        origin: Option<Pos2>,
436        z_order_ids: Option<&[String]>,
437    ) -> Vec<String> {
438        use super::layout_helpers;
439
440        // Note: Sorting is now done in layout_helpers based on current positions
441        // (not by ID), so the spatial arrangement is preserved.
442        // Exception: Cascade uses z_order_ids if provided.
443
444        // Collect non-collapsed, non-maximized panes
445        let pane_data: Vec<(usize, Rect)> = self
446            .panes
447            .iter()
448            .enumerate()
449            .filter(|(_, p)| !p.collapsed && !p.maximized)
450            .map(|(i, p)| (i, Rect::from_min_size(p.position, p.size)))
451            .collect();
452
453        if pane_data.is_empty() {
454            return Vec::new();
455        }
456
457        let rects: Vec<Rect> = pane_data.iter().map(|(_, r)| *r).collect();
458
459        // Determine origin
460        let origin = origin.unwrap_or_else(|| {
461            layout_helpers::bounding_box(&rects)
462                .map(|b| b.min)
463                .unwrap_or(Pos2::ZERO)
464        });
465
466        // Apply the appropriate arrangement
467        let result = match strategy {
468            ArrangeStrategy::ResolveOverlaps => {
469                layout_helpers::resolve_overlaps(&rects, gap, 100).into()
470            }
471            ArrangeStrategy::Grid { columns } => {
472                layout_helpers::arrange_grid_proportional(&rects, columns, origin, gap)
473            }
474            ArrangeStrategy::Cascade => {
475                let offset = Vec2::new(30.0, 30.0);
476
477                // Build cascade order from z_order_ids if provided
478                let cascade_order = z_order_ids.and_then(|ids| {
479                    // Map z_order IDs (back-to-front) to pane_data indices
480                    // Reverse to get back-to-front order (first = top-left)
481                    let order: Vec<usize> = ids
482                        .iter()
483                        .rev() // Reverse: draw_order is front-to-back, we want back-to-front
484                        .filter_map(|id| {
485                            pane_data
486                                .iter()
487                                .position(|(idx, _)| self.panes[*idx].id == *id)
488                        })
489                        .collect();
490
491                    if order.len() == pane_data.len() {
492                        Some(order)
493                    } else {
494                        None // Fallback to default if not all panes matched
495                    }
496                });
497
498                match cascade_order {
499                    Some(order) => {
500                        layout_helpers::arrange_cascade(&rects, origin, offset, Some(&order))
501                    }
502                    None => layout_helpers::arrange_cascade(&rects, origin, offset, None),
503                }
504            }
505            ArrangeStrategy::Horizontal => {
506                layout_helpers::arrange_horizontal(&rects, origin, gap, false)
507            }
508            ArrangeStrategy::Vertical => {
509                layout_helpers::arrange_vertical(&rects, origin, gap, false)
510            }
511        };
512
513        if !result.changed {
514            return Vec::new();
515        }
516
517        // Apply new positions
518        let mut moved_ids = Vec::new();
519        for (i, new_pos) in result.positions.iter().enumerate() {
520            let pane_idx = pane_data[i].0;
521            let pane = &mut self.panes[pane_idx];
522            if pane.position != *new_pos {
523                pane.position = *new_pos;
524                moved_ids.push(pane.id.clone());
525            }
526        }
527
528        moved_ids
529    }
530
531    /// Get the bounding box of all visible panes
532    pub fn bounding_box(&self) -> Option<Rect> {
533        let rects: Vec<Rect> = self
534            .panes
535            .iter()
536            .filter(|p| !p.collapsed && !p.maximized)
537            .map(|p| Rect::from_min_size(p.position, p.size))
538            .collect();
539        super::layout_helpers::bounding_box(&rects)
540    }
541}
542
543/// Node layout area widget
544pub struct NodeLayoutArea<'a, F> {
545    layout: &'a mut NodeLayout,
546    content_fn: F,
547    lock_level: LockLevel,
548    title_height: f32,
549    content_padding: Option<f32>,
550    grid_size: f32,
551    grid_alpha: u8,
552    min_scale: f32,
553    max_scale: f32,
554    /// Whether to show the menu bar
555    show_menu_bar: bool,
556    /// Menu bar height
557    menu_bar_height: f32,
558}
559
560impl<'a, F> NodeLayoutArea<'a, F>
561where
562    F: FnMut(&mut Ui, &LayoutPane),
563{
564    /// Create a new node layout area
565    pub fn new(layout: &'a mut NodeLayout, content_fn: F) -> Self {
566        Self {
567            layout,
568            content_fn,
569            lock_level: LockLevel::None,
570            title_height: 24.0,
571            content_padding: None, // Uses theme.spacing_sm by default
572            grid_size: 50.0,
573            grid_alpha: 30,
574            min_scale: 0.25,
575            max_scale: 2.0,
576            show_menu_bar: false,
577            menu_bar_height: 28.0,
578        }
579    }
580
581    /// Show/hide the menu bar
582    pub fn show_menu_bar(mut self, show: bool) -> Self {
583        self.show_menu_bar = show;
584        self
585    }
586
587    /// Set menu bar height
588    pub fn menu_bar_height(mut self, height: f32) -> Self {
589        self.menu_bar_height = height;
590        self
591    }
592
593    /// Set lock level (controls what operations are allowed)
594    pub fn lock_level(mut self, level: LockLevel) -> Self {
595        self.lock_level = level;
596        self
597    }
598
599    /// Set locked state (shorthand for Full lock)
600    pub fn locked(mut self, locked: bool) -> Self {
601        self.lock_level = if locked {
602            LockLevel::Full
603        } else {
604            LockLevel::None
605        };
606        self
607    }
608
609    /// Set title bar height
610    pub fn title_height(mut self, height: f32) -> Self {
611        self.title_height = height;
612        self
613    }
614
615    /// Set content padding (defaults to theme.spacing_sm)
616    pub fn content_padding(mut self, padding: f32) -> Self {
617        self.content_padding = Some(padding);
618        self
619    }
620
621    /// Set grid size
622    pub fn grid_size(mut self, size: f32) -> Self {
623        self.grid_size = size;
624        self
625    }
626
627    /// Set grid line alpha (0-255)
628    pub fn grid_alpha(mut self, alpha: u8) -> Self {
629        self.grid_alpha = alpha;
630        self
631    }
632
633    /// Set zoom range (min, max scale)
634    pub fn zoom_range(mut self, min: f32, max: f32) -> Self {
635        self.min_scale = min;
636        self.max_scale = max;
637        self
638    }
639
640    /// Show the layout
641    pub fn show(mut self, ui: &mut Ui) -> Vec<NodeLayoutEvent> {
642        let theme = Theme::current(ui.ctx());
643        let mut events = Vec::new();
644
645        // Get available rect
646        let full_rect = ui.available_rect_before_wrap();
647
648        // Calculate menu bar and canvas rects
649        let (menu_rect, canvas_rect) = if self.show_menu_bar {
650            let menu_rect = Rect::from_min_size(
651                full_rect.min,
652                Vec2::new(full_rect.width(), self.menu_bar_height),
653            );
654            let canvas_rect = Rect::from_min_max(
655                Pos2::new(full_rect.min.x, full_rect.min.y + self.menu_bar_height),
656                full_rect.max,
657            );
658            (Some(menu_rect), canvas_rect)
659        } else {
660            (None, full_rect)
661        };
662
663        // Use canvas_rect for the rest
664        let rect = canvas_rect;
665
666        // Load state (before menu bar so we can pass draw_order)
667        let state_id = ui.id().with("node_layout_state");
668        let mut state: LayoutState = ui.ctx().data(|d| d.get_temp(state_id)).unwrap_or_default();
669
670        // Ensure draw order contains all panes
671        self.sync_draw_order(&mut state);
672
673        // Draw menu bar FIRST (before allocating canvas) so it can receive input
674        if let Some(menu_rect) = menu_rect {
675            self.draw_menu_bar(ui, menu_rect, &theme, &mut events, &state.draw_order);
676        }
677
678        // Initialize transform on first frame to map graph origin to rect top-left
679        if !state.initialized {
680            state.to_screen = TSTransform::from_translation(rect.min.to_vec2());
681            state.initialized = true;
682        }
683
684        // Handle pan/zoom (only if canvas allows move/resize)
685        let mut to_screen = state.to_screen;
686        if self.lock_level.allows_move_resize() {
687            // Create a response for the canvas area only
688            let canvas_response = ui.allocate_rect(rect, Sense::drag());
689            let mut scene_response = canvas_response;
690            Scene::new()
691                .zoom_range(self.min_scale..=self.max_scale)
692                .register_pan_and_zoom(ui, &mut scene_response, &mut to_screen);
693        }
694
695        // Handle zoom events from menu bar
696        for event in &events {
697            match event {
698                NodeLayoutEvent::ZoomToFit => {
699                    if let Some(bounds) = self.layout.bounding_box() {
700                        // Add margin around the bounding box
701                        let margin = 20.0;
702                        let bounds = bounds.expand(margin);
703
704                        // Calculate scale to fit bounds in canvas
705                        let scale_x = rect.width() / bounds.width();
706                        let scale_y = rect.height() / bounds.height();
707                        let scale = scale_x.min(scale_y).clamp(self.min_scale, self.max_scale);
708
709                        // Calculate translation to center the bounds
710                        let bounds_center = bounds.center();
711                        let rect_center = rect.center();
712
713                        to_screen = TSTransform::from_translation(
714                            rect_center.to_vec2() - bounds_center.to_vec2() * scale,
715                        ) * TSTransform::from_scaling(scale);
716                    }
717                }
718                NodeLayoutEvent::ZoomReset => {
719                    // Reset to 100% zoom, keeping content at origin
720                    to_screen = TSTransform::from_translation(rect.min.to_vec2());
721                }
722                _ => {}
723            }
724        }
725
726        let from_screen = to_screen.inverse();
727
728        // Calculate viewport in graph space
729        let viewport = from_screen * rect;
730
731        // Draw background (clipped to rect)
732        let bg_painter = ui.painter_at(rect);
733        bg_painter.rect_filled(rect, 0.0, theme.bg_secondary);
734
735        // Draw grid (in screen space, clipped to rect)
736        self.draw_grid_screen(
737            &bg_painter,
738            rect,
739            self.grid_size,
740            self.grid_alpha,
741            &to_screen,
742            &theme,
743        );
744
745        // Use clipped painter for panes (clips to canvas area)
746        let painter = ui.painter_at(rect).clone();
747
748        // Collect pane interactions
749        let mut pane_to_top: Option<String> = None;
750        let mut pane_moved: Option<(String, Vec2)> = None;
751        let mut pane_resized: Option<(String, Vec2)> = None;
752        let mut pane_collapsed: Option<(String, bool)> = None;
753        let mut pane_maximized: Option<(String, bool)> = None;
754        let mut pane_lock_changed: Option<(String, LockLevel)> = None;
755        let mut pane_closed: Option<String> = None;
756
757        // Button size for title bar
758        let button_size = self.title_height * 0.7;
759        let button_padding = self.title_height * 0.15;
760
761        // Resize edge detection threshold (in screen pixels)
762        let resize_edge_size = 6.0;
763
764        // Draw panes in order (back to front)
765        // Collect IDs first to avoid borrow issues during iteration
766        let draw_order: Vec<_> = state.draw_order.iter().rev().cloned().collect();
767        for pane_id in draw_order {
768            let Some(pane) = self.layout.get_pane(&pane_id) else {
769                continue;
770            };
771
772            // Calculate effective pane rect (considering maximized state)
773            let (pane_rect, is_maximized) = if pane.maximized {
774                // Maximized: fill the viewport
775                (viewport, true)
776            } else {
777                (Rect::from_min_size(pane.position, pane.size), false)
778            };
779
780            // For collapsed panes, use only title height
781            let effective_pane_rect = if pane.collapsed && !is_maximized {
782                Rect::from_min_size(
783                    pane_rect.min,
784                    Vec2::new(pane_rect.width(), self.title_height),
785                )
786            } else {
787                pane_rect
788            };
789
790            // Transform to screen space
791            let screen_pane_rect = to_screen * effective_pane_rect;
792
793            // Skip if not visible in canvas rect (screen space check)
794            if !is_maximized && !rect.intersects(screen_pane_rect) {
795                continue;
796            }
797
798            // Draw pane frame (no clipping for infinite canvas)
799            let frame_stroke = if state.dragging.as_ref() == Some(&pane_id) {
800                Stroke::new(theme.border_width * 2.0, theme.primary)
801            } else {
802                Stroke::new(theme.border_width, theme.border)
803            };
804
805            painter.rect_filled(screen_pane_rect, theme.radius_sm, theme.bg_primary);
806            painter.rect_stroke(
807                screen_pane_rect,
808                theme.radius_sm,
809                frame_stroke,
810                egui::StrokeKind::Outside,
811            );
812
813            // Draw title bar
814            let scaled_title_height = self.title_height * to_screen.scaling;
815            let screen_title_rect = Rect::from_min_size(
816                screen_pane_rect.min,
817                Vec2::new(screen_pane_rect.width(), scaled_title_height),
818            );
819
820            let radius = theme.radius_sm as u8;
821            let title_rounding = if pane.collapsed {
822                // All corners rounded when collapsed
823                CornerRadius::same(radius)
824            } else {
825                CornerRadius {
826                    nw: radius,
827                    ne: radius,
828                    sw: 0,
829                    se: 0,
830                }
831            };
832            painter.rect_filled(screen_title_rect, title_rounding, theme.bg_tertiary);
833
834            // Calculate button positions (right side of title bar)
835            let scaled_button_size = button_size * to_screen.scaling;
836            let scaled_button_padding = button_padding * to_screen.scaling;
837            let mut button_x = screen_title_rect.max.x - scaled_button_padding - scaled_button_size;
838
839            // Helper to draw icon button
840            let icon_font =
841                egui::FontId::new(scaled_button_size * 0.7, FontFamily::Name("icons".into()));
842
843            // Check if window controls are allowed (not Full locked)
844            let pane_allows_window_ctrl = pane.lock_level.allows_window_controls();
845            let canvas_allows_window_ctrl = self.lock_level.allows_window_controls();
846            let window_ctrl_enabled = pane_allows_window_ctrl && canvas_allows_window_ctrl;
847
848            // Close button (if closable and not full-locked)
849            if pane.closable {
850                let close_rect = Rect::from_min_size(
851                    Pos2::new(button_x, screen_title_rect.min.y + scaled_button_padding),
852                    Vec2::splat(scaled_button_size),
853                );
854                let close_response = ui.interact(
855                    close_rect,
856                    ui.id().with(&pane_id).with("close"),
857                    if window_ctrl_enabled {
858                        Sense::click()
859                    } else {
860                        Sense::hover()
861                    },
862                );
863                let close_color = if !window_ctrl_enabled {
864                    theme.text_muted
865                } else if close_response.hovered() {
866                    theme.state_danger
867                } else {
868                    theme.text_secondary
869                };
870                painter.text(
871                    close_rect.center(),
872                    egui::Align2::CENTER_CENTER,
873                    icons::X,
874                    icon_font.clone(),
875                    close_color,
876                );
877                if window_ctrl_enabled && close_response.clicked() {
878                    pane_closed = Some(pane_id.clone());
879                }
880                button_x -= scaled_button_size + scaled_button_padding * 0.5;
881            }
882
883            // Maximize button
884            let max_rect = Rect::from_min_size(
885                Pos2::new(button_x, screen_title_rect.min.y + scaled_button_padding),
886                Vec2::splat(scaled_button_size),
887            );
888            let max_response = ui.interact(
889                max_rect,
890                ui.id().with(&pane_id).with("maximize"),
891                if window_ctrl_enabled {
892                    Sense::click()
893                } else {
894                    Sense::hover()
895                },
896            );
897            let max_color = if !window_ctrl_enabled {
898                theme.text_muted
899            } else if max_response.hovered() {
900                theme.text_primary
901            } else {
902                theme.text_secondary
903            };
904            let max_icon = if pane.maximized {
905                icons::CORNERS_IN
906            } else {
907                icons::CORNERS_OUT
908            };
909            painter.text(
910                max_rect.center(),
911                egui::Align2::CENTER_CENTER,
912                max_icon,
913                icon_font.clone(),
914                max_color,
915            );
916            if window_ctrl_enabled && max_response.clicked() {
917                pane_maximized = Some((pane_id.clone(), !pane.maximized));
918                // Bring to front when maximizing
919                if !pane.maximized {
920                    pane_to_top = Some(pane_id.clone());
921                }
922            }
923            button_x -= scaled_button_size + scaled_button_padding * 0.5;
924
925            // Collapse button
926            let collapse_rect = Rect::from_min_size(
927                Pos2::new(button_x, screen_title_rect.min.y + scaled_button_padding),
928                Vec2::splat(scaled_button_size),
929            );
930            let collapse_response = ui.interact(
931                collapse_rect,
932                ui.id().with(&pane_id).with("collapse"),
933                if window_ctrl_enabled {
934                    Sense::click()
935                } else {
936                    Sense::hover()
937                },
938            );
939            let collapse_color = if !window_ctrl_enabled {
940                theme.text_muted
941            } else if collapse_response.hovered() {
942                theme.text_primary
943            } else {
944                theme.text_secondary
945            };
946            let collapse_icon = if pane.collapsed {
947                icons::CARET_DOWN
948            } else {
949                icons::CARET_UP
950            };
951            painter.text(
952                collapse_rect.center(),
953                egui::Align2::CENTER_CENTER,
954                collapse_icon,
955                icon_font.clone(),
956                collapse_color,
957            );
958            if window_ctrl_enabled && collapse_response.clicked() {
959                pane_collapsed = Some((pane_id.clone(), !pane.collapsed));
960            }
961            button_x -= scaled_button_size + scaled_button_padding * 0.5;
962
963            // Lock button (cycles: None -> Light -> Full -> None)
964            // Disabled when canvas is Full locked
965            let lock_button_enabled = canvas_allows_window_ctrl;
966            let lock_rect = Rect::from_min_size(
967                Pos2::new(button_x, screen_title_rect.min.y + scaled_button_padding),
968                Vec2::splat(scaled_button_size),
969            );
970            let lock_response = ui.interact(
971                lock_rect,
972                ui.id().with(&pane_id).with("lock"),
973                if lock_button_enabled {
974                    Sense::click()
975                } else {
976                    Sense::hover()
977                },
978            );
979            let (lock_icon, lock_color) = if !lock_button_enabled {
980                (icons::LOCK, theme.text_muted)
981            } else {
982                match pane.lock_level {
983                    LockLevel::None => (
984                        icons::LOCK_OPEN,
985                        if lock_response.hovered() {
986                            theme.text_primary
987                        } else {
988                            theme.text_secondary
989                        },
990                    ),
991                    LockLevel::Light => (
992                        icons::LOCK,
993                        if lock_response.hovered() {
994                            theme.text_primary
995                        } else {
996                            theme.primary
997                        },
998                    ),
999                    LockLevel::Full => (
1000                        icons::LOCK,
1001                        if lock_response.hovered() {
1002                            theme.text_primary
1003                        } else {
1004                            theme.state_danger
1005                        },
1006                    ),
1007                }
1008            };
1009            painter.text(
1010                lock_rect.center(),
1011                egui::Align2::CENTER_CENTER,
1012                lock_icon,
1013                icon_font.clone(),
1014                lock_color,
1015            );
1016            if lock_button_enabled && lock_response.clicked() {
1017                pane_lock_changed = Some((pane_id.clone(), pane.lock_level.cycle()));
1018            }
1019
1020            // Draw title (icon + text, with space for buttons)
1021            let title_text_max_x = button_x - scaled_button_padding;
1022            let font_size = (theme.font_size_sm * to_screen.scaling).max(theme.font_size_xs);
1023            let text_padding = theme.spacing_sm * to_screen.scaling;
1024            let title_text_rect = Rect::from_min_max(
1025                screen_title_rect.min + Vec2::new(text_padding, 0.0),
1026                Pos2::new(title_text_max_x, screen_title_rect.max.y),
1027            );
1028            // Clip to both title area and canvas rect
1029            let clipped_painter = ui.painter().with_clip_rect(title_text_rect.intersect(rect));
1030
1031            // Draw title icon if present
1032            let mut title_x = screen_title_rect.left_center().x + text_padding;
1033            if let Some(icon_char) = pane.title_icon {
1034                let icon_font = egui::FontId::new(font_size, FontFamily::Name("icons".into()));
1035                let icon_galley = clipped_painter.layout_no_wrap(
1036                    icon_char.to_string(),
1037                    icon_font.clone(),
1038                    theme.text_secondary,
1039                );
1040                clipped_painter.galley(
1041                    Pos2::new(
1042                        title_x,
1043                        screen_title_rect.center().y - icon_galley.size().y * 0.5,
1044                    ),
1045                    icon_galley.clone(),
1046                    theme.text_secondary,
1047                );
1048                title_x += icon_galley.size().x + text_padding * 0.5;
1049            }
1050
1051            // Draw title text
1052            clipped_painter.text(
1053                Pos2::new(title_x, screen_title_rect.center().y),
1054                egui::Align2::LEFT_CENTER,
1055                &pane.title,
1056                egui::FontId::proportional(font_size),
1057                theme.text_primary,
1058            );
1059
1060            // Title drag interaction (only in area before buttons)
1061            let title_drag_rect = Rect::from_min_max(
1062                screen_title_rect.min,
1063                Pos2::new(title_text_max_x, screen_title_rect.max.y),
1064            );
1065
1066            // Compute effective lock: use stricter of canvas and pane lock levels
1067            let pane_lock = pane.lock_level;
1068            let can_move_resize =
1069                self.lock_level.allows_move_resize() && pane_lock.allows_move_resize();
1070            let _can_window_control =
1071                self.lock_level.allows_window_controls() && pane_lock.allows_window_controls();
1072
1073            let title_response = ui.interact(
1074                title_drag_rect,
1075                ui.id().with(&pane_id).with("title_drag"),
1076                if !can_move_resize || is_maximized {
1077                    Sense::hover()
1078                } else {
1079                    Sense::click_and_drag()
1080                },
1081            );
1082
1083            if title_response.clicked() || title_response.drag_started() {
1084                pane_to_top = Some(pane_id.clone());
1085            }
1086
1087            if can_move_resize && !is_maximized && title_response.dragged() {
1088                if state.dragging.is_none() && state.resizing.is_none() {
1089                    state.dragging = Some(pane_id.clone());
1090                }
1091
1092                if state.dragging.as_ref() == Some(&pane_id) {
1093                    // Convert screen delta to graph delta
1094                    let screen_delta = title_response.drag_delta();
1095                    let graph_delta = screen_delta / to_screen.scaling;
1096                    pane_moved = Some((pane_id.clone(), graph_delta));
1097                }
1098            }
1099
1100            if title_response.drag_stopped() && state.dragging.as_ref() == Some(&pane_id) {
1101                state.dragging = None;
1102            }
1103
1104            // Resize handling (only if not collapsed, not maximized, resizable, and can move/resize)
1105            // Use direct pointer input to avoid stealing events from title bar
1106            if !pane.collapsed && !is_maximized && pane.resizable && can_move_resize {
1107                // Detect resize edges (check if pointer is near the pane border)
1108                let detect_edge = |pos: Pos2| -> Option<ResizeEdge> {
1109                    // Must be within expanded pane rect
1110                    let expanded = screen_pane_rect.expand(resize_edge_size);
1111                    if !expanded.contains(pos) {
1112                        return None;
1113                    }
1114
1115                    let in_left = pos.x < screen_pane_rect.min.x + resize_edge_size;
1116                    let in_right = pos.x > screen_pane_rect.max.x - resize_edge_size;
1117                    let in_top = pos.y < screen_pane_rect.min.y + resize_edge_size;
1118                    let in_bottom = pos.y > screen_pane_rect.max.y - resize_edge_size;
1119
1120                    match (in_left, in_right, in_top, in_bottom) {
1121                        (true, _, true, _) => Some(ResizeEdge::TopLeft),
1122                        (true, _, _, true) => Some(ResizeEdge::BottomLeft),
1123                        (_, true, true, _) => Some(ResizeEdge::TopRight),
1124                        (_, true, _, true) => Some(ResizeEdge::BottomRight),
1125                        (true, _, _, _) => Some(ResizeEdge::Left),
1126                        (_, true, _, _) => Some(ResizeEdge::Right),
1127                        (_, _, true, _) => Some(ResizeEdge::Top),
1128                        (_, _, _, true) => Some(ResizeEdge::Bottom),
1129                        _ => None,
1130                    }
1131                };
1132
1133                // Get pointer state directly (avoids ui.interact stealing events)
1134                let pointer = ui.input(|i| i.pointer.clone());
1135
1136                // Update cursor based on hover position
1137                if let Some(hover_pos) = pointer.hover_pos() {
1138                    if let Some(edge) = detect_edge(hover_pos) {
1139                        let cursor = match edge {
1140                            ResizeEdge::Left | ResizeEdge::Right => {
1141                                egui::CursorIcon::ResizeHorizontal
1142                            }
1143                            ResizeEdge::Top | ResizeEdge::Bottom => {
1144                                egui::CursorIcon::ResizeVertical
1145                            }
1146                            ResizeEdge::TopLeft | ResizeEdge::BottomRight => {
1147                                egui::CursorIcon::ResizeNwSe
1148                            }
1149                            ResizeEdge::TopRight | ResizeEdge::BottomLeft => {
1150                                egui::CursorIcon::ResizeNeSw
1151                            }
1152                        };
1153                        ui.ctx().set_cursor_icon(cursor);
1154                    }
1155                }
1156
1157                // Handle resize drag start
1158                if pointer.any_pressed() && state.resizing.is_none() && state.dragging.is_none() {
1159                    if let Some(pos) = pointer.press_origin() {
1160                        if let Some(edge) = detect_edge(pos) {
1161                            state.resizing = Some((pane_id.clone(), edge));
1162                            pane_to_top = Some(pane_id.clone());
1163                        }
1164                    }
1165                }
1166
1167                // Handle resize drag
1168                if let Some((ref resize_id, edge)) = state.resizing.clone() {
1169                    if resize_id == &pane_id {
1170                        if pointer.is_decidedly_dragging() {
1171                            let delta = pointer.delta() / to_screen.scaling;
1172                            let min_size = pane.min_size;
1173
1174                            // Calculate new size and position based on edge
1175                            let mut new_pos = pane.position;
1176                            let mut new_size = pane.size;
1177
1178                            match edge {
1179                                ResizeEdge::Right => {
1180                                    new_size.x = (new_size.x + delta.x).max(min_size.x);
1181                                }
1182                                ResizeEdge::Bottom => {
1183                                    new_size.y = (new_size.y + delta.y).max(min_size.y);
1184                                }
1185                                ResizeEdge::Left => {
1186                                    let new_width = (new_size.x - delta.x).max(min_size.x);
1187                                    new_pos.x += new_size.x - new_width;
1188                                    new_size.x = new_width;
1189                                }
1190                                ResizeEdge::Top => {
1191                                    let new_height = (new_size.y - delta.y).max(min_size.y);
1192                                    new_pos.y += new_size.y - new_height;
1193                                    new_size.y = new_height;
1194                                }
1195                                ResizeEdge::TopLeft => {
1196                                    let new_width = (new_size.x - delta.x).max(min_size.x);
1197                                    let new_height = (new_size.y - delta.y).max(min_size.y);
1198                                    new_pos.x += new_size.x - new_width;
1199                                    new_pos.y += new_size.y - new_height;
1200                                    new_size = Vec2::new(new_width, new_height);
1201                                }
1202                                ResizeEdge::TopRight => {
1203                                    let new_height = (new_size.y - delta.y).max(min_size.y);
1204                                    new_pos.y += new_size.y - new_height;
1205                                    new_size.x = (new_size.x + delta.x).max(min_size.x);
1206                                    new_size.y = new_height;
1207                                }
1208                                ResizeEdge::BottomLeft => {
1209                                    let new_width = (new_size.x - delta.x).max(min_size.x);
1210                                    new_pos.x += new_size.x - new_width;
1211                                    new_size.x = new_width;
1212                                    new_size.y = (new_size.y + delta.y).max(min_size.y);
1213                                }
1214                                ResizeEdge::BottomRight => {
1215                                    new_size.x = (new_size.x + delta.x).max(min_size.x);
1216                                    new_size.y = (new_size.y + delta.y).max(min_size.y);
1217                                }
1218                            }
1219
1220                            if new_pos != pane.position {
1221                                pane_moved = Some((pane_id.clone(), new_pos - pane.position));
1222                            }
1223                            if new_size != pane.size {
1224                                pane_resized = Some((pane_id.clone(), new_size));
1225                            }
1226                        }
1227
1228                        // Check if drag ended
1229                        if pointer.any_released() {
1230                            state.resizing = None;
1231                        }
1232                    }
1233                }
1234            }
1235
1236            // Draw content area (only if not collapsed)
1237            if !pane.collapsed {
1238                let base_padding = self.content_padding.unwrap_or(theme.spacing_sm);
1239                let content_padding = base_padding * to_screen.scaling;
1240                let screen_content_rect = Rect::from_min_max(
1241                    screen_pane_rect.min
1242                        + Vec2::new(content_padding, scaled_title_height + content_padding),
1243                    screen_pane_rect.max - Vec2::new(content_padding, content_padding),
1244                );
1245
1246                // Clip content to canvas area
1247                let clipped_content_rect = screen_content_rect.intersect(rect);
1248
1249                // Only draw content if there's visible space
1250                let min_size = theme.spacing_sm * to_screen.scaling;
1251                if clipped_content_rect.height() > min_size
1252                    && clipped_content_rect.width() > min_size
1253                {
1254                    // Create child UI for content
1255                    let mut child_ui = ui.new_child(
1256                        egui::UiBuilder::new()
1257                            .max_rect(screen_content_rect)
1258                            .layout(egui::Layout::top_down(egui::Align::LEFT)),
1259                    );
1260                    child_ui.set_clip_rect(clipped_content_rect);
1261                    child_ui.spacing_mut().item_spacing =
1262                        egui::vec2(theme.spacing_xs, theme.spacing_xs);
1263
1264                    let pane_ref = self.layout.get_pane(&pane_id).unwrap();
1265                    (self.content_fn)(&mut child_ui, pane_ref);
1266                }
1267            }
1268        }
1269
1270        // Apply pane moved
1271        if let Some((id, delta)) = pane_moved {
1272            if let Some(pane) = self.layout.get_pane_mut(&id) {
1273                pane.position += delta;
1274                events.push(NodeLayoutEvent::PaneMoved {
1275                    id: id.clone(),
1276                    position: pane.position,
1277                });
1278            }
1279        }
1280
1281        // Apply pane resized
1282        if let Some((id, new_size)) = pane_resized {
1283            if let Some(pane) = self.layout.get_pane_mut(&id) {
1284                pane.size = new_size;
1285                events.push(NodeLayoutEvent::PaneResized {
1286                    id: id.clone(),
1287                    size: new_size,
1288                });
1289            }
1290        }
1291
1292        // Apply pane collapsed
1293        if let Some((id, collapsed)) = pane_collapsed {
1294            if let Some(pane) = self.layout.get_pane_mut(&id) {
1295                pane.collapsed = collapsed;
1296                events.push(NodeLayoutEvent::PaneCollapsed {
1297                    id: id.clone(),
1298                    collapsed,
1299                });
1300            }
1301        }
1302
1303        // Apply pane maximized
1304        if let Some((id, maximized)) = pane_maximized {
1305            if let Some(pane) = self.layout.get_pane_mut(&id) {
1306                if maximized {
1307                    // Store current size/position for restore
1308                    pane.pre_maximize_size = Some(pane.size);
1309                    pane.pre_maximize_position = Some(pane.position);
1310                } else {
1311                    // Restore previous size/position
1312                    if let Some(size) = pane.pre_maximize_size.take() {
1313                        pane.size = size;
1314                    }
1315                    if let Some(pos) = pane.pre_maximize_position.take() {
1316                        pane.position = pos;
1317                    }
1318                }
1319                pane.maximized = maximized;
1320                events.push(NodeLayoutEvent::PaneMaximized {
1321                    id: id.clone(),
1322                    maximized,
1323                });
1324            }
1325        }
1326
1327        // Apply pane lock level change
1328        if let Some((id, new_level)) = pane_lock_changed {
1329            if let Some(pane) = self.layout.get_pane_mut(&id) {
1330                pane.lock_level = new_level;
1331                events.push(NodeLayoutEvent::PaneLockChanged {
1332                    id: id.clone(),
1333                    lock_level: new_level,
1334                });
1335            }
1336        }
1337
1338        // Apply pane closed
1339        if let Some(id) = pane_closed {
1340            self.layout.remove_pane(&id);
1341            events.push(NodeLayoutEvent::PaneClosed(id));
1342        }
1343
1344        // Bring pane to top
1345        if let Some(id) = pane_to_top {
1346            if let Some(pos) = state.draw_order.iter().position(|x| x == &id) {
1347                state.draw_order.remove(pos);
1348                state.draw_order.insert(0, id);
1349            }
1350        }
1351
1352        // Save state
1353        state.to_screen = to_screen;
1354        ui.ctx().data_mut(|d| d.insert_temp(state_id, state));
1355
1356        events
1357    }
1358
1359    fn sync_draw_order(&self, state: &mut LayoutState) {
1360        // Remove panes that no longer exist
1361        state
1362            .draw_order
1363            .retain(|id| self.layout.id_to_index.contains_key(id));
1364
1365        // Add new panes
1366        for pane in &self.layout.panes {
1367            if !state.draw_order.contains(&pane.id) {
1368                state.draw_order.push(pane.id.clone());
1369            }
1370        }
1371    }
1372
1373    /// Draw the menu bar
1374    fn draw_menu_bar(
1375        &mut self,
1376        ui: &mut Ui,
1377        rect: Rect,
1378        theme: &Theme,
1379        events: &mut Vec<NodeLayoutEvent>,
1380        draw_order: &[String],
1381    ) {
1382        use crate::atoms::icons;
1383
1384        // Draw background
1385        let painter = ui.painter_at(rect);
1386        painter.rect_filled(rect, 0.0, theme.bg_primary);
1387        painter.hline(
1388            rect.x_range(),
1389            rect.max.y,
1390            egui::Stroke::new(1.0, theme.border),
1391        );
1392
1393        // Inset rect for better vertical centering
1394        let inner_rect = rect.shrink2(Vec2::new(theme.spacing_sm, 2.0));
1395
1396        // Use allocate_ui_at_rect to properly handle input
1397        ui.allocate_ui_at_rect(inner_rect, |child_ui| {
1398            child_ui.horizontal_centered(|child_ui| {
1399                child_ui.style_mut().spacing.item_spacing = Vec2::new(4.0, 0.0);
1400
1401                // Helper to create icon text
1402                let icon_text = |icon: &str| -> egui::RichText {
1403                    egui::RichText::new(icon).family(FontFamily::Name("icons".into()))
1404                };
1405
1406                // Lock button with Phosphor icon
1407                let (lock_icon, lock_tooltip) = match self.lock_level {
1408                    LockLevel::None => (icons::LOCK_OPEN, "Unlocked - Click to lock position"),
1409                    LockLevel::Light => (icons::LOCK_KEY, "Position locked - Click to fully lock"),
1410                    LockLevel::Full => (icons::LOCK, "Fully locked - Click to unlock"),
1411                };
1412
1413                if child_ui
1414                    .add(egui::Button::new(icon_text(lock_icon)).min_size(Vec2::new(24.0, 20.0)))
1415                    .on_hover_text(lock_tooltip)
1416                    .clicked()
1417                {
1418                    let new_level = match self.lock_level {
1419                        LockLevel::None => LockLevel::Light,
1420                        LockLevel::Light => LockLevel::Full,
1421                        LockLevel::Full => LockLevel::None,
1422                    };
1423                    self.lock_level = new_level;
1424                    events.push(NodeLayoutEvent::CanvasLockChanged(new_level));
1425                }
1426
1427                child_ui.separator();
1428
1429                // Arrange dropdown with Phosphor icon
1430                let gap = super::layout_helpers::DEFAULT_GAP;
1431
1432                child_ui.menu_button(icon_text(icons::SLIDERS_HORIZONTAL), |ui| {
1433                    use crate::atoms::ListItem;
1434
1435                    if ListItem::new("Grid")
1436                        .icon(icons::GRID_FOUR)
1437                        .compact()
1438                        .show(ui)
1439                        .clicked()
1440                    {
1441                        let moved = self.layout.auto_arrange(
1442                            ArrangeStrategy::Grid { columns: None },
1443                            gap,
1444                            None,
1445                            None,
1446                        );
1447                        if !moved.is_empty() {
1448                            events.push(NodeLayoutEvent::AutoArranged {
1449                                strategy: ArrangeStrategy::Grid { columns: None },
1450                                moved_pane_ids: moved,
1451                            });
1452                        }
1453                        ui.close();
1454                    }
1455                    if ListItem::new("Horizontal")
1456                        .icon(icons::ARROWS_OUT_LINE_HORIZONTAL)
1457                        .compact()
1458                        .show(ui)
1459                        .clicked()
1460                    {
1461                        let moved =
1462                            self.layout
1463                                .auto_arrange(ArrangeStrategy::Horizontal, gap, None, None);
1464                        if !moved.is_empty() {
1465                            events.push(NodeLayoutEvent::AutoArranged {
1466                                strategy: ArrangeStrategy::Horizontal,
1467                                moved_pane_ids: moved,
1468                            });
1469                        }
1470                        ui.close();
1471                    }
1472                    if ListItem::new("Vertical")
1473                        .icon(icons::ARROWS_OUT_LINE_VERTICAL)
1474                        .compact()
1475                        .show(ui)
1476                        .clicked()
1477                    {
1478                        let moved =
1479                            self.layout
1480                                .auto_arrange(ArrangeStrategy::Vertical, gap, None, None);
1481                        if !moved.is_empty() {
1482                            events.push(NodeLayoutEvent::AutoArranged {
1483                                strategy: ArrangeStrategy::Vertical,
1484                                moved_pane_ids: moved,
1485                            });
1486                        }
1487                        ui.close();
1488                    }
1489                    if ListItem::new("Cascade")
1490                        .icon(icons::SQUARES_FOUR)
1491                        .compact()
1492                        .show(ui)
1493                        .clicked()
1494                    {
1495                        // Use Z-order: back pane at top-left, front pane at bottom-right
1496                        let moved = self.layout.auto_arrange(
1497                            ArrangeStrategy::Cascade,
1498                            gap,
1499                            None,
1500                            Some(draw_order),
1501                        );
1502                        if !moved.is_empty() {
1503                            events.push(NodeLayoutEvent::AutoArranged {
1504                                strategy: ArrangeStrategy::Cascade,
1505                                moved_pane_ids: moved,
1506                            });
1507                        }
1508                        ui.close();
1509                    }
1510                    ui.separator();
1511                    if ListItem::new("Resolve Overlaps")
1512                        .icon(icons::BROOM)
1513                        .compact()
1514                        .show(ui)
1515                        .clicked()
1516                    {
1517                        let moved = self.layout.auto_arrange(
1518                            ArrangeStrategy::ResolveOverlaps,
1519                            gap,
1520                            None,
1521                            None,
1522                        );
1523                        if !moved.is_empty() {
1524                            events.push(NodeLayoutEvent::AutoArranged {
1525                                strategy: ArrangeStrategy::ResolveOverlaps,
1526                                moved_pane_ids: moved,
1527                            });
1528                        }
1529                        ui.close();
1530                    }
1531                });
1532
1533                child_ui.separator();
1534
1535                // Zoom buttons with Phosphor icons
1536                if child_ui
1537                    .add(
1538                        egui::Button::new(icon_text(icons::FRAME_CORNERS))
1539                            .min_size(Vec2::new(28.0, 20.0)),
1540                    )
1541                    .on_hover_text("Zoom to fit all panes")
1542                    .clicked()
1543                {
1544                    events.push(NodeLayoutEvent::ZoomToFit);
1545                }
1546
1547                if child_ui
1548                    .add(egui::Button::new("100%").min_size(Vec2::new(40.0, 20.0)))
1549                    .on_hover_text("Reset zoom to 100%")
1550                    .clicked()
1551                {
1552                    events.push(NodeLayoutEvent::ZoomReset);
1553                }
1554
1555                // Show pane count on the right
1556                child_ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
1557                    ui.add_space(theme.spacing_sm);
1558                    let visible_count = self.layout.panes.iter().filter(|p| !p.collapsed).count();
1559                    let total_count = self.layout.panes.len();
1560                    ui.label(
1561                        egui::RichText::new(format!("{}/{} panes", visible_count, total_count))
1562                            .color(theme.text_secondary)
1563                            .small(),
1564                    );
1565                });
1566            }); // end horizontal_centered
1567        }); // end allocate_ui_at_rect
1568    }
1569
1570    fn draw_grid_screen(
1571        &self,
1572        painter: &egui::Painter,
1573        rect: Rect,
1574        grid_size: f32,
1575        grid_alpha: u8,
1576        to_screen: &TSTransform,
1577        theme: &Theme,
1578    ) {
1579        let grid_color = Color32::from_rgba_unmultiplied(
1580            theme.border.r(),
1581            theme.border.g(),
1582            theme.border.b(),
1583            grid_alpha,
1584        );
1585
1586        let from_screen = to_screen.inverse();
1587        let viewport = from_screen * rect;
1588
1589        // Calculate screen-space grid size
1590        let screen_grid_size = grid_size * to_screen.scaling;
1591
1592        // Don't draw grid if too small or too large
1593        if screen_grid_size < 10.0 || screen_grid_size > 500.0 {
1594            return;
1595        }
1596
1597        // Vertical lines
1598        let start_x = (viewport.min.x / grid_size).floor() * grid_size;
1599        let mut x = start_x;
1600        while x <= viewport.max.x {
1601            let screen_x = to_screen.translation.x + x * to_screen.scaling;
1602            if screen_x >= rect.min.x && screen_x <= rect.max.x {
1603                painter.line_segment(
1604                    [
1605                        Pos2::new(screen_x, rect.min.y),
1606                        Pos2::new(screen_x, rect.max.y),
1607                    ],
1608                    Stroke::new(theme.stroke_width, grid_color),
1609                );
1610            }
1611            x += grid_size;
1612        }
1613
1614        // Horizontal lines
1615        let start_y = (viewport.min.y / grid_size).floor() * grid_size;
1616        let mut y = start_y;
1617        while y <= viewport.max.y {
1618            let screen_y = to_screen.translation.y + y * to_screen.scaling;
1619            if screen_y >= rect.min.y && screen_y <= rect.max.y {
1620                painter.line_segment(
1621                    [
1622                        Pos2::new(rect.min.x, screen_y),
1623                        Pos2::new(rect.max.x, screen_y),
1624                    ],
1625                    Stroke::new(theme.stroke_width, grid_color),
1626                );
1627            }
1628            y += grid_size;
1629        }
1630    }
1631}