use crate::Theme;
use egui::{Color32, Id, Pos2, Rect, Sense, Stroke, Ui, Vec2};
use egui_cha::ViewCtx;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum LayoutMode {
Tile { columns: Option<usize> },
Free,
}
impl Default for LayoutMode {
fn default() -> Self {
LayoutMode::Tile { columns: None }
}
}
#[derive(Clone, Debug)]
pub struct WorkspacePane {
pub id: String,
pub title: String,
pub position: Pos2,
pub size: Vec2,
pub min_size: Vec2,
pub visible: bool,
pub minimized: bool,
pub order: usize,
pub weight: f32,
}
impl WorkspacePane {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
Self {
id: id.into(),
title: title.into(),
position: Pos2::new(50.0, 50.0),
size: Vec2::new(200.0, 150.0),
min_size: Vec2::new(100.0, 80.0),
visible: true,
minimized: false,
order: 0,
weight: 1.0,
}
}
pub fn with_position(mut self, x: f32, y: f32) -> Self {
self.position = Pos2::new(x, y);
self
}
pub fn with_size(mut self, width: f32, height: f32) -> Self {
self.size = Vec2::new(width, height);
self
}
pub fn with_min_size(mut self, width: f32, height: f32) -> Self {
self.min_size = Vec2::new(width, height);
self
}
pub fn with_order(mut self, order: usize) -> Self {
self.order = order;
self
}
pub fn with_visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
pub fn with_weight(mut self, weight: f32) -> Self {
self.weight = weight.max(0.1); self
}
}
#[derive(Clone, Debug)]
pub enum WorkspaceEvent {
PaneMoved { id: String, position: Pos2 },
PaneResized { id: String, size: Vec2 },
PaneClosed(String),
PaneMinimized { id: String, minimized: bool },
PaneReordered { from: usize, to: usize },
WeightsChanged(Vec<(String, f32)>),
LayoutChanged(LayoutMode),
LockChanged(bool),
}
#[derive(Clone, Debug, PartialEq)]
pub enum SnapTarget {
Pane { id: String, edge: Edge },
CanvasEdge(Edge),
Grid { x: i32, y: i32 },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Edge {
Left,
Right,
Top,
Bottom,
}
#[derive(Clone, Debug)]
struct DividerInfo {
is_vertical: bool,
position: f32,
panes: (Vec<usize>, Vec<usize>),
rect: Rect,
}
#[derive(Clone, Debug, Default)]
struct DragState {
dragging: Option<String>,
original_pos: Option<Pos2>,
snap_target: Option<SnapTarget>,
resizing: Option<(String, ResizeEdge)>,
tile_drag_source: Option<usize>,
tile_drop_target: Option<usize>,
divider_drag: Option<DividerDrag>,
}
#[derive(Clone, Debug)]
struct DividerDrag {
is_vertical: bool,
index: usize,
start_pos: f32,
original_weights: Vec<(usize, f32)>,
}
#[derive(Clone, Copy, Debug)]
enum ResizeEdge {
Right,
Bottom,
BottomRight,
}
struct PaneInteraction {
idx: usize,
rect: Rect,
title_rect: Rect,
close_rect: Option<Rect>,
minimize_rect: Option<Rect>,
title_hovered: bool,
title_dragged: bool,
close_clicked: bool,
minimize_clicked: bool,
resize_edge: Option<ResizeEdge>,
}
pub struct WorkspaceCanvas<'a> {
panes: &'a mut Vec<WorkspacePane>,
layout_mode: LayoutMode,
locked: bool,
snap_threshold: f32,
grid_size: Option<f32>,
show_grid: bool,
gap: f32,
title_bar_height: f32,
show_close_buttons: bool,
show_minimize_buttons: bool,
}
impl<'a> WorkspaceCanvas<'a> {
pub fn new(panes: &'a mut Vec<WorkspacePane>) -> Self {
Self {
panes,
layout_mode: LayoutMode::default(),
locked: false,
snap_threshold: 8.0,
grid_size: None,
show_grid: false,
gap: 4.0,
title_bar_height: 24.0,
show_close_buttons: true,
show_minimize_buttons: true,
}
}
pub fn layout(mut self, mode: LayoutMode) -> Self {
self.layout_mode = mode;
self
}
pub fn locked(mut self, locked: bool) -> Self {
self.locked = locked;
self
}
pub fn snap_threshold(mut self, threshold: f32) -> Self {
self.snap_threshold = threshold;
self
}
pub fn grid(mut self, size: Option<f32>) -> Self {
self.grid_size = size;
self
}
pub fn show_grid(mut self, show: bool) -> Self {
self.show_grid = show;
self
}
pub fn gap(mut self, gap: f32) -> Self {
self.gap = gap;
self
}
pub fn title_bar_height(mut self, height: f32) -> Self {
self.title_bar_height = height;
self
}
pub fn show_close_buttons(mut self, show: bool) -> Self {
self.show_close_buttons = show;
self
}
pub fn show_minimize_buttons(mut self, show: bool) -> Self {
self.show_minimize_buttons = show;
self
}
pub fn show<F>(self, ui: &mut Ui, mut content: F) -> Vec<WorkspaceEvent>
where
F: FnMut(&mut Ui, &WorkspacePane),
{
self.show_internal(ui, &mut content)
}
pub fn show_with<Msg, F>(
self,
ctx: &mut ViewCtx<'_, Msg>,
mut content: F,
on_event: impl Fn(WorkspaceEvent) -> Msg,
) where
F: FnMut(&mut Ui, &WorkspacePane),
{
let events = self.show_internal(ctx.ui, &mut content);
for event in events {
ctx.emit(on_event(event));
}
}
fn show_internal<F>(self, ui: &mut Ui, content: &mut F) -> Vec<WorkspaceEvent>
where
F: FnMut(&mut Ui, &WorkspacePane),
{
let theme = Theme::current(ui.ctx());
let mut events = Vec::new();
let available_rect = ui.available_rect_before_wrap();
let canvas_id = Id::new("workspace_canvas");
let mut drag_state: DragState = ui
.ctx()
.data_mut(|d| d.get_temp(canvas_id).unwrap_or_default());
let (rect, _response) = ui.allocate_exact_size(available_rect.size(), Sense::hover());
if !ui.is_rect_visible(rect) {
return events;
}
let mut visible_panes: Vec<_> = self
.panes
.iter()
.enumerate()
.filter(|(_, p)| p.visible && !p.minimized)
.collect();
visible_panes.sort_by_key(|(_, p)| p.order);
let columns = match self.layout_mode {
LayoutMode::Tile { columns } => columns,
LayoutMode::Free => None,
};
let pane_rects: Vec<(usize, Rect)> = match self.layout_mode {
LayoutMode::Tile { columns } => {
self.calculate_tile_layout(&visible_panes, rect, columns)
}
LayoutMode::Free => visible_panes
.iter()
.map(|(idx, pane)| {
let pos = rect.min + pane.position.to_vec2();
(*idx, Rect::from_min_size(pos, pane.size))
})
.collect(),
};
let dividers = if matches!(self.layout_mode, LayoutMode::Tile { .. }) && !self.locked {
self.calculate_dividers(&visible_panes, rect, columns)
} else {
Vec::new()
};
if !self.locked && !dividers.is_empty() {
let pointer_pos = ui.input(|i| i.pointer.hover_pos());
if let Some(pos) = pointer_pos {
for (div_idx, divider) in dividers.iter().enumerate() {
if divider.rect.contains(pos) {
if divider.is_vertical {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
} else {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
if ui.input(|i| i.pointer.any_pressed())
&& drag_state.divider_drag.is_none()
{
let original_weights: Vec<(usize, f32)> =
self.panes.iter().map(|p| (p.order, p.weight)).collect();
drag_state.divider_drag = Some(DividerDrag {
is_vertical: divider.is_vertical,
index: div_idx,
start_pos: if divider.is_vertical { pos.x } else { pos.y },
original_weights,
});
}
break;
}
}
}
if let Some(ref drag) = drag_state.divider_drag {
if let Some(pos) = pointer_pos {
let divider = ÷rs[drag.index.min(dividers.len().saturating_sub(1))];
let delta = if drag.is_vertical {
pos.x - drag.start_pos
} else {
pos.y - drag.start_pos
};
let sensitivity = 0.005; let weight_delta = delta * sensitivity;
let mut new_weights: Vec<(String, f32)> = Vec::new();
for (order, orig_weight) in &drag.original_weights {
let pane = self.panes.iter().find(|p| p.order == *order);
if let Some(pane) = pane {
let is_left = divider.panes.0.contains(order);
let is_right = divider.panes.1.contains(order);
let new_weight = if is_left {
(*orig_weight + weight_delta).max(0.2)
} else if is_right {
(*orig_weight - weight_delta).max(0.2)
} else {
*orig_weight
};
new_weights.push((pane.id.clone(), new_weight));
}
}
if !new_weights.is_empty() {
events.push(WorkspaceEvent::WeightsChanged(new_weights));
}
}
}
}
let mut interactions: Vec<PaneInteraction> = Vec::new();
for (idx, pane_rect) in &pane_rects {
let _pane = &self.panes[*idx];
let title_rect = Rect::from_min_size(
pane_rect.min,
Vec2::new(pane_rect.width(), self.title_bar_height),
);
let button_size = self.title_bar_height - 8.0;
let mut button_x = pane_rect.max.x - 4.0;
let close_rect = if self.show_close_buttons {
button_x -= button_size;
Some(Rect::from_min_size(
Pos2::new(button_x, pane_rect.min.y + 4.0),
Vec2::splat(button_size),
))
} else {
None
};
let minimize_rect = if self.show_minimize_buttons {
button_x -= button_size + 2.0;
Some(Rect::from_min_size(
Pos2::new(button_x, pane_rect.min.y + 4.0),
Vec2::splat(button_size),
))
} else {
None
};
let title_response = ui.allocate_rect(title_rect, Sense::click_and_drag());
let close_response = close_rect.map(|r| ui.allocate_rect(r, Sense::click()));
let minimize_response = minimize_rect.map(|r| ui.allocate_rect(r, Sense::click()));
let resize_edge = if !self.locked && matches!(self.layout_mode, LayoutMode::Free) {
self.check_resize_edge(ui, *pane_rect)
} else {
None
};
interactions.push(PaneInteraction {
idx: *idx,
rect: *pane_rect,
title_rect,
close_rect,
minimize_rect,
title_hovered: title_response.hovered(),
title_dragged: title_response.dragged() && !self.locked,
close_clicked: close_response.map_or(false, |r| r.clicked()),
minimize_clicked: minimize_response.map_or(false, |r| r.clicked()),
resize_edge,
});
}
for interaction in &interactions {
let pane = &self.panes[interaction.idx];
if interaction.close_clicked {
events.push(WorkspaceEvent::PaneClosed(pane.id.clone()));
}
if interaction.minimize_clicked {
events.push(WorkspaceEvent::PaneMinimized {
id: pane.id.clone(),
minimized: !pane.minimized,
});
}
if interaction.title_dragged && matches!(self.layout_mode, LayoutMode::Tile { .. }) {
if drag_state.tile_drag_source.is_none() {
drag_state.dragging = Some(pane.id.clone());
drag_state.tile_drag_source = Some(pane.order);
}
if drag_state.dragging.as_ref() == Some(&pane.id) {
let pointer_pos = ui.input(|i| i.pointer.hover_pos());
if let Some(pos) = pointer_pos {
let mut new_target = None;
for other in &interactions {
if other.idx != interaction.idx && other.rect.contains(pos) {
new_target = Some(self.panes[other.idx].order);
break;
}
}
drag_state.tile_drop_target = new_target;
}
}
}
if interaction.title_dragged && matches!(self.layout_mode, LayoutMode::Free) {
if drag_state.dragging.is_none() {
drag_state.dragging = Some(pane.id.clone());
drag_state.original_pos = Some(pane.position);
}
if drag_state.dragging.as_ref() == Some(&pane.id) {
let delta = ui.input(|i| i.pointer.delta());
let new_pos = pane.position + delta;
let (snapped_pos, snap_target) =
self.apply_snap(new_pos, pane.size, rect, &pane_rects, interaction.idx);
drag_state.snap_target = snap_target;
events.push(WorkspaceEvent::PaneMoved {
id: pane.id.clone(),
position: snapped_pos,
});
}
}
}
{
let painter = ui.painter();
painter.rect_filled(rect, 0.0, theme.bg_primary);
if self.show_grid {
if let Some(grid_size) = self.grid_size {
self.draw_grid(painter, rect, grid_size, &theme);
}
}
for interaction in &interactions {
let pane = &self.panes[interaction.idx];
self.draw_pane(painter, interaction, pane, &theme, &drag_state, self.locked);
}
}
for interaction in &interactions {
let pane = &self.panes[interaction.idx];
let content_rect = Rect::from_min_max(
Pos2::new(
interaction.rect.min.x,
interaction.rect.min.y + self.title_bar_height,
),
interaction.rect.max,
);
let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(content_rect));
content(&mut child_ui, pane);
}
{
let painter = ui.painter();
if !self.locked {
for divider in ÷rs {
let is_active = drag_state
.divider_drag
.as_ref()
.map_or(false, |d| d.is_vertical == divider.is_vertical);
let divider_color = if is_active {
theme.primary
} else {
theme.border.gamma_multiply(0.5)
};
if divider.is_vertical {
painter.line_segment(
[
Pos2::new(divider.position, divider.rect.min.y + self.gap),
Pos2::new(divider.position, divider.rect.max.y - self.gap),
],
Stroke::new(2.0, divider_color),
);
} else {
painter.line_segment(
[
Pos2::new(divider.rect.min.x + self.gap, divider.position),
Pos2::new(divider.rect.max.x - self.gap, divider.position),
],
Stroke::new(2.0, divider_color),
);
}
}
}
if let Some(ref target) = drag_state.snap_target {
self.draw_snap_guide(painter, target, rect, &theme);
}
if self.locked {
self.draw_lock_indicator(painter, rect, &theme);
}
}
if !ui.input(|i| i.pointer.any_down()) {
if let (Some(from), Some(to)) =
(drag_state.tile_drag_source, drag_state.tile_drop_target)
{
if from != to {
events.push(WorkspaceEvent::PaneReordered { from, to });
}
}
drag_state.dragging = None;
drag_state.snap_target = None;
drag_state.resizing = None;
drag_state.tile_drag_source = None;
drag_state.tile_drop_target = None;
drag_state.divider_drag = None;
}
ui.ctx().data_mut(|d| d.insert_temp(canvas_id, drag_state));
events
}
fn calculate_tile_layout(
&self,
visible_panes: &[(usize, &WorkspacePane)],
rect: Rect,
columns: Option<usize>,
) -> Vec<(usize, Rect)> {
if visible_panes.is_empty() {
return Vec::new();
}
let count = visible_panes.len();
let cols = columns.unwrap_or_else(|| {
match count {
1 => 1,
2 => 2,
3..=4 => 2,
5..=6 => 3,
_ => ((count as f32).sqrt().ceil() as usize).max(2),
}
});
let rows = (count + cols - 1) / cols;
let mut col_weights = vec![0.0f32; cols];
for (i, (_, pane)) in visible_panes.iter().enumerate() {
let col = i % cols;
col_weights[col] = col_weights[col].max(pane.weight);
}
let total_col_weight: f32 = col_weights.iter().sum();
let mut row_weights = vec![0.0f32; rows];
for (i, (_, pane)) in visible_panes.iter().enumerate() {
let row = i / cols;
row_weights[row] = row_weights[row].max(pane.weight);
}
let total_row_weight: f32 = row_weights.iter().sum();
let available_width = rect.width() - self.gap * (cols + 1) as f32;
let available_height = rect.height() - self.gap * (rows + 1) as f32;
let col_widths: Vec<f32> = col_weights
.iter()
.map(|w| available_width * (w / total_col_weight))
.collect();
let row_heights: Vec<f32> = row_weights
.iter()
.map(|w| available_height * (w / total_row_weight))
.collect();
let mut col_positions = vec![rect.min.x + self.gap];
for (i, width) in col_widths.iter().enumerate() {
col_positions.push(col_positions[i] + width + self.gap);
}
let mut row_positions = vec![rect.min.y + self.gap];
for (i, height) in row_heights.iter().enumerate() {
row_positions.push(row_positions[i] + height + self.gap);
}
visible_panes
.iter()
.enumerate()
.map(|(i, (pane_idx, _))| {
let col = i % cols;
let row = i / cols;
let x = col_positions[col];
let y = row_positions[row];
let w = col_widths[col];
let h = row_heights[row];
(
*pane_idx,
Rect::from_min_size(Pos2::new(x, y), Vec2::new(w, h)),
)
})
.collect()
}
fn calculate_dividers(
&self,
visible_panes: &[(usize, &WorkspacePane)],
rect: Rect,
columns: Option<usize>,
) -> Vec<DividerInfo> {
if visible_panes.len() <= 1 {
return Vec::new();
}
let count = visible_panes.len();
let cols = columns.unwrap_or_else(|| match count {
1 => 1,
2 => 2,
3..=4 => 2,
5..=6 => 3,
_ => ((count as f32).sqrt().ceil() as usize).max(2),
});
let rows = (count + cols - 1) / cols;
let mut dividers = Vec::new();
let pane_rects = self.calculate_tile_layout(visible_panes, rect, columns);
for col in 0..cols.saturating_sub(1) {
let left_panes: Vec<usize> = visible_panes
.iter()
.enumerate()
.filter(|(i, _)| i % cols == col)
.map(|(_, (_, p))| p.order)
.collect();
let right_panes: Vec<usize> = visible_panes
.iter()
.enumerate()
.filter(|(i, _)| i % cols == col + 1)
.map(|(_, (_, p))| p.order)
.collect();
if !left_panes.is_empty() && !right_panes.is_empty() {
if let Some((_, left_rect)) = pane_rects.iter().find(|(idx, _)| {
visible_panes
.iter()
.position(|(i, _)| *i == *idx)
.map_or(false, |pos| pos % cols == col)
}) {
let div_x = left_rect.max.x + self.gap / 2.0;
let div_rect = Rect::from_min_max(
Pos2::new(div_x - 4.0, rect.min.y),
Pos2::new(div_x + 4.0, rect.max.y),
);
dividers.push(DividerInfo {
is_vertical: true,
position: div_x,
panes: (left_panes, right_panes),
rect: div_rect,
});
}
}
}
for row in 0..rows.saturating_sub(1) {
let top_panes: Vec<usize> = visible_panes
.iter()
.enumerate()
.filter(|(i, _)| i / cols == row)
.map(|(_, (_, p))| p.order)
.collect();
let bottom_panes: Vec<usize> = visible_panes
.iter()
.enumerate()
.filter(|(i, _)| i / cols == row + 1)
.map(|(_, (_, p))| p.order)
.collect();
if !top_panes.is_empty() && !bottom_panes.is_empty() {
if let Some((_, top_rect)) = pane_rects.iter().find(|(idx, _)| {
visible_panes
.iter()
.position(|(i, _)| *i == *idx)
.map_or(false, |pos| pos / cols == row)
}) {
let div_y = top_rect.max.y + self.gap / 2.0;
let div_rect = Rect::from_min_max(
Pos2::new(rect.min.x, div_y - 4.0),
Pos2::new(rect.max.x, div_y + 4.0),
);
dividers.push(DividerInfo {
is_vertical: false,
position: div_y,
panes: (top_panes, bottom_panes),
rect: div_rect,
});
}
}
}
dividers
}
fn check_resize_edge(&self, ui: &mut Ui, rect: Rect) -> Option<ResizeEdge> {
let pointer_pos = ui.input(|i| i.pointer.hover_pos())?;
let edge_size = 8.0;
let right_edge =
Rect::from_min_max(Pos2::new(rect.max.x - edge_size, rect.min.y), rect.max);
let bottom_edge =
Rect::from_min_max(Pos2::new(rect.min.x, rect.max.y - edge_size), rect.max);
let corner = Rect::from_min_max(
Pos2::new(rect.max.x - edge_size, rect.max.y - edge_size),
rect.max,
);
if corner.contains(pointer_pos) {
Some(ResizeEdge::BottomRight)
} else if right_edge.contains(pointer_pos) {
Some(ResizeEdge::Right)
} else if bottom_edge.contains(pointer_pos) {
Some(ResizeEdge::Bottom)
} else {
None
}
}
fn apply_snap(
&self,
pos: Pos2,
size: Vec2,
canvas_rect: Rect,
pane_rects: &[(usize, Rect)],
current_idx: usize,
) -> (Pos2, Option<SnapTarget>) {
let mut snapped_pos = pos;
let mut snap_target = None;
if (pos.x - canvas_rect.min.x).abs() < self.snap_threshold {
snapped_pos.x = canvas_rect.min.x + self.gap;
snap_target = Some(SnapTarget::CanvasEdge(Edge::Left));
}
if (pos.x + size.x - canvas_rect.max.x).abs() < self.snap_threshold {
snapped_pos.x = canvas_rect.max.x - size.x - self.gap;
snap_target = Some(SnapTarget::CanvasEdge(Edge::Right));
}
if (pos.y - canvas_rect.min.y).abs() < self.snap_threshold {
snapped_pos.y = canvas_rect.min.y + self.gap;
snap_target = Some(SnapTarget::CanvasEdge(Edge::Top));
}
if (pos.y + size.y - canvas_rect.max.y).abs() < self.snap_threshold {
snapped_pos.y = canvas_rect.max.y - size.y - self.gap;
snap_target = Some(SnapTarget::CanvasEdge(Edge::Bottom));
}
for (idx, other_rect) in pane_rects {
if *idx == current_idx {
continue;
}
let pane = &self.panes[*idx];
if (pos.x + size.x - other_rect.min.x).abs() < self.snap_threshold {
snapped_pos.x = other_rect.min.x - size.x - self.gap;
snap_target = Some(SnapTarget::Pane {
id: pane.id.clone(),
edge: Edge::Left,
});
}
if (pos.x - other_rect.max.x).abs() < self.snap_threshold {
snapped_pos.x = other_rect.max.x + self.gap;
snap_target = Some(SnapTarget::Pane {
id: pane.id.clone(),
edge: Edge::Right,
});
}
if (pos.y + size.y - other_rect.min.y).abs() < self.snap_threshold {
snapped_pos.y = other_rect.min.y - size.y - self.gap;
snap_target = Some(SnapTarget::Pane {
id: pane.id.clone(),
edge: Edge::Top,
});
}
if (pos.y - other_rect.max.y).abs() < self.snap_threshold {
snapped_pos.y = other_rect.max.y + self.gap;
snap_target = Some(SnapTarget::Pane {
id: pane.id.clone(),
edge: Edge::Bottom,
});
}
}
if let Some(grid_size) = self.grid_size {
if snap_target.is_none() {
let grid_x = (snapped_pos.x / grid_size).round() as i32;
let grid_y = (snapped_pos.y / grid_size).round() as i32;
snapped_pos.x = grid_x as f32 * grid_size;
snapped_pos.y = grid_y as f32 * grid_size;
snap_target = Some(SnapTarget::Grid {
x: grid_x,
y: grid_y,
});
}
}
(snapped_pos, snap_target)
}
fn draw_pane(
&self,
painter: &egui::Painter,
interaction: &PaneInteraction,
pane: &WorkspacePane,
theme: &Theme,
drag_state: &DragState,
locked: bool,
) {
let is_dragging = drag_state.dragging.as_ref() == Some(&pane.id);
let is_drop_target = drag_state.tile_drop_target == Some(pane.order)
&& drag_state.tile_drag_source.is_some()
&& drag_state.tile_drag_source != Some(pane.order);
let bg_color = if is_dragging {
theme.bg_tertiary
} else if is_drop_target {
Color32::from_rgba_unmultiplied(
theme.primary.r(),
theme.primary.g(),
theme.primary.b(),
40,
)
} else {
theme.bg_secondary
};
painter.rect_filled(interaction.rect, theme.radius_md, bg_color);
let title_bg = if interaction.title_hovered && !locked {
theme.bg_tertiary
} else {
theme.bg_primary
};
painter.rect_filled(interaction.title_rect, theme.radius_md, title_bg);
painter.text(
Pos2::new(
interaction.title_rect.min.x + theme.spacing_sm,
interaction.title_rect.center().y,
),
egui::Align2::LEFT_CENTER,
&pane.title,
egui::FontId::proportional(theme.font_size_sm),
theme.text_primary,
);
if locked {
painter.text(
Pos2::new(
interaction.title_rect.min.x + theme.spacing_xs,
interaction.title_rect.min.y + theme.spacing_xs,
),
egui::Align2::LEFT_TOP,
"🔒",
egui::FontId::proportional(theme.font_size_xs),
theme.text_muted,
);
}
if let Some(close_rect) = interaction.close_rect {
let close_color = if interaction.close_clicked {
theme.state_danger
} else {
theme.text_muted
};
painter.text(
close_rect.center(),
egui::Align2::CENTER_CENTER,
"×",
egui::FontId::proportional(theme.font_size_md),
close_color,
);
}
if let Some(minimize_rect) = interaction.minimize_rect {
painter.text(
minimize_rect.center(),
egui::Align2::CENTER_CENTER,
"−",
egui::FontId::proportional(theme.font_size_md),
theme.text_muted,
);
}
let border_color = if is_dragging || is_drop_target {
theme.primary
} else {
theme.border
};
painter.rect_stroke(
interaction.rect,
theme.radius_md,
Stroke::new(theme.border_width, border_color),
egui::StrokeKind::Inside,
);
if !locked && matches!(self.layout_mode, LayoutMode::Free) {
if let Some(edge) = interaction.resize_edge {
let handle_color = theme.primary.gamma_multiply(0.5);
let handle_size = 6.0;
let handle_pos = match edge {
ResizeEdge::Right => {
Pos2::new(interaction.rect.max.x - 3.0, interaction.rect.center().y)
}
ResizeEdge::Bottom => {
Pos2::new(interaction.rect.center().x, interaction.rect.max.y - 3.0)
}
ResizeEdge::BottomRight => {
Pos2::new(interaction.rect.max.x - 3.0, interaction.rect.max.y - 3.0)
}
};
painter.circle_filled(handle_pos, handle_size, handle_color);
}
}
}
fn draw_snap_guide(
&self,
painter: &egui::Painter,
target: &SnapTarget,
canvas_rect: Rect,
theme: &Theme,
) {
let guide_color = theme.primary.gamma_multiply(0.7);
let guide_stroke = Stroke::new(2.0, guide_color);
match target {
SnapTarget::CanvasEdge(edge) => {
let (start, end) = match edge {
Edge::Left => (
Pos2::new(canvas_rect.min.x + self.gap, canvas_rect.min.y),
Pos2::new(canvas_rect.min.x + self.gap, canvas_rect.max.y),
),
Edge::Right => (
Pos2::new(canvas_rect.max.x - self.gap, canvas_rect.min.y),
Pos2::new(canvas_rect.max.x - self.gap, canvas_rect.max.y),
),
Edge::Top => (
Pos2::new(canvas_rect.min.x, canvas_rect.min.y + self.gap),
Pos2::new(canvas_rect.max.x, canvas_rect.min.y + self.gap),
),
Edge::Bottom => (
Pos2::new(canvas_rect.min.x, canvas_rect.max.y - self.gap),
Pos2::new(canvas_rect.max.x, canvas_rect.max.y - self.gap),
),
};
painter.line_segment([start, end], guide_stroke);
}
SnapTarget::Pane { .. } => {
}
SnapTarget::Grid { x, y } => {
if let Some(grid_size) = self.grid_size {
let pos = Pos2::new(*x as f32 * grid_size, *y as f32 * grid_size);
painter.circle_filled(pos, 4.0, guide_color);
}
}
}
}
fn draw_grid(&self, painter: &egui::Painter, rect: Rect, grid_size: f32, theme: &Theme) {
let grid_color = Color32::from_rgba_unmultiplied(
theme.border.r(),
theme.border.g(),
theme.border.b(),
30,
);
let grid_stroke = Stroke::new(0.5, grid_color);
let mut x = rect.min.x;
while x < rect.max.x {
painter.line_segment(
[Pos2::new(x, rect.min.y), Pos2::new(x, rect.max.y)],
grid_stroke,
);
x += grid_size;
}
let mut y = rect.min.y;
while y < rect.max.y {
painter.line_segment(
[Pos2::new(rect.min.x, y), Pos2::new(rect.max.x, y)],
grid_stroke,
);
y += grid_size;
}
}
fn draw_lock_indicator(&self, painter: &egui::Painter, rect: Rect, theme: &Theme) {
let indicator_rect = Rect::from_min_size(
Pos2::new(rect.max.x - 40.0, rect.min.y + 4.0),
Vec2::new(36.0, 20.0),
);
painter.rect_filled(
indicator_rect,
theme.radius_sm,
Color32::from_rgba_unmultiplied(0, 0, 0, 100),
);
painter.text(
indicator_rect.center(),
egui::Align2::CENTER_CENTER,
"🔒 Lock",
egui::FontId::proportional(theme.font_size_xs),
theme.text_muted,
);
}
}