Skip to main content

egui_cha_ds/molecules/
workspace.rs

1//! WorkspaceCanvas - Flexible window layout system
2//!
3//! A unified layout system that supports both fixed (Tile) and free layouts
4//! with optional locking for performance-critical contexts.
5//!
6//! # Features
7//! - **Tile Mode**: Auto-arrange panes in a grid layout
8//! - **Free Mode**: Drag panes freely with snap-to-edge/grid
9//! - **Lock/Unlock**: Prevent accidental layout changes (ideal for Live mode)
10//! - **Snap System**: Magnetic snapping to edges and other panes
11//!
12//! # Usage Patterns
13//!
14//! ## Live Mode (Fixed, Locked)
15//! ```ignore
16//! WorkspaceCanvas::new(&mut panes)
17//!     .layout(LayoutMode::Tile { columns: None })
18//!     .locked(true)  // Prevent accidental changes
19//!     .show(ui, |ui, pane| { ... });
20//! ```
21//!
22//! ## Lab Mode (Free, Unlocked)
23//! ```ignore
24//! WorkspaceCanvas::new(&mut panes)
25//!     .layout(LayoutMode::Free)
26//!     .snap_threshold(8.0)
27//!     .show(ui, |ui, pane| { ... });
28//! ```
29
30use crate::Theme;
31use egui::{Color32, Id, Pos2, Rect, Sense, Stroke, Ui, Vec2};
32use egui_cha::ViewCtx;
33
34/// Layout mode for workspace
35#[derive(Clone, Copy, Debug, PartialEq)]
36pub enum LayoutMode {
37    /// Auto-arrange panes in a grid
38    /// columns = None means auto-calculate based on pane count
39    Tile { columns: Option<usize> },
40
41    /// Free positioning with drag & snap
42    Free,
43}
44
45impl Default for LayoutMode {
46    fn default() -> Self {
47        LayoutMode::Tile { columns: None }
48    }
49}
50
51/// A pane (window) in the workspace
52#[derive(Clone, Debug)]
53pub struct WorkspacePane {
54    /// Unique identifier
55    pub id: String,
56    /// Display title
57    pub title: String,
58    /// Position (used in Free mode, computed in Tile mode)
59    pub position: Pos2,
60    /// Size (used in Free mode, computed in Tile mode)
61    pub size: Vec2,
62    /// Minimum size constraint
63    pub min_size: Vec2,
64    /// Whether the pane is visible
65    pub visible: bool,
66    /// Whether the pane is minimized
67    pub minimized: bool,
68    /// Order in the layout (for Tile mode)
69    pub order: usize,
70    /// Weight for proportional sizing in Tile mode (default: 1.0)
71    pub weight: f32,
72}
73
74impl WorkspacePane {
75    /// Create a new pane
76    pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
77        Self {
78            id: id.into(),
79            title: title.into(),
80            position: Pos2::new(50.0, 50.0),
81            size: Vec2::new(200.0, 150.0),
82            min_size: Vec2::new(100.0, 80.0),
83            visible: true,
84            minimized: false,
85            order: 0,
86            weight: 1.0,
87        }
88    }
89
90    /// Set initial position
91    pub fn with_position(mut self, x: f32, y: f32) -> Self {
92        self.position = Pos2::new(x, y);
93        self
94    }
95
96    /// Set initial size
97    pub fn with_size(mut self, width: f32, height: f32) -> Self {
98        self.size = Vec2::new(width, height);
99        self
100    }
101
102    /// Set minimum size
103    pub fn with_min_size(mut self, width: f32, height: f32) -> Self {
104        self.min_size = Vec2::new(width, height);
105        self
106    }
107
108    /// Set order (for Tile mode)
109    pub fn with_order(mut self, order: usize) -> Self {
110        self.order = order;
111        self
112    }
113
114    /// Set visibility
115    pub fn with_visible(mut self, visible: bool) -> Self {
116        self.visible = visible;
117        self
118    }
119
120    /// Set weight for proportional sizing (default: 1.0)
121    pub fn with_weight(mut self, weight: f32) -> Self {
122        self.weight = weight.max(0.1); // Minimum weight to prevent zero-size
123        self
124    }
125}
126
127/// Events emitted by WorkspaceCanvas
128#[derive(Clone, Debug)]
129pub enum WorkspaceEvent {
130    /// Pane was moved
131    PaneMoved { id: String, position: Pos2 },
132    /// Pane was resized
133    PaneResized { id: String, size: Vec2 },
134    /// Pane was closed
135    PaneClosed(String),
136    /// Pane was minimized/restored
137    PaneMinimized { id: String, minimized: bool },
138    /// Pane order changed (drag reorder in Tile mode)
139    PaneReordered { from: usize, to: usize },
140    /// Pane weights changed (divider drag in Tile mode)
141    WeightsChanged(Vec<(String, f32)>),
142    /// Layout mode changed
143    LayoutChanged(LayoutMode),
144    /// Lock state changed
145    LockChanged(bool),
146}
147
148/// Snap target for visual feedback
149#[derive(Clone, Debug, PartialEq)]
150pub enum SnapTarget {
151    /// Snapped to another pane's edge
152    Pane { id: String, edge: Edge },
153    /// Snapped to canvas edge
154    CanvasEdge(Edge),
155    /// Snapped to grid
156    Grid { x: i32, y: i32 },
157}
158
159/// Edge direction
160#[derive(Clone, Copy, Debug, PartialEq, Eq)]
161pub enum Edge {
162    Left,
163    Right,
164    Top,
165    Bottom,
166}
167
168/// Divider info for tile resize
169#[derive(Clone, Debug)]
170struct DividerInfo {
171    /// Divider orientation (true = vertical, false = horizontal)
172    is_vertical: bool,
173    /// Position of the divider line
174    position: f32,
175    /// Pane orders on either side (left/top, right/bottom)
176    panes: (Vec<usize>, Vec<usize>),
177    /// Divider rect for hit testing
178    rect: Rect,
179}
180
181/// Internal state for drag operations
182#[derive(Clone, Debug, Default)]
183struct DragState {
184    /// Currently dragging pane ID
185    dragging: Option<String>,
186    /// Original pane position at drag start (Free mode)
187    original_pos: Option<Pos2>,
188    /// Current snap target (for visual feedback)
189    snap_target: Option<SnapTarget>,
190    /// Resizing pane ID and edge
191    resizing: Option<(String, ResizeEdge)>,
192    /// Tile reorder: source pane order
193    tile_drag_source: Option<usize>,
194    /// Tile reorder: current drop target order (for highlight)
195    tile_drop_target: Option<usize>,
196    /// Divider drag: which divider is being dragged
197    divider_drag: Option<DividerDrag>,
198}
199
200/// State for divider dragging
201#[derive(Clone, Debug)]
202struct DividerDrag {
203    /// Is this a vertical divider (resize columns)?
204    is_vertical: bool,
205    /// Column or row index of the divider
206    index: usize,
207    /// Starting position of the drag
208    start_pos: f32,
209    /// Original weights of affected panes
210    original_weights: Vec<(usize, f32)>,
211}
212
213#[derive(Clone, Copy, Debug)]
214enum ResizeEdge {
215    Right,
216    Bottom,
217    BottomRight,
218}
219
220/// Internal struct for collecting pane interactions
221struct PaneInteraction {
222    idx: usize,
223    rect: Rect,
224    title_rect: Rect,
225    close_rect: Option<Rect>,
226    minimize_rect: Option<Rect>,
227    title_hovered: bool,
228    title_dragged: bool,
229    close_clicked: bool,
230    minimize_clicked: bool,
231    resize_edge: Option<ResizeEdge>,
232}
233
234/// Flexible workspace canvas with Tile and Free layout modes
235pub struct WorkspaceCanvas<'a> {
236    panes: &'a mut Vec<WorkspacePane>,
237    layout_mode: LayoutMode,
238    locked: bool,
239    snap_threshold: f32,
240    grid_size: Option<f32>,
241    show_grid: bool,
242    gap: f32,
243    title_bar_height: f32,
244    show_close_buttons: bool,
245    show_minimize_buttons: bool,
246}
247
248impl<'a> WorkspaceCanvas<'a> {
249    /// Create a new workspace canvas
250    pub fn new(panes: &'a mut Vec<WorkspacePane>) -> Self {
251        Self {
252            panes,
253            layout_mode: LayoutMode::default(),
254            locked: false,
255            snap_threshold: 8.0,
256            grid_size: None,
257            show_grid: false,
258            gap: 4.0,
259            title_bar_height: 24.0,
260            show_close_buttons: true,
261            show_minimize_buttons: true,
262        }
263    }
264
265    /// Set layout mode
266    pub fn layout(mut self, mode: LayoutMode) -> Self {
267        self.layout_mode = mode;
268        self
269    }
270
271    /// Set locked state (prevents all layout changes)
272    pub fn locked(mut self, locked: bool) -> Self {
273        self.locked = locked;
274        self
275    }
276
277    /// Set snap threshold (distance for magnetic snapping)
278    pub fn snap_threshold(mut self, threshold: f32) -> Self {
279        self.snap_threshold = threshold;
280        self
281    }
282
283    /// Set grid size for snapping (None = no grid)
284    pub fn grid(mut self, size: Option<f32>) -> Self {
285        self.grid_size = size;
286        self
287    }
288
289    /// Show/hide grid lines
290    pub fn show_grid(mut self, show: bool) -> Self {
291        self.show_grid = show;
292        self
293    }
294
295    /// Set gap between panes in Tile mode
296    pub fn gap(mut self, gap: f32) -> Self {
297        self.gap = gap;
298        self
299    }
300
301    /// Set title bar height
302    pub fn title_bar_height(mut self, height: f32) -> Self {
303        self.title_bar_height = height;
304        self
305    }
306
307    /// Show/hide close buttons
308    pub fn show_close_buttons(mut self, show: bool) -> Self {
309        self.show_close_buttons = show;
310        self
311    }
312
313    /// Show/hide minimize buttons
314    pub fn show_minimize_buttons(mut self, show: bool) -> Self {
315        self.show_minimize_buttons = show;
316        self
317    }
318
319    /// Show workspace and render pane contents
320    pub fn show<F>(self, ui: &mut Ui, mut content: F) -> Vec<WorkspaceEvent>
321    where
322        F: FnMut(&mut Ui, &WorkspacePane),
323    {
324        self.show_internal(ui, &mut content)
325    }
326
327    /// TEA-style: Show workspace and emit events
328    pub fn show_with<Msg, F>(
329        self,
330        ctx: &mut ViewCtx<'_, Msg>,
331        mut content: F,
332        on_event: impl Fn(WorkspaceEvent) -> Msg,
333    ) where
334        F: FnMut(&mut Ui, &WorkspacePane),
335    {
336        let events = self.show_internal(ctx.ui, &mut content);
337        for event in events {
338            ctx.emit(on_event(event));
339        }
340    }
341
342    fn show_internal<F>(self, ui: &mut Ui, content: &mut F) -> Vec<WorkspaceEvent>
343    where
344        F: FnMut(&mut Ui, &WorkspacePane),
345    {
346        let theme = Theme::current(ui.ctx());
347        let mut events = Vec::new();
348
349        // Get available rect
350        let available_rect = ui.available_rect_before_wrap();
351        let canvas_id = Id::new("workspace_canvas");
352
353        // Load/save drag state
354        let mut drag_state: DragState = ui
355            .ctx()
356            .data_mut(|d| d.get_temp(canvas_id).unwrap_or_default());
357
358        // Allocate the canvas area
359        let (rect, _response) = ui.allocate_exact_size(available_rect.size(), Sense::hover());
360
361        if !ui.is_rect_visible(rect) {
362            return events;
363        }
364
365        // Get visible panes sorted by order
366        let mut visible_panes: Vec<_> = self
367            .panes
368            .iter()
369            .enumerate()
370            .filter(|(_, p)| p.visible && !p.minimized)
371            .collect();
372        visible_panes.sort_by_key(|(_, p)| p.order);
373
374        // Calculate layout based on mode
375        let columns = match self.layout_mode {
376            LayoutMode::Tile { columns } => columns,
377            LayoutMode::Free => None,
378        };
379        let pane_rects: Vec<(usize, Rect)> = match self.layout_mode {
380            LayoutMode::Tile { columns } => {
381                self.calculate_tile_layout(&visible_panes, rect, columns)
382            }
383            LayoutMode::Free => visible_panes
384                .iter()
385                .map(|(idx, pane)| {
386                    // Position is relative to the canvas rect
387                    let pos = rect.min + pane.position.to_vec2();
388                    (*idx, Rect::from_min_size(pos, pane.size))
389                })
390                .collect(),
391        };
392
393        // Calculate dividers for Tile mode
394        let dividers = if matches!(self.layout_mode, LayoutMode::Tile { .. }) && !self.locked {
395            self.calculate_dividers(&visible_panes, rect, columns)
396        } else {
397            Vec::new()
398        };
399
400        // Handle divider interactions
401        if !self.locked && !dividers.is_empty() {
402            let pointer_pos = ui.input(|i| i.pointer.hover_pos());
403
404            // Check for divider hover and start drag
405            if let Some(pos) = pointer_pos {
406                for (div_idx, divider) in dividers.iter().enumerate() {
407                    if divider.rect.contains(pos) {
408                        // Set cursor
409                        if divider.is_vertical {
410                            ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
411                        } else {
412                            ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
413                        }
414
415                        // Start drag
416                        if ui.input(|i| i.pointer.any_pressed())
417                            && drag_state.divider_drag.is_none()
418                        {
419                            let original_weights: Vec<(usize, f32)> =
420                                self.panes.iter().map(|p| (p.order, p.weight)).collect();
421                            drag_state.divider_drag = Some(DividerDrag {
422                                is_vertical: divider.is_vertical,
423                                index: div_idx,
424                                start_pos: if divider.is_vertical { pos.x } else { pos.y },
425                                original_weights,
426                            });
427                        }
428                        break;
429                    }
430                }
431            }
432
433            // Handle ongoing divider drag
434            if let Some(ref drag) = drag_state.divider_drag {
435                if let Some(pos) = pointer_pos {
436                    let divider = &dividers[drag.index.min(dividers.len().saturating_sub(1))];
437                    let delta = if drag.is_vertical {
438                        pos.x - drag.start_pos
439                    } else {
440                        pos.y - drag.start_pos
441                    };
442
443                    // Calculate weight changes
444                    let sensitivity = 0.005; // Weight change per pixel
445                    let weight_delta = delta * sensitivity;
446
447                    let mut new_weights: Vec<(String, f32)> = Vec::new();
448                    for (order, orig_weight) in &drag.original_weights {
449                        let pane = self.panes.iter().find(|p| p.order == *order);
450                        if let Some(pane) = pane {
451                            let is_left = divider.panes.0.contains(order);
452                            let is_right = divider.panes.1.contains(order);
453                            let new_weight = if is_left {
454                                (*orig_weight + weight_delta).max(0.2)
455                            } else if is_right {
456                                (*orig_weight - weight_delta).max(0.2)
457                            } else {
458                                *orig_weight
459                            };
460                            new_weights.push((pane.id.clone(), new_weight));
461                        }
462                    }
463
464                    if !new_weights.is_empty() {
465                        events.push(WorkspaceEvent::WeightsChanged(new_weights));
466                    }
467                }
468            }
469        }
470
471        // Collect interaction info
472        let mut interactions: Vec<PaneInteraction> = Vec::new();
473
474        for (idx, pane_rect) in &pane_rects {
475            let _pane = &self.panes[*idx];
476
477            // Title bar rect
478            let title_rect = Rect::from_min_size(
479                pane_rect.min,
480                Vec2::new(pane_rect.width(), self.title_bar_height),
481            );
482
483            // Button rects
484            let button_size = self.title_bar_height - 8.0;
485            let mut button_x = pane_rect.max.x - 4.0;
486
487            let close_rect = if self.show_close_buttons {
488                button_x -= button_size;
489                Some(Rect::from_min_size(
490                    Pos2::new(button_x, pane_rect.min.y + 4.0),
491                    Vec2::splat(button_size),
492                ))
493            } else {
494                None
495            };
496
497            let minimize_rect = if self.show_minimize_buttons {
498                button_x -= button_size + 2.0;
499                Some(Rect::from_min_size(
500                    Pos2::new(button_x, pane_rect.min.y + 4.0),
501                    Vec2::splat(button_size),
502                ))
503            } else {
504                None
505            };
506
507            // Allocate interaction areas
508            let title_response = ui.allocate_rect(title_rect, Sense::click_and_drag());
509            let close_response = close_rect.map(|r| ui.allocate_rect(r, Sense::click()));
510            let minimize_response = minimize_rect.map(|r| ui.allocate_rect(r, Sense::click()));
511
512            // Check resize edge hover (only in Free mode and unlocked)
513            let resize_edge = if !self.locked && matches!(self.layout_mode, LayoutMode::Free) {
514                self.check_resize_edge(ui, *pane_rect)
515            } else {
516                None
517            };
518
519            interactions.push(PaneInteraction {
520                idx: *idx,
521                rect: *pane_rect,
522                title_rect,
523                close_rect,
524                minimize_rect,
525                title_hovered: title_response.hovered(),
526                title_dragged: title_response.dragged() && !self.locked,
527                close_clicked: close_response.map_or(false, |r| r.clicked()),
528                minimize_clicked: minimize_response.map_or(false, |r| r.clicked()),
529                resize_edge,
530            });
531        }
532
533        // Process interactions (before drawing)
534        for interaction in &interactions {
535            let pane = &self.panes[interaction.idx];
536
537            // Handle close
538            if interaction.close_clicked {
539                events.push(WorkspaceEvent::PaneClosed(pane.id.clone()));
540            }
541
542            // Handle minimize
543            if interaction.minimize_clicked {
544                events.push(WorkspaceEvent::PaneMinimized {
545                    id: pane.id.clone(),
546                    minimized: !pane.minimized,
547                });
548            }
549
550            // Handle drag (Tile mode - reorder)
551            if interaction.title_dragged && matches!(self.layout_mode, LayoutMode::Tile { .. }) {
552                // Start drag
553                if drag_state.tile_drag_source.is_none() {
554                    drag_state.dragging = Some(pane.id.clone());
555                    drag_state.tile_drag_source = Some(pane.order);
556                }
557
558                // Find drop target (which pane is the pointer over?)
559                if drag_state.dragging.as_ref() == Some(&pane.id) {
560                    let pointer_pos = ui.input(|i| i.pointer.hover_pos());
561                    if let Some(pos) = pointer_pos {
562                        // Find which pane rect contains the pointer
563                        let mut new_target = None;
564                        for other in &interactions {
565                            if other.idx != interaction.idx && other.rect.contains(pos) {
566                                new_target = Some(self.panes[other.idx].order);
567                                break;
568                            }
569                        }
570                        drag_state.tile_drop_target = new_target;
571                    }
572                }
573            }
574
575            // Handle drag (Free mode - position)
576            if interaction.title_dragged && matches!(self.layout_mode, LayoutMode::Free) {
577                if drag_state.dragging.is_none() {
578                    drag_state.dragging = Some(pane.id.clone());
579                    drag_state.original_pos = Some(pane.position);
580                }
581
582                if drag_state.dragging.as_ref() == Some(&pane.id) {
583                    let delta = ui.input(|i| i.pointer.delta());
584                    let new_pos = pane.position + delta;
585
586                    // Apply snapping
587                    let (snapped_pos, snap_target) =
588                        self.apply_snap(new_pos, pane.size, rect, &pane_rects, interaction.idx);
589
590                    drag_state.snap_target = snap_target;
591
592                    events.push(WorkspaceEvent::PaneMoved {
593                        id: pane.id.clone(),
594                        position: snapped_pos,
595                    });
596                }
597            }
598        }
599
600        // Draw background and pane frames (BEFORE content)
601        {
602            let painter = ui.painter();
603
604            // Draw background
605            painter.rect_filled(rect, 0.0, theme.bg_primary);
606
607            // Draw grid if enabled
608            if self.show_grid {
609                if let Some(grid_size) = self.grid_size {
610                    self.draw_grid(painter, rect, grid_size, &theme);
611                }
612            }
613
614            // Draw pane frames (background and borders)
615            for interaction in &interactions {
616                let pane = &self.panes[interaction.idx];
617                self.draw_pane(painter, interaction, pane, &theme, &drag_state, self.locked);
618            }
619        }
620
621        // Draw pane content (AFTER background and frames)
622        for interaction in &interactions {
623            let pane = &self.panes[interaction.idx];
624            let content_rect = Rect::from_min_max(
625                Pos2::new(
626                    interaction.rect.min.x,
627                    interaction.rect.min.y + self.title_bar_height,
628                ),
629                interaction.rect.max,
630            );
631            let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(content_rect));
632            content(&mut child_ui, pane);
633        }
634
635        // Draw overlays (dividers, snap guides) AFTER content
636        {
637            let painter = ui.painter();
638
639            // Draw dividers (Tile mode only)
640            if !self.locked {
641                for divider in &dividers {
642                    let is_active = drag_state
643                        .divider_drag
644                        .as_ref()
645                        .map_or(false, |d| d.is_vertical == divider.is_vertical);
646                    let divider_color = if is_active {
647                        theme.primary
648                    } else {
649                        theme.border.gamma_multiply(0.5)
650                    };
651
652                    // Draw divider line (subtle)
653                    if divider.is_vertical {
654                        painter.line_segment(
655                            [
656                                Pos2::new(divider.position, divider.rect.min.y + self.gap),
657                                Pos2::new(divider.position, divider.rect.max.y - self.gap),
658                            ],
659                            Stroke::new(2.0, divider_color),
660                        );
661                    } else {
662                        painter.line_segment(
663                            [
664                                Pos2::new(divider.rect.min.x + self.gap, divider.position),
665                                Pos2::new(divider.rect.max.x - self.gap, divider.position),
666                            ],
667                            Stroke::new(2.0, divider_color),
668                        );
669                    }
670                }
671            }
672
673            // Draw snap guides
674            if let Some(ref target) = drag_state.snap_target {
675                self.draw_snap_guide(painter, target, rect, &theme);
676            }
677
678            // Draw lock indicator
679            if self.locked {
680                self.draw_lock_indicator(painter, rect, &theme);
681            }
682        }
683
684        // Handle drag end (mouse released)
685        if !ui.input(|i| i.pointer.any_down()) {
686            // Tile reorder: emit event if we have a valid drop target
687            if let (Some(from), Some(to)) =
688                (drag_state.tile_drag_source, drag_state.tile_drop_target)
689            {
690                if from != to {
691                    events.push(WorkspaceEvent::PaneReordered { from, to });
692                }
693            }
694
695            // Clear drag state
696            drag_state.dragging = None;
697            drag_state.snap_target = None;
698            drag_state.resizing = None;
699            drag_state.tile_drag_source = None;
700            drag_state.tile_drop_target = None;
701            drag_state.divider_drag = None;
702        }
703
704        // Save drag state
705        ui.ctx().data_mut(|d| d.insert_temp(canvas_id, drag_state));
706
707        events
708    }
709
710    fn calculate_tile_layout(
711        &self,
712        visible_panes: &[(usize, &WorkspacePane)],
713        rect: Rect,
714        columns: Option<usize>,
715    ) -> Vec<(usize, Rect)> {
716        if visible_panes.is_empty() {
717            return Vec::new();
718        }
719
720        let count = visible_panes.len();
721        let cols = columns.unwrap_or_else(|| {
722            // Auto-calculate columns: sqrt-ish for balanced grid
723            match count {
724                1 => 1,
725                2 => 2,
726                3..=4 => 2,
727                5..=6 => 3,
728                _ => ((count as f32).sqrt().ceil() as usize).max(2),
729            }
730        });
731
732        let rows = (count + cols - 1) / cols;
733
734        // Calculate column weights (use max weight of panes in each column)
735        let mut col_weights = vec![0.0f32; cols];
736        for (i, (_, pane)) in visible_panes.iter().enumerate() {
737            let col = i % cols;
738            col_weights[col] = col_weights[col].max(pane.weight);
739        }
740        let total_col_weight: f32 = col_weights.iter().sum();
741
742        // Calculate row weights (use max weight of panes in each row)
743        let mut row_weights = vec![0.0f32; rows];
744        for (i, (_, pane)) in visible_panes.iter().enumerate() {
745            let row = i / cols;
746            row_weights[row] = row_weights[row].max(pane.weight);
747        }
748        let total_row_weight: f32 = row_weights.iter().sum();
749
750        let available_width = rect.width() - self.gap * (cols + 1) as f32;
751        let available_height = rect.height() - self.gap * (rows + 1) as f32;
752
753        // Calculate column widths based on weights
754        let col_widths: Vec<f32> = col_weights
755            .iter()
756            .map(|w| available_width * (w / total_col_weight))
757            .collect();
758
759        // Calculate row heights based on weights
760        let row_heights: Vec<f32> = row_weights
761            .iter()
762            .map(|w| available_height * (w / total_row_weight))
763            .collect();
764
765        // Calculate cumulative positions
766        let mut col_positions = vec![rect.min.x + self.gap];
767        for (i, width) in col_widths.iter().enumerate() {
768            col_positions.push(col_positions[i] + width + self.gap);
769        }
770
771        let mut row_positions = vec![rect.min.y + self.gap];
772        for (i, height) in row_heights.iter().enumerate() {
773            row_positions.push(row_positions[i] + height + self.gap);
774        }
775
776        visible_panes
777            .iter()
778            .enumerate()
779            .map(|(i, (pane_idx, _))| {
780                let col = i % cols;
781                let row = i / cols;
782
783                let x = col_positions[col];
784                let y = row_positions[row];
785                let w = col_widths[col];
786                let h = row_heights[row];
787
788                (
789                    *pane_idx,
790                    Rect::from_min_size(Pos2::new(x, y), Vec2::new(w, h)),
791                )
792            })
793            .collect()
794    }
795
796    /// Calculate divider positions for tile layout
797    fn calculate_dividers(
798        &self,
799        visible_panes: &[(usize, &WorkspacePane)],
800        rect: Rect,
801        columns: Option<usize>,
802    ) -> Vec<DividerInfo> {
803        if visible_panes.len() <= 1 {
804            return Vec::new();
805        }
806
807        let count = visible_panes.len();
808        let cols = columns.unwrap_or_else(|| match count {
809            1 => 1,
810            2 => 2,
811            3..=4 => 2,
812            5..=6 => 3,
813            _ => ((count as f32).sqrt().ceil() as usize).max(2),
814        });
815
816        let rows = (count + cols - 1) / cols;
817        let mut dividers = Vec::new();
818
819        // Calculate current layout to get positions
820        let pane_rects = self.calculate_tile_layout(visible_panes, rect, columns);
821
822        // Vertical dividers (between columns)
823        for col in 0..cols.saturating_sub(1) {
824            // Get panes on either side
825            let left_panes: Vec<usize> = visible_panes
826                .iter()
827                .enumerate()
828                .filter(|(i, _)| i % cols == col)
829                .map(|(_, (_, p))| p.order)
830                .collect();
831            let right_panes: Vec<usize> = visible_panes
832                .iter()
833                .enumerate()
834                .filter(|(i, _)| i % cols == col + 1)
835                .map(|(_, (_, p))| p.order)
836                .collect();
837
838            if !left_panes.is_empty() && !right_panes.is_empty() {
839                // Find the x position of the divider (right edge of left column)
840                if let Some((_, left_rect)) = pane_rects.iter().find(|(idx, _)| {
841                    visible_panes
842                        .iter()
843                        .position(|(i, _)| *i == *idx)
844                        .map_or(false, |pos| pos % cols == col)
845                }) {
846                    let div_x = left_rect.max.x + self.gap / 2.0;
847                    let div_rect = Rect::from_min_max(
848                        Pos2::new(div_x - 4.0, rect.min.y),
849                        Pos2::new(div_x + 4.0, rect.max.y),
850                    );
851                    dividers.push(DividerInfo {
852                        is_vertical: true,
853                        position: div_x,
854                        panes: (left_panes, right_panes),
855                        rect: div_rect,
856                    });
857                }
858            }
859        }
860
861        // Horizontal dividers (between rows)
862        for row in 0..rows.saturating_sub(1) {
863            let top_panes: Vec<usize> = visible_panes
864                .iter()
865                .enumerate()
866                .filter(|(i, _)| i / cols == row)
867                .map(|(_, (_, p))| p.order)
868                .collect();
869            let bottom_panes: Vec<usize> = visible_panes
870                .iter()
871                .enumerate()
872                .filter(|(i, _)| i / cols == row + 1)
873                .map(|(_, (_, p))| p.order)
874                .collect();
875
876            if !top_panes.is_empty() && !bottom_panes.is_empty() {
877                if let Some((_, top_rect)) = pane_rects.iter().find(|(idx, _)| {
878                    visible_panes
879                        .iter()
880                        .position(|(i, _)| *i == *idx)
881                        .map_or(false, |pos| pos / cols == row)
882                }) {
883                    let div_y = top_rect.max.y + self.gap / 2.0;
884                    let div_rect = Rect::from_min_max(
885                        Pos2::new(rect.min.x, div_y - 4.0),
886                        Pos2::new(rect.max.x, div_y + 4.0),
887                    );
888                    dividers.push(DividerInfo {
889                        is_vertical: false,
890                        position: div_y,
891                        panes: (top_panes, bottom_panes),
892                        rect: div_rect,
893                    });
894                }
895            }
896        }
897
898        dividers
899    }
900
901    fn check_resize_edge(&self, ui: &mut Ui, rect: Rect) -> Option<ResizeEdge> {
902        let pointer_pos = ui.input(|i| i.pointer.hover_pos())?;
903        let edge_size = 8.0;
904
905        let right_edge =
906            Rect::from_min_max(Pos2::new(rect.max.x - edge_size, rect.min.y), rect.max);
907        let bottom_edge =
908            Rect::from_min_max(Pos2::new(rect.min.x, rect.max.y - edge_size), rect.max);
909        let corner = Rect::from_min_max(
910            Pos2::new(rect.max.x - edge_size, rect.max.y - edge_size),
911            rect.max,
912        );
913
914        if corner.contains(pointer_pos) {
915            Some(ResizeEdge::BottomRight)
916        } else if right_edge.contains(pointer_pos) {
917            Some(ResizeEdge::Right)
918        } else if bottom_edge.contains(pointer_pos) {
919            Some(ResizeEdge::Bottom)
920        } else {
921            None
922        }
923    }
924
925    fn apply_snap(
926        &self,
927        pos: Pos2,
928        size: Vec2,
929        canvas_rect: Rect,
930        pane_rects: &[(usize, Rect)],
931        current_idx: usize,
932    ) -> (Pos2, Option<SnapTarget>) {
933        let mut snapped_pos = pos;
934        let mut snap_target = None;
935
936        // Snap to canvas edges
937        if (pos.x - canvas_rect.min.x).abs() < self.snap_threshold {
938            snapped_pos.x = canvas_rect.min.x + self.gap;
939            snap_target = Some(SnapTarget::CanvasEdge(Edge::Left));
940        }
941        if (pos.x + size.x - canvas_rect.max.x).abs() < self.snap_threshold {
942            snapped_pos.x = canvas_rect.max.x - size.x - self.gap;
943            snap_target = Some(SnapTarget::CanvasEdge(Edge::Right));
944        }
945        if (pos.y - canvas_rect.min.y).abs() < self.snap_threshold {
946            snapped_pos.y = canvas_rect.min.y + self.gap;
947            snap_target = Some(SnapTarget::CanvasEdge(Edge::Top));
948        }
949        if (pos.y + size.y - canvas_rect.max.y).abs() < self.snap_threshold {
950            snapped_pos.y = canvas_rect.max.y - size.y - self.gap;
951            snap_target = Some(SnapTarget::CanvasEdge(Edge::Bottom));
952        }
953
954        // Snap to other panes
955        for (idx, other_rect) in pane_rects {
956            if *idx == current_idx {
957                continue;
958            }
959
960            let pane = &self.panes[*idx];
961
962            // Snap right edge to left edge of other
963            if (pos.x + size.x - other_rect.min.x).abs() < self.snap_threshold {
964                snapped_pos.x = other_rect.min.x - size.x - self.gap;
965                snap_target = Some(SnapTarget::Pane {
966                    id: pane.id.clone(),
967                    edge: Edge::Left,
968                });
969            }
970            // Snap left edge to right edge of other
971            if (pos.x - other_rect.max.x).abs() < self.snap_threshold {
972                snapped_pos.x = other_rect.max.x + self.gap;
973                snap_target = Some(SnapTarget::Pane {
974                    id: pane.id.clone(),
975                    edge: Edge::Right,
976                });
977            }
978            // Snap bottom edge to top edge of other
979            if (pos.y + size.y - other_rect.min.y).abs() < self.snap_threshold {
980                snapped_pos.y = other_rect.min.y - size.y - self.gap;
981                snap_target = Some(SnapTarget::Pane {
982                    id: pane.id.clone(),
983                    edge: Edge::Top,
984                });
985            }
986            // Snap top edge to bottom edge of other
987            if (pos.y - other_rect.max.y).abs() < self.snap_threshold {
988                snapped_pos.y = other_rect.max.y + self.gap;
989                snap_target = Some(SnapTarget::Pane {
990                    id: pane.id.clone(),
991                    edge: Edge::Bottom,
992                });
993            }
994        }
995
996        // Grid snap
997        if let Some(grid_size) = self.grid_size {
998            if snap_target.is_none() {
999                let grid_x = (snapped_pos.x / grid_size).round() as i32;
1000                let grid_y = (snapped_pos.y / grid_size).round() as i32;
1001                snapped_pos.x = grid_x as f32 * grid_size;
1002                snapped_pos.y = grid_y as f32 * grid_size;
1003                snap_target = Some(SnapTarget::Grid {
1004                    x: grid_x,
1005                    y: grid_y,
1006                });
1007            }
1008        }
1009
1010        (snapped_pos, snap_target)
1011    }
1012
1013    fn draw_pane(
1014        &self,
1015        painter: &egui::Painter,
1016        interaction: &PaneInteraction,
1017        pane: &WorkspacePane,
1018        theme: &Theme,
1019        drag_state: &DragState,
1020        locked: bool,
1021    ) {
1022        let is_dragging = drag_state.dragging.as_ref() == Some(&pane.id);
1023        let is_drop_target = drag_state.tile_drop_target == Some(pane.order)
1024            && drag_state.tile_drag_source.is_some()
1025            && drag_state.tile_drag_source != Some(pane.order);
1026
1027        // Pane background
1028        let bg_color = if is_dragging {
1029            theme.bg_tertiary
1030        } else if is_drop_target {
1031            // Highlight drop target with primary color tint
1032            Color32::from_rgba_unmultiplied(
1033                theme.primary.r(),
1034                theme.primary.g(),
1035                theme.primary.b(),
1036                40,
1037            )
1038        } else {
1039            theme.bg_secondary
1040        };
1041        painter.rect_filled(interaction.rect, theme.radius_md, bg_color);
1042
1043        // Title bar
1044        let title_bg = if interaction.title_hovered && !locked {
1045            theme.bg_tertiary
1046        } else {
1047            theme.bg_primary
1048        };
1049        painter.rect_filled(interaction.title_rect, theme.radius_md, title_bg);
1050
1051        // Title text
1052        painter.text(
1053            Pos2::new(
1054                interaction.title_rect.min.x + theme.spacing_sm,
1055                interaction.title_rect.center().y,
1056            ),
1057            egui::Align2::LEFT_CENTER,
1058            &pane.title,
1059            egui::FontId::proportional(theme.font_size_sm),
1060            theme.text_primary,
1061        );
1062
1063        // Lock icon if locked
1064        if locked {
1065            painter.text(
1066                Pos2::new(
1067                    interaction.title_rect.min.x + theme.spacing_xs,
1068                    interaction.title_rect.min.y + theme.spacing_xs,
1069                ),
1070                egui::Align2::LEFT_TOP,
1071                "🔒",
1072                egui::FontId::proportional(theme.font_size_xs),
1073                theme.text_muted,
1074            );
1075        }
1076
1077        // Close button
1078        if let Some(close_rect) = interaction.close_rect {
1079            let close_color = if interaction.close_clicked {
1080                theme.state_danger
1081            } else {
1082                theme.text_muted
1083            };
1084            painter.text(
1085                close_rect.center(),
1086                egui::Align2::CENTER_CENTER,
1087                "×",
1088                egui::FontId::proportional(theme.font_size_md),
1089                close_color,
1090            );
1091        }
1092
1093        // Minimize button
1094        if let Some(minimize_rect) = interaction.minimize_rect {
1095            painter.text(
1096                minimize_rect.center(),
1097                egui::Align2::CENTER_CENTER,
1098                "−",
1099                egui::FontId::proportional(theme.font_size_md),
1100                theme.text_muted,
1101            );
1102        }
1103
1104        // Border
1105        let border_color = if is_dragging || is_drop_target {
1106            theme.primary
1107        } else {
1108            theme.border
1109        };
1110        painter.rect_stroke(
1111            interaction.rect,
1112            theme.radius_md,
1113            Stroke::new(theme.border_width, border_color),
1114            egui::StrokeKind::Inside,
1115        );
1116
1117        // Resize handles (only in Free mode and unlocked)
1118        if !locked && matches!(self.layout_mode, LayoutMode::Free) {
1119            if let Some(edge) = interaction.resize_edge {
1120                let handle_color = theme.primary.gamma_multiply(0.5);
1121                let handle_size = 6.0;
1122
1123                let handle_pos = match edge {
1124                    ResizeEdge::Right => {
1125                        Pos2::new(interaction.rect.max.x - 3.0, interaction.rect.center().y)
1126                    }
1127                    ResizeEdge::Bottom => {
1128                        Pos2::new(interaction.rect.center().x, interaction.rect.max.y - 3.0)
1129                    }
1130                    ResizeEdge::BottomRight => {
1131                        Pos2::new(interaction.rect.max.x - 3.0, interaction.rect.max.y - 3.0)
1132                    }
1133                };
1134
1135                painter.circle_filled(handle_pos, handle_size, handle_color);
1136            }
1137        }
1138    }
1139
1140    fn draw_snap_guide(
1141        &self,
1142        painter: &egui::Painter,
1143        target: &SnapTarget,
1144        canvas_rect: Rect,
1145        theme: &Theme,
1146    ) {
1147        let guide_color = theme.primary.gamma_multiply(0.7);
1148        let guide_stroke = Stroke::new(2.0, guide_color);
1149
1150        match target {
1151            SnapTarget::CanvasEdge(edge) => {
1152                let (start, end) = match edge {
1153                    Edge::Left => (
1154                        Pos2::new(canvas_rect.min.x + self.gap, canvas_rect.min.y),
1155                        Pos2::new(canvas_rect.min.x + self.gap, canvas_rect.max.y),
1156                    ),
1157                    Edge::Right => (
1158                        Pos2::new(canvas_rect.max.x - self.gap, canvas_rect.min.y),
1159                        Pos2::new(canvas_rect.max.x - self.gap, canvas_rect.max.y),
1160                    ),
1161                    Edge::Top => (
1162                        Pos2::new(canvas_rect.min.x, canvas_rect.min.y + self.gap),
1163                        Pos2::new(canvas_rect.max.x, canvas_rect.min.y + self.gap),
1164                    ),
1165                    Edge::Bottom => (
1166                        Pos2::new(canvas_rect.min.x, canvas_rect.max.y - self.gap),
1167                        Pos2::new(canvas_rect.max.x, canvas_rect.max.y - self.gap),
1168                    ),
1169                };
1170                painter.line_segment([start, end], guide_stroke);
1171            }
1172            SnapTarget::Pane { .. } => {
1173                // Draw connection indicator between panes
1174                // (Could be enhanced with more visual feedback)
1175            }
1176            SnapTarget::Grid { x, y } => {
1177                if let Some(grid_size) = self.grid_size {
1178                    let pos = Pos2::new(*x as f32 * grid_size, *y as f32 * grid_size);
1179                    painter.circle_filled(pos, 4.0, guide_color);
1180                }
1181            }
1182        }
1183    }
1184
1185    fn draw_grid(&self, painter: &egui::Painter, rect: Rect, grid_size: f32, theme: &Theme) {
1186        let grid_color = Color32::from_rgba_unmultiplied(
1187            theme.border.r(),
1188            theme.border.g(),
1189            theme.border.b(),
1190            30,
1191        );
1192        let grid_stroke = Stroke::new(0.5, grid_color);
1193
1194        // Vertical lines
1195        let mut x = rect.min.x;
1196        while x < rect.max.x {
1197            painter.line_segment(
1198                [Pos2::new(x, rect.min.y), Pos2::new(x, rect.max.y)],
1199                grid_stroke,
1200            );
1201            x += grid_size;
1202        }
1203
1204        // Horizontal lines
1205        let mut y = rect.min.y;
1206        while y < rect.max.y {
1207            painter.line_segment(
1208                [Pos2::new(rect.min.x, y), Pos2::new(rect.max.x, y)],
1209                grid_stroke,
1210            );
1211            y += grid_size;
1212        }
1213    }
1214
1215    fn draw_lock_indicator(&self, painter: &egui::Painter, rect: Rect, theme: &Theme) {
1216        // Subtle lock indicator in corner
1217        let indicator_rect = Rect::from_min_size(
1218            Pos2::new(rect.max.x - 40.0, rect.min.y + 4.0),
1219            Vec2::new(36.0, 20.0),
1220        );
1221
1222        painter.rect_filled(
1223            indicator_rect,
1224            theme.radius_sm,
1225            Color32::from_rgba_unmultiplied(0, 0, 0, 100),
1226        );
1227
1228        painter.text(
1229            indicator_rect.center(),
1230            egui::Align2::CENTER_CENTER,
1231            "🔒 Lock",
1232            egui::FontId::proportional(theme.font_size_xs),
1233            theme.text_muted,
1234        );
1235    }
1236}