1use crate::Theme;
31use egui::{Color32, Id, Pos2, Rect, Sense, Stroke, Ui, Vec2};
32use egui_cha::ViewCtx;
33
34#[derive(Clone, Copy, Debug, PartialEq)]
36pub enum LayoutMode {
37 Tile { columns: Option<usize> },
40
41 Free,
43}
44
45impl Default for LayoutMode {
46 fn default() -> Self {
47 LayoutMode::Tile { columns: None }
48 }
49}
50
51#[derive(Clone, Debug)]
53pub struct WorkspacePane {
54 pub id: String,
56 pub title: String,
58 pub position: Pos2,
60 pub size: Vec2,
62 pub min_size: Vec2,
64 pub visible: bool,
66 pub minimized: bool,
68 pub order: usize,
70 pub weight: f32,
72}
73
74impl WorkspacePane {
75 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 pub fn with_position(mut self, x: f32, y: f32) -> Self {
92 self.position = Pos2::new(x, y);
93 self
94 }
95
96 pub fn with_size(mut self, width: f32, height: f32) -> Self {
98 self.size = Vec2::new(width, height);
99 self
100 }
101
102 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 pub fn with_order(mut self, order: usize) -> Self {
110 self.order = order;
111 self
112 }
113
114 pub fn with_visible(mut self, visible: bool) -> Self {
116 self.visible = visible;
117 self
118 }
119
120 pub fn with_weight(mut self, weight: f32) -> Self {
122 self.weight = weight.max(0.1); self
124 }
125}
126
127#[derive(Clone, Debug)]
129pub enum WorkspaceEvent {
130 PaneMoved { id: String, position: Pos2 },
132 PaneResized { id: String, size: Vec2 },
134 PaneClosed(String),
136 PaneMinimized { id: String, minimized: bool },
138 PaneReordered { from: usize, to: usize },
140 WeightsChanged(Vec<(String, f32)>),
142 LayoutChanged(LayoutMode),
144 LockChanged(bool),
146}
147
148#[derive(Clone, Debug, PartialEq)]
150pub enum SnapTarget {
151 Pane { id: String, edge: Edge },
153 CanvasEdge(Edge),
155 Grid { x: i32, y: i32 },
157}
158
159#[derive(Clone, Copy, Debug, PartialEq, Eq)]
161pub enum Edge {
162 Left,
163 Right,
164 Top,
165 Bottom,
166}
167
168#[derive(Clone, Debug)]
170struct DividerInfo {
171 is_vertical: bool,
173 position: f32,
175 panes: (Vec<usize>, Vec<usize>),
177 rect: Rect,
179}
180
181#[derive(Clone, Debug, Default)]
183struct DragState {
184 dragging: Option<String>,
186 original_pos: Option<Pos2>,
188 snap_target: Option<SnapTarget>,
190 resizing: Option<(String, ResizeEdge)>,
192 tile_drag_source: Option<usize>,
194 tile_drop_target: Option<usize>,
196 divider_drag: Option<DividerDrag>,
198}
199
200#[derive(Clone, Debug)]
202struct DividerDrag {
203 is_vertical: bool,
205 index: usize,
207 start_pos: f32,
209 original_weights: Vec<(usize, f32)>,
211}
212
213#[derive(Clone, Copy, Debug)]
214enum ResizeEdge {
215 Right,
216 Bottom,
217 BottomRight,
218}
219
220struct 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
234pub 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 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 pub fn layout(mut self, mode: LayoutMode) -> Self {
267 self.layout_mode = mode;
268 self
269 }
270
271 pub fn locked(mut self, locked: bool) -> Self {
273 self.locked = locked;
274 self
275 }
276
277 pub fn snap_threshold(mut self, threshold: f32) -> Self {
279 self.snap_threshold = threshold;
280 self
281 }
282
283 pub fn grid(mut self, size: Option<f32>) -> Self {
285 self.grid_size = size;
286 self
287 }
288
289 pub fn show_grid(mut self, show: bool) -> Self {
291 self.show_grid = show;
292 self
293 }
294
295 pub fn gap(mut self, gap: f32) -> Self {
297 self.gap = gap;
298 self
299 }
300
301 pub fn title_bar_height(mut self, height: f32) -> Self {
303 self.title_bar_height = height;
304 self
305 }
306
307 pub fn show_close_buttons(mut self, show: bool) -> Self {
309 self.show_close_buttons = show;
310 self
311 }
312
313 pub fn show_minimize_buttons(mut self, show: bool) -> Self {
315 self.show_minimize_buttons = show;
316 self
317 }
318
319 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 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 let available_rect = ui.available_rect_before_wrap();
351 let canvas_id = Id::new("workspace_canvas");
352
353 let mut drag_state: DragState = ui
355 .ctx()
356 .data_mut(|d| d.get_temp(canvas_id).unwrap_or_default());
357
358 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 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 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 let pos = rect.min + pane.position.to_vec2();
388 (*idx, Rect::from_min_size(pos, pane.size))
389 })
390 .collect(),
391 };
392
393 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 if !self.locked && !dividers.is_empty() {
402 let pointer_pos = ui.input(|i| i.pointer.hover_pos());
403
404 if let Some(pos) = pointer_pos {
406 for (div_idx, divider) in dividers.iter().enumerate() {
407 if divider.rect.contains(pos) {
408 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 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 if let Some(ref drag) = drag_state.divider_drag {
435 if let Some(pos) = pointer_pos {
436 let divider = ÷rs[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 let sensitivity = 0.005; 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 let mut interactions: Vec<PaneInteraction> = Vec::new();
473
474 for (idx, pane_rect) in &pane_rects {
475 let _pane = &self.panes[*idx];
476
477 let title_rect = Rect::from_min_size(
479 pane_rect.min,
480 Vec2::new(pane_rect.width(), self.title_bar_height),
481 );
482
483 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 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 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 for interaction in &interactions {
535 let pane = &self.panes[interaction.idx];
536
537 if interaction.close_clicked {
539 events.push(WorkspaceEvent::PaneClosed(pane.id.clone()));
540 }
541
542 if interaction.minimize_clicked {
544 events.push(WorkspaceEvent::PaneMinimized {
545 id: pane.id.clone(),
546 minimized: !pane.minimized,
547 });
548 }
549
550 if interaction.title_dragged && matches!(self.layout_mode, LayoutMode::Tile { .. }) {
552 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 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 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 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 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 {
602 let painter = ui.painter();
603
604 painter.rect_filled(rect, 0.0, theme.bg_primary);
606
607 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 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 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 {
637 let painter = ui.painter();
638
639 if !self.locked {
641 for divider in ÷rs {
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 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 if let Some(ref target) = drag_state.snap_target {
675 self.draw_snap_guide(painter, target, rect, &theme);
676 }
677
678 if self.locked {
680 self.draw_lock_indicator(painter, rect, &theme);
681 }
682 }
683
684 if !ui.input(|i| i.pointer.any_down()) {
686 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 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 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 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 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 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 let col_widths: Vec<f32> = col_weights
755 .iter()
756 .map(|w| available_width * (w / total_col_weight))
757 .collect();
758
759 let row_heights: Vec<f32> = row_weights
761 .iter()
762 .map(|w| available_height * (w / total_row_weight))
763 .collect();
764
765 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 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 let pane_rects = self.calculate_tile_layout(visible_panes, rect, columns);
821
822 for col in 0..cols.saturating_sub(1) {
824 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 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 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 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 for (idx, other_rect) in pane_rects {
956 if *idx == current_idx {
957 continue;
958 }
959
960 let pane = &self.panes[*idx];
961
962 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 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 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 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 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 let bg_color = if is_dragging {
1029 theme.bg_tertiary
1030 } else if is_drop_target {
1031 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 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 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 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 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 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 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 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 }
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 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 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 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}