//! Application-owned panel IDs and commands around the shared docking layout. use craft_ui::docking::{Action, Floating, Layout, Node, Placement, Zone}; use serde_json::{Value, json}; use crate::VectorcraftApp; #[derive(Clone, Default)] struct LegacyDrag { layout: Option>, open_panel: Option, floating: Vec, icons: std::collections::BTreeSet, } #[derive(Clone)] struct RootGroupDrag { panel: String, layout: Layout, hidden: std::collections::BTreeMap>, generation: u64, } fn cancel_legacy_drag(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { let key = egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"); let previous = ctx.data_mut(|data| data.remove_temp::(key)); if let Some(previous) = previous { app.ui.open_panel = previous.open_panel; app.ui.floating_panels = previous.floating; app.ui.docking = previous.layout; app.ui.docking_icons = previous.icons; craft_ui::docking::cancel_drag::(ctx, egui::Id::new("vectorcraft-panel-docking")); true } else { false } } fn normalize(raw: &str) -> Option<&'static str> { crate::menus::normalize_panel(raw) } pub(crate) fn valid(layout: &Layout) -> bool { layout.validate().is_ok() && layout.panels().iter().all(|id| normalize(id) == Some(id.as_str())) } fn legacy(app: &VectorcraftApp) -> Layout { let panels: Vec = crate::floating::docked_tabs(&app.ui).map(|tab| tab.info().0.to_string()).collect(); let active = panels.iter().position(|id| id == app.ui.dock_tab.info().0).unwrap_or(0); let root = (!panels.is_empty()).then_some(Node::Tabs { panels, active }); let mut layout = Layout { root, floating: Vec::new() }; for group in &app.ui.floating_panels { let panels: Vec = group.panels.iter().filter(|id| normalize(id).is_some() && !layout.contains(id)).cloned().collect(); if !panels.is_empty() && group.pos.iter().all(|v| v.is_finite()) { let active = group.active.min(panels.len().saturating_sub(1)); layout.floating.push(Floating { panels, active, rect: [group.pos[0], group.pos[1], group.width.unwrap_or(300.0).clamp(200.0, 720.0), 400.0], }); } } layout } fn icon_origins(app: &VectorcraftApp) -> std::collections::BTreeSet { let mut icons = app.ui.docking_icons.clone(); if app.ui.docking.is_none() { // Legacy workspaces have no custom groups in the dock. Icon-panel floats therefore // retain their native rail destination when first converted to a shared layout. icons.extend( app.ui .floating_panels .iter() .flat_map(|group| group.panels.iter()) .filter(|id| crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id.as_str())) .cloned(), ); } icons } fn first_root(layout: &Layout, except: &str) -> Option { let root = Layout { root: layout.root.clone(), floating: Vec::new() }; root.panels().into_iter().find(|p| p.as_str() != except).cloned() } // Preserve the native dock's selected tab when another tab leaves. If the selected tab // itself leaves, the remaining group's first tab is the native fallback. fn float_fallback(layout: &Layout, panel: &str) -> Option { let mut pending: Vec<_> = layout.root.iter().collect(); while let Some(node) = pending.pop() { match node { Node::Tabs { panels, active } if panels.iter().any(|id| id == panel) => { return panels.get(*active).filter(|id| id.as_str() != panel).or_else(|| panels.iter().find(|id| id.as_str() != panel)).cloned(); } Node::Split { first, second, .. } => pending.extend([second.as_ref(), first.as_ref()]), _ => {} } } None } fn restore_float_fallback(layout: &mut Layout, panel: Option) -> Result<(), String> { if let Some(panel) = panel.filter(|panel| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(panel)) { layout.apply(Action::Activate { panel }).map_err(|error| error.to_string())?; } Ok(()) } fn ensure(layout: &mut Layout, panel: &str) -> Result<(), String> { let anchor = first_root(layout, panel); layout.apply(Action::Open { panel: panel.into(), anchor }).map_err(|e| e.to_string()) } fn group_members(layout: &Layout, panel: &str) -> Vec { if let Some(group) = layout.floating.iter().find(|g| g.panels.iter().any(|id| id == panel)) { return group.panels.clone(); } let mut visit: Vec<_> = layout.root.iter().collect(); while let Some(node) = visit.pop() { match node { Node::Tabs { panels, .. } if panels.iter().any(|id| id == panel) => return panels.clone(), Node::Split { first, second, .. } => { visit.push(second); visit.push(first); } _ => {} } } vec![panel.into()] } fn number(params: &Value, key: &str, default: f32) -> Result { match params.get(key) { None => Ok(default), Some(v) => v .as_f64() .filter(|v| v.is_finite() && v.abs() <= 1_000_000.0) .map(|v| v as f32) .ok_or_else(|| format!("{key} must be a finite coordinate")), } } fn placement(params: &Value) -> Result, String> { if let Some(before) = params.get("before") { let id = before.as_str().and_then(normalize).ok_or("before must name a panel")?; return Ok(Placement::Tab { before: Some(id.into()) }); } let zone = match params.get("zone") { None => "tab", Some(value) => value.as_str().ok_or("zone must be a string")?, }; Ok(match zone { "tab" => Placement::Tab { before: None }, "center" => Placement::Split(Zone::Center), "left" => Placement::Split(Zone::Left), "right" => Placement::Split(Zone::Right), "top" => Placement::Split(Zone::Top), "bottom" => Placement::Split(Zone::Bottom), _ => return Err("zone must be tab, center, left, right, top or bottom".into()), }) } pub(crate) fn command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Option> { if matches!(id, "window.panel.float" | "window.panel.dock") && (params.get("above").is_some() || params.get("below").is_some() || params.get("column").is_some() || params.get("collapsed").is_some() || params .get("onto") .and_then(Value::as_str) .and_then(normalize) .and_then(|id| crate::floating::group_of(&app.ui, id)) .and_then(|i| app.ui.floating_panels.get(i)) .is_some_and(decorated) || params .get("panel") .and_then(Value::as_str) .and_then(normalize) .and_then(|id| crate::floating::group_of(&app.ui, id)) .and_then(|i| app.ui.floating_panels.get(i)) .is_some_and(decorated)) { return Some(native_change(app, id, params)); } if id == "window.panel.layout" { return Some( params .get("action") .ok_or_else(|| "action is required".to_string()) .and_then(|value| serde_json::from_value::>(value.clone()).map_err(|error| error.to_string())) .and_then(|action| apply_action(app, action)), ); } if params.get("panel").and_then(Value::as_str).is_some_and(|s| s.eq_ignore_ascii_case("tools") || s.eq_ignore_ascii_case("toolbar")) { return None; } let operation = match id { "window.panel.move" => "move", "window.panel.float" => "float", "window.panel.dock" => "dock", "window.panel.activate" => "activate", "window.panel.close" => "close", "window.panel" if app.ui.docking.is_some() => "activate", _ => return None, }; Some(change(app, operation, params).map(|value| { if id == "window.panel" && let Some(panel) = params.get("panel").and_then(Value::as_str).and_then(normalize) && let Some(group) = app.ui.floating_panels.iter().find(|group| group.panels.iter().any(|id| id == panel)) { return json!({"floating":group.panels}); } value })) } // UI output and automation share this atomic dispatcher. The application owns the registry // and saved return locations; craft-ui owns tree validation and structural changes. fn apply_action(app: &mut VectorcraftApp, action: Action) -> Result { let registered = |id: &String| normalize(id) == Some(id.as_str()); let known = match &action { Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => { registered(panel) && registered(anchor) && match placement { Placement::Tab { before: Some(id) } => registered(id), _ => true, } } Action::Open { panel, anchor } => registered(panel) && anchor.as_ref().is_none_or(registered), Action::Float { panel, .. } | Action::Close { panel } | Action::Activate { panel } | Action::MoveFloating { panel, .. } | Action::SetStackOpen { panel, .. } | Action::ResizeStack { panel, .. } => registered(panel), Action::ResizeSplit { path, .. } => path.len() <= craft_ui::docking::MAX_DEPTH, }; if !known { return Err("docking action contains an unknown panel or an excessive split path".into()); } if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { return Err("saved panel layout is invalid; reset the workspace first".into()); } let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); let mut icons = icon_origins(app); let explicit_dock = match &action { Action::Move { panel, anchor, .. } | Action::OpenAt { panel, anchor, .. } if (Layout { root: layout.root.clone(), floating: Vec::new() }).contains(anchor) => { Some(panel.clone()) } _ => None, }; let returning = match &action { Action::Close { panel } => layout.location(panel).ok().map(|location| (panel.clone(), location)), Action::Float { panel, .. } => { layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location)) } _ => None, }; let fallback = match &action { Action::Float { panel, .. } => float_fallback(&layout, panel), _ => None, }; let activated = match &action { Action::Activate { panel } => Some(panel.clone()), _ => None, }; layout.apply(action).map_err(|error| error.to_string())?; restore_float_fallback(&mut layout, fallback)?; if let Some((panel, location)) = returning { app.ui.docking_hidden.insert(panel, location); } if let Some(panel) = explicit_dock { icons.remove(&panel); } app.ui.docking_icons = icons; app.ui.docking = Some(layout); if let Some(panel) = activated { for group in &mut app.ui.floating_panels { if group.panels.contains(&panel) { group.collapsed = false; } } } sync_legacy(app); app.ui.open_panel = None; Ok(json!({"layout": app.ui.docking})) } fn change(app: &mut VectorcraftApp, operation: &str, params: &Value) -> Result { let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { return Err("saved panel layout is invalid; reset the workspace first".into()); } let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); if params.get("x").is_some() != params.get("y").is_some() { return Err("x and y must be provided together".into()); } let whole_group = match params.get("group") { None => false, Some(Value::Bool(value)) => *value, Some(_) => return Err("group must be true or false".into()), }; let members = if whole_group { group_members(&layout, panel) } else { vec![panel.to_string()] }; let mut hidden: std::collections::BTreeMap<_, _> = app.ui.docking_hidden.iter().filter(|(id, _)| normalize(id).is_some()).take(64).map(|(id, at)| (id.clone(), at.clone())).collect(); let mut icons = icon_origins(app); let icon_panel = crate::state::ICON_PANELS.iter().any(|entry| entry.0 == panel); if icon_panel && !layout.contains(&panel.to_string()) { icons.insert(panel.to_string()); } if operation == "activate" && icon_panel && !layout.contains(&panel.to_string()) { app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(panel) { None } else { Some(panel.into()) }; return Ok(json!({"panel": panel, "open": app.ui.open_panel})); } if operation == "dock" && params.get("anchor").or_else(|| params.get("onto")).is_none() { let mut candidate = layout.clone(); let mut open = None; for member in &members { if candidate.contains(member) { candidate.apply(Action::Close { panel: member.clone() }).map_err(|error| error.to_string())?; } } // Remove the complete group before restoring any saved neighbor. Otherwise a // still-floating group member can accidentally receive the first restored tab. for member in &members { if icons.contains(member) { open = Some(member.clone()); } else if let Some(location) = hidden.get(member).filter(|location| { location.floating.is_none() && location.anchor.as_ref().is_none_or(|anchor| Layout { root: candidate.root.clone(), floating: Vec::new() }.contains(anchor)) }) { if candidate.restore(member.clone(), location).is_err() { // A removed neighboring group uses the normal dock as its documented fallback. ensure(&mut candidate, member)?; } } else { ensure(&mut candidate, member)?; } } if candidate.contains(&panel.to_string()) { candidate.apply(Action::Activate { panel: panel.to_string() }).map_err(|error| error.to_string())?; } else if icons.contains(panel) { open = Some(panel.to_string()); } if !valid(&candidate) { return Err("invalid restored panel group".into()); } app.ui.docking = Some(candidate); app.ui.docking_icons = icons; for member in &members { hidden.remove(member); } app.ui.docking_hidden = hidden; sync_legacy(app); app.ui.open_panel = open; return Ok(json!({"panel": panel, "floating": false, "layout": app.ui.docking})); } let mut restored = false; if operation == "dock" && params.get("anchor").or_else(|| params.get("onto")).is_none() && let Some(location) = hidden.get(panel).filter(|location| { location.floating.is_none() && location.anchor.as_ref().is_none_or(|anchor| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(anchor)) }) { let mut candidate = layout.clone(); if candidate.contains(&panel.to_string()) { candidate.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; } if candidate.restore(panel.into(), location).is_ok() { layout = candidate; restored = true; hidden.remove(panel); } } match operation { "dock" if restored => {} "float" => { let old = layout .floating .iter() .find(|group| group.panels.iter().any(|id| id == panel) && group.panels.len() == members.len()) .map(|group| group.rect) .unwrap_or([400.0 + 24.0 * layout.floating.len().min(12) as f32, 140.0 + 24.0 * layout.floating.len().min(12) as f32, 300.0, 400.0]); let rect = [number(params, "x", old[0])?, number(params, "y", old[1])?, number(params, "width", old[2])?, number(params, "height", old[3])?]; let fallback = float_fallback(&layout, panel).or_else(|| first_root(&layout, panel)); for member in &members { if let Ok(location) = layout.location(member) && location.floating.is_none() { hidden.insert(member.clone(), location); } } ensure(&mut layout, panel)?; if let Ok(location) = layout.location(&panel.to_string()) && location.floating.is_none() { hidden.insert(panel.into(), location); } if let Some(onto) = params.get("onto") { let onto = onto.as_str().and_then(normalize).ok_or("onto must name a floating panel")?; if members.iter().any(|id| id == onto) || layout.floating.iter().any(|group| group.panels.iter().any(|id| id == panel) && group.panels.iter().any(|id| id == onto)) || !layout.floating.iter().any(|group| group.panels.iter().any(|id| id == onto)) { return Err("onto must name a panel floating in another group".into()); } for member in &members { layout .apply(Action::Move { panel: member.clone(), anchor: onto.into(), placement: Placement::Tab { before: None } }) .map_err(|e| e.to_string())?; } } else { let lead = members.first().ok_or("panel group is empty")?; layout.apply(Action::Float { panel: lead.clone(), rect }).map_err(|e| e.to_string())?; for member in members.iter().filter(|id| *id != lead) { layout .apply(Action::Move { panel: member.clone(), anchor: lead.clone(), placement: Placement::Tab { before: None } }) .map_err(|e| e.to_string())?; } } layout.apply(Action::Activate { panel: panel.into() }).map_err(|e| e.to_string())?; restore_float_fallback(&mut layout, fallback)?; } "move" | "dock" => { if operation == "move" && params.get("anchor").or_else(|| params.get("onto")).is_none() { return Err("move requires a destination panel".into()); } let anchor = match params.get("anchor").or_else(|| params.get("onto")) { Some(v) => Some(v.as_str().and_then(normalize).ok_or("anchor must name a registered panel")?.to_string()), None => first_root(&layout, panel), }; let placement = placement(params)?; if let Some(anchor) = anchor { ensure(&mut layout, panel)?; layout.apply(Action::Move { panel: panel.into(), anchor, placement }).map_err(|e| e.to_string())?; } else if operation == "dock" { if layout.contains(&panel.to_string()) { layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; } layout.apply(Action::Open { panel: panel.into(), anchor: None }).map_err(|e| e.to_string())?; } else { return Err("move requires an existing destination panel".into()); } } "close" => { hidden.insert(panel.into(), layout.location(&panel.to_string()).map_err(|e| e.to_string())?); layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; } _ => { if !layout.contains(&panel.to_string()) && let Some(location) = hidden.get(panel) { // A missing neighbor is allowed: the normal visible dock becomes the fallback. let _ = layout.restore(panel.into(), location); } ensure(&mut layout, panel)?; hidden.remove(panel); } } if operation == "dock" && whole_group { for member in members.iter().filter(|id| id.as_str() != panel) { layout .apply(Action::Move { panel: member.clone(), anchor: panel.into(), placement: Placement::Tab { before: None } }) .map_err(|e| e.to_string())?; } layout.apply(Action::Activate { panel: panel.into() }).map_err(|e| e.to_string())?; } let group = layout.floating.iter().find(|group| group.panels.iter().any(|id| id == panel)); let result = json!({"panel": panel, "floating": group.is_some(), "group": group.map(|g| &g.panels), "pos": group.map(|g| [g.rect[0], g.rect[1]]), "layout": layout}); if matches!(operation, "move" | "dock") && (Layout { root: layout.root.clone(), floating: Vec::new() }).contains(&panel.to_string()) { icons.remove(panel); } app.ui.docking = Some(layout); app.ui.docking_hidden = hidden; app.ui.docking_icons = icons; if operation == "activate" { for group in &mut app.ui.floating_panels { if group.panels.iter().any(|id| id == panel) { group.collapsed = false; } } } sync_legacy(app); app.ui.open_panel = None; app.ui.dock = true; if matches!(operation, "activate" | "move" | "dock") { app.ui.dock_collapsed = false; } Ok(result) } struct PanelContent<'a> { app: &'a mut VectorcraftApp, commands: Vec<(&'static str, Value)>, } impl craft_ui::docking::DockContent for PanelContent<'_> { fn body(&mut self, ui: &mut egui::Ui, panel: &String) { let width = ui.available_width(); let height = (ui.available_height() - 20.0).max(0.0); if crate::state::DockTab::from_id(panel).is_some() { crate::dock::panel_body(self.app, ui, panel, width, height); } else { egui::ScrollArea::vertical() .id_salt(("shared-panel-body", panel)) .auto_shrink([false, false]) .show(ui, |ui| crate::dock::panel_body(self.app, ui, panel, width, height)); } } fn panel_menu(&mut self, ui: &mut egui::Ui, panel: &String) { crate::panels::panel_menu_items(self.app, ui, panel); } fn group_header_width(&self, _panels: &[String], _active: &String, floating: bool) -> f32 { if floating { 48.0 } else { 80.0 } } fn group_header(&mut self, ui: &mut egui::Ui, _panels: &[String], active: &String, floating: bool) { let rect = ui.max_rect(); let menu = egui::Rect::from_center_size(rect.right_center() - egui::vec2(12.0, 0.0), egui::vec2(16.0, 16.0)); crate::panels::panel_menu(self.app, ui, active, menu); let grip = egui::Rect::from_min_max(rect.min, egui::pos2(menu.left() - 4.0, rect.bottom())); if floating { let response = ui.put(grip, egui::Button::new("×")).on_hover_text(tl!("Dock Panel")); if response.clicked() { self.commands.push(("window.panel.dock", json!({"panel": active, "group": true}))); } } else { let response = ui.interact(grip, ui.id().with("dock-group-bar"), egui::Sense::drag()); if response.drag_started() && let Some(pos) = response.interact_pointer_pos() { ui.ctx().data_mut(|data| { data.insert_temp( egui::Id::new("vectorcraft-root-group-drag"), RootGroupDrag { panel: active.clone(), layout: self.app.ui.docking.clone().unwrap_or_else(|| legacy(self.app)), hidden: self.app.ui.docking_hidden.clone(), generation: self.app.ui.docking_generation, }, ) }); self.commands.push(("window.panel.float", json!({"panel": active, "group": true, "x": pos.x, "y": pos.y}))); } response.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group")); } } } pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { let root_key = egui::Id::new("vectorcraft-root-group-drag"); if let Some(drag) = ui.ctx().data(|data| data.get_temp::(root_key)) { if drag.generation != app.ui.docking_generation { ui.ctx().data_mut(|data| data.remove::(root_key)); } else if ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape)) { app.ui.docking = Some(drag.layout); app.ui.docking_hidden = drag.hidden; sync_legacy(app); ui.ctx().data_mut(|data| data.remove::(root_key)); } else if let Some(pos) = ui.input(|input| input.pointer.interact_pos()) { if ui.input(|input| input.pointer.primary_down()) { if let Err(error) = app.run("window.panel.float", json!({"panel":drag.panel, "group":true, "x":pos.x, "y":pos.y})) { app.ui.status = error; } if let Some(panel) = normalize(&drag.panel) && let Some((target, rect)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) { crate::floating::preview(ui.ctx(), target, rect); } } else { if let Some(panel) = normalize(&drag.panel) && let Some((target, _)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) { let (id, params) = match target { crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel":panel,"group":true})), crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"onto":onto})), crate::floating::Drop::Below(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"below":onto})), crate::floating::Drop::Above(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"above":onto})), crate::floating::Drop::Column(slot) => ("window.panel.dock", json!({"panel":panel,"group":true,"column":slot})), }; if let Err(error) = app.run(id, params) { app.ui.status = error; } } ui.ctx().data_mut(|data| data.remove::(root_key)); } } } let area = egui::Id::new("vectorcraft-panel-docking"); let changed_workspace = ui.ctx().data_mut(|data| { let previous = data.get_temp::(area.with("generation")); data.insert_temp(area.with("generation"), app.ui.docking_generation); previous.is_some_and(|generation| generation != app.ui.docking_generation) }); if changed_workspace { craft_ui::docking::cancel_drag::(ui.ctx(), area); ui.ctx().data_mut(|data| data.remove::(area.with("legacy-origin"))); } let cancelled = ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape) || (!input.pointer.primary_down() && !input.pointer.any_released())); if cancelled && cancel_legacy_drag(app, ui.ctx()) && app.ui.docking.is_none() { return false; } let Some(layout) = app.ui.docking.as_ref() else { return false }; if !valid(layout) { app.ui.docking = None; app.ui.status = "Invalid panel layout; restored the default dock".into(); return false; } let layout = layout.clone(); let t = crate::theme::Tokens::get(ui.ctx()); let collapsed = app.ui.dock_collapsed; let mut display = if collapsed { Layout { root: None, floating: layout.floating.clone() } } else { layout.clone() }; display .floating .retain(|group| !app.ui.floating_panels.iter().any(|native| decorated(native) && native.panels.iter().any(|id| group.panels.contains(id)))); // Floating groups lie on the canvas, not on the dock's card: reserve a fill under each one // before the dock draws it, sized once it is placed, so the canvas doesn't show through (#2421). let fills: Vec<_> = display .floating .iter() .filter_map(|group| group.panels.first()) .map(|first| { let layer = egui::LayerId::new(egui::Order::Middle, area.with(("floating", first))); (layer, ui.ctx().layer_painter(layer).add(egui::Shape::Noop)) }) .collect(); let mut draw = |ui: &mut egui::Ui| { if layout.root.is_some() && crate::dock::collapse_header(ui, collapsed) { let _ = app.run("window.collapseDock", json!({"collapsed":!collapsed})); } if collapsed { let root = Layout { root: layout.root.clone(), floating: Vec::new() }; for panel in root.panels() { let info = crate::state::DockTab::from_id(panel) .map(|tab| tab.info()) .or_else(|| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == panel).copied()); if let Some((_, label, icon)) = info && crate::widgets::icon_button(ui, icon, tl!(label), app.ui.open_panel.as_ref() == Some(panel), 30.0).clicked() { app.ui.open_panel = if app.ui.open_panel.as_ref() == Some(panel) { None } else { Some(panel.clone()) }; } } } let mut style = craft_ui::docking::DockStyle::from_ui(ui); style.tab_height = 33.0; // The dock is a card (`theme::card`): its panes and tab strip lie on the card's colour // (square fills would cover its rounded corners). Floating groups get the fills below. style.background = egui::Color32::TRANSPARENT; style.tab_background = egui::Color32::TRANSPARENT; style.active_background = t.button; style.text = t.text_strong; style.inactive_text = t.text_dim; style.border = egui::Stroke::new(1.0, t.border); style.accent = t.accent; style.float_label = tl!("Float Panel").to_string(); style.close_label = tl!("Close Panel").to_string(); style.move_label = tl!("Group with").to_string(); style.panels_label = tl!("Panels").to_string(); style.resize_label = tl!("Resize panels").to_string(); style.resize_window_label = tl!("Resize panel window").to_string(); let mut content = PanelContent { app, commands: Vec::new() }; let output = craft_ui::docking::DockArea::new(area).show_customized( ui, &display, &style, |id| crate::state::all_panels().find(|p| p.0 == id).map(|p| tl!(p.1).to_string()).unwrap_or_else(|| id.clone()), |_| craft_ui::docking::Permissions::default(), |_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() }, &mut content, ); for (id, params) in content.commands { if let Err(error) = app.run(id, params) { app.ui.status = error; } } output }; let output = if layout.root.is_some() { let panel = egui::Panel::right("shared-panel-dock").default_size(310.0).size_range(220.0..=720.0).resizable(true).frame(egui::Frame::NONE); let panel = if collapsed { panel.exact_size(38.0).resizable(false) } else { panel }; let shown = panel.show(ui, |ui| crate::theme::card(ui, &t, egui::Margin { left: 0, right: 6, top: 6, bottom: 6 }, &mut draw)); ui.ctx().data_mut(|data| data.insert_temp(crate::floating::dock_rect_id(), shown.response.rect)); shown.inner } else { draw(ui) }; if let Some(panel) = output.group_drag.as_deref().and_then(normalize) && let Some(pos) = ui.input(|input| input.pointer.interact_pos()) && let Some((target, rect)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) { crate::floating::preview(ui.ctx(), target, rect); } if let Some(error) = output.error { app.ui.status = error.to_string(); } // Tab and icon tears finish as model actions; title moves finish as group_drop. // Native sets are absent from the shared render tree, so their pointer target must win // over a float proposal or a root target drawn behind the native window. let tab_drop = ui.input(|input| input.pointer.primary_released().then(|| input.pointer.interact_pos()).flatten()).and_then(|pos| { output.actions.iter().find_map(|action| { let panel = match action { Action::Float { panel, .. } | Action::Move { panel, .. } => normalize(panel)?, _ => return None, }; crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) .filter(|(target, _)| !matches!(target, crate::floating::Drop::Dock)) .map(|(target, _)| (panel, target)) }) }); if ui.input(|input| input.pointer.any_released()) { let native_drop = output.group_drop.as_ref().is_some_and(|(panel, pos)| { normalize(panel).and_then(|id| crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(id), *pos)).is_some() }); if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. })) && !native_drop && tab_drop.is_none() && cancel_legacy_drag(app, ui.ctx()) { return app.ui.docking.is_some(); } ui.ctx().data_mut(|data| data.remove::(egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"))); } for action in output.actions { if tab_drop .is_some_and(|(panel, _)| matches!(&action, Action::Float { panel: moved, .. } | Action::Move { panel: moved, .. } if moved == panel)) { continue; } if let Err(error) = app.run("window.panel.layout", json!({"action": action})) { app.ui.status = error; } } let group_drop = output.group_drop.and_then(|(panel, pos)| { let panel = normalize(&panel)?; crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos).map(|(target, _)| (panel, target, true)) }); if let Some((panel, target, whole_group)) = group_drop.or_else(|| tab_drop.map(|(panel, target)| (panel, target, false))) { let (command, params) = match target { crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel": panel, "group": whole_group})), crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "onto": onto})), crate::floating::Drop::Below(onto) => ("window.panel.float", json!({"panel":panel,"group":whole_group,"below":onto})), crate::floating::Drop::Above(onto) => ("window.panel.float", json!({"panel":panel,"group":whole_group,"above":onto})), crate::floating::Drop::Column(slot) => ("window.panel.dock", json!({"panel":panel,"group":whole_group,"column":slot})), }; // Shared and native windows have already chosen their render ownership for this // frame. Switch representations only after both renderers finish, so a panel's // widgets never appear in two layers in the same frame. ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("vectorcraft-native-panel-drop"), (command, params))); } for group in &display.floating { if let Some(first) = group.panels.first() && let Some(rect) = ui.ctx().memory(|memory| memory.area_rect(area.with(("floating", first)))) { ui.ctx().data_mut(|data| data.insert_temp(crate::floating::group_rect_id(first), rect)); } } for (layer, fill) in fills { if let Some(rect) = ui.ctx().memory(|memory| memory.area_rect(layer.id)) { ui.ctx().layer_painter(layer).set(fill, egui::Shape::rect_filled(rect, 0.0, t.panel)); } } icon_rail(app, ui); crate::floating::docked_columns(app, ui); true } fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); let column = egui::Panel::right("shared-panel-icons") .exact_size(38.0) .resizable(false) .frame(egui::Frame::NONE) .show(ui, |ui| { crate::theme::card(ui, &t, egui::Margin { left: 3, right: 3, top: 6, bottom: 6 }, |ui| { egui::ScrollArea::vertical().id_salt("shared-panel-icons-scroll").show(ui, |ui| { ui.spacing_mut().item_spacing.y = 2.0; for &(id, label, icon) in crate::state::ICON_PANEL_GROUPS .iter() .flat_map(|group| group.iter()) .filter_map(|id| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == *id)) { if app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&id.to_string())) { continue; } let (_, rect) = ui.allocate_space(egui::vec2(30.0, 30.0)); let response = ui.interact(rect, ui.id().with(("panel-icon", id)), egui::Sense::click_and_drag()); let response = crate::widgets::paint_icon_button(ui, response, icon, tl!(label), app.ui.open_panel.as_deref() == Some(id)); if response.clicked() { app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(id) { None } else { Some(id.into()) }; } legacy_tab(app, ui, id, &response); } }); }) }) .response .rect; ui.ctx().data_mut(|data| { let dock = if app.ui.docking.as_ref().is_some_and(|layout| layout.root.is_some()) { data.get_temp::(crate::floating::dock_rect_id()).unwrap_or(column) } else { column }; data.insert_temp(crate::floating::dock_rect_id(), dock.union(column)); data.insert_temp(crate::floating::icons_rect_id(), column); data.insert_temp(egui::Id::new("dock-icon-column-left"), column.left()); }); } pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) { let Some(panel) = normalize(panel) else { return }; response.context_menu(|ui| { if ui.button(tl!("Float Panel")).clicked() { if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) { app.ui.status = error; } ui.close(); } ui.menu_button(tl!("Group with"), |ui| { for (anchor, label) in crate::state::all_panels() { if anchor != panel && ui.button(tl!(label)).clicked() { // A closed icon panel is made visible before becoming the destination. let result = app .run("window.panel.activate", json!({"panel": anchor})) .and_then(|_| app.run("window.panel.move", json!({"panel":panel,"anchor":anchor}))); if let Err(error) = result { app.ui.status = error; } ui.close(); } } }); }); if response.drag_started() && let Some(id) = normalize(panel) { let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); let bounds = ui.max_rect(); let source = egui::Rect::from_min_size( egui::pos2(bounds.left(), response.rect.top()), egui::vec2(bounds.width().clamp(240.0, 600.0), bounds.height().clamp(200.0, 600.0)), ); if ensure(&mut layout, id).is_ok() && craft_ui::docking::begin_drag(ui.ctx(), egui::Id::new("vectorcraft-panel-docking"), id.to_string(), source) { ui.ctx().data_mut(|data| { data.insert_temp( egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"), LegacyDrag { layout: app.ui.docking.clone(), open_panel: app.ui.open_panel.clone(), floating: app.ui.floating_panels.clone(), icons: app.ui.docking_icons.clone(), }, ) }); if !app.ui.docking.as_ref().unwrap_or(&legacy(app)).contains(&id.to_string()) && crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id) { app.ui.docking_icons.insert(id.into()); } app.ui.docking = Some(layout); app.ui.open_panel = None; } } } // Keep the legacy inspection schema useful; shared layout remains the rendering authority. pub(crate) fn decorated(group: &crate::state::FloatingPanels) -> bool { group.column.is_some() || group.docked.is_some() || group.collapsed } /// Keep the native set metadata alongside the validated shared ownership tree. Return locations /// and root splits survive the mirror update; failed reconciliation restores the whole UI state. pub(crate) fn reconcile_native(app: &mut VectorcraftApp, before: crate::state::UiState) -> Result<(), String> { if before.docking.is_none() { return Ok(()); } let desired = app.ui.floating_panels.clone(); let result = (|| { let prior = before.docking.as_ref().ok_or("missing panel layout")?; app.ui.docking = Some(prior.clone()); for group in &prior.floating { for panel in &group.panels { if !desired.iter().any(|g| g.panels.contains(panel)) { change(app, "dock", &json!({"panel":panel}))?; } } } let mut layout = app.ui.docking.clone().ok_or("missing panel layout")?; for group in &desired { for panel in &group.panels { if (Layout { root: layout.root.clone(), floating: vec![] }).contains(panel) { if let Ok(location) = layout.location(panel) { app.ui.docking_hidden.insert(panel.clone(), location); } layout.apply(Action::Close { panel: panel.clone() }).map_err(|e| e.to_string())?; } } } layout.floating = desired .iter() .map(|group| { let height = prior.floating.iter().find(|g| g.panels.iter().any(|id| group.panels.contains(id))).map_or(400.0, |g| g.rect[3]); Floating { panels: group.panels.clone(), active: group.active, rect: [group.pos[0], group.pos[1], group.width.unwrap_or(300.0), height], } }) .collect(); if !valid(&layout) { return Err("invalid native panel set".into()); } app.ui.docking = Some(layout); app.ui.floating_panels = desired; Ok(()) })(); if result.is_err() { app.ui = before; } result } fn native_change(app: &mut VectorcraftApp, id: &str, params: &Value) -> Result { let before = app.ui.clone(); let result = (|| { let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; if crate::floating::group_of(&app.ui, panel).is_none() { let mut initial = params.clone(); if let Some(object) = initial.as_object_mut() { for key in ["above", "below", "column", "collapsed", "onto"] { object.remove(key); } } change(app, "float", &initial)?; } let baseline = app.ui.clone(); let value = crate::floating::command(app, id, params).ok_or("unsupported native docking operation")??; reconcile_native(app, baseline)?; Ok(value) })(); if result.is_err() { app.ui = before; } result } fn sync_legacy(app: &mut VectorcraftApp) { if let Some(layout) = &app.ui.docking { let old = app.ui.floating_panels.clone(); let mut pending: Vec<_> = layout.root.iter().collect(); while let Some(node) = pending.pop() { match node { Node::Tabs { panels, active } => { if let Some(tab) = panels.get(*active).and_then(|id| crate::state::DockTab::from_id(id)) { app.ui.dock_tab = tab; } } Node::Split { first, second, .. } => pending.extend([second.as_ref(), first.as_ref()]), _ => {} } } app.ui.floating_panels = layout .floating .iter() .map(|group| crate::state::FloatingPanels { panels: group.panels.clone(), active: group.active, pos: [group.rect[0], group.rect[1]], width: Some(group.rect[2]), column: old.iter().find(|native| group.panels.first().is_some_and(|first| native.panels.contains(first))).and_then(|g| g.column), collapsed: old .iter() .find(|native| group.panels.first().is_some_and(|first| native.panels.contains(first))) .is_some_and(|g| g.collapsed), docked: old.iter().find(|native| group.panels.first().is_some_and(|first| native.panels.contains(first))).and_then(|g| g.docked), }) .collect(); } } #[cfg(test)] #[path = "panel_docking_tests.rs"] mod tests; /// Commit a pointer drop after shared and native panel windows have finished rendering. pub(crate) fn finish_native_drop(app: &mut VectorcraftApp, ctx: &egui::Context) { if let Some((command, params)) = ctx.data_mut(|data| data.remove_temp::<(&'static str, serde_json::Value)>(egui::Id::new("vectorcraft-native-panel-drop"))) && let Err(error) = app.run(command, params) { app.ui.status = error; } }