diff --git a/crates/ui-egui/src/dock_shared.rs b/crates/ui-egui/src/dock_shared.rs index d5bb28c2..2b64351d 100644 --- a/crates/ui-egui/src/dock_shared.rs +++ b/crates/ui-egui/src/dock_shared.rs @@ -1,6 +1,6 @@ //! Convert the persisted workspace schema to shared docking transactions. -use craft_ui::docking::{Action, Floating as SharedFloating, Layout as SharedLayout, Node}; +use craft_ui::docking::{Action, Floating as SharedFloating, Layout as SharedLayout, Location, Node, Placement, Zone}; use craft_ui::layout::{SplitAxis, SplitSize as SharedSplitSize}; use crate::dock::{DockNode, Floating, Layout, PanelKind, SplitSize}; @@ -96,6 +96,24 @@ pub(crate) fn apply_tree(root: &mut DockNode, action: Action) -> bool true } +/// Dock a panel along one edge of the whole workspace. The shared model restores a panel beside a +/// complete sibling subtree, so naming every remaining docked panel targets the root itself. +pub(crate) fn dock_at_edge(layout: &mut SharedLayout, panel: PanelKind, zone: Zone) -> Result<(), String> { + let size = match zone { + Zone::Left | Zone::Top => SharedSplitSize::Ratio(0.25), + Zone::Right | Zone::Bottom => SharedSplitSize::Ratio(0.75), + Zone::Center => return Err("a workspace edge needs a left, right, top or bottom zone".into()), + }; + let mut proposed = layout.clone(); + proposed.apply(Action::Close { panel }).map_err(|error| error.to_string())?; + let sibling: Vec = SharedLayout { root: proposed.root.clone(), floating: vec![] }.panels().into_iter().copied().collect(); + let anchor = *sibling.first().ok_or("another docked panel is required to dock beside")?; + let location = Location { floating: None, anchor: Some(anchor), placement: Placement::Split(zone), size: Some(size), stack: None, sibling }; + proposed.restore(panel, &location).map_err(|error| error.to_string())?; + *layout = proposed; + Ok(()) +} + /// Keep one docked destination available for dragging floating panels back into the workspace. pub(crate) fn check_last_docked(layout: &SharedLayout, action: &Action) -> Result<(), String> { let panel = match action { diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 8d206d71..26b30601 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -48,7 +48,6 @@ pub(crate) fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, param Action::Float { panel, rect } } _ => { - let anchor = params.get("anchor").and_then(Value::as_str).and_then(PanelKind::from_name).ok_or("a known anchor panel is required")?; let zone_name = match params.get("zone") { None => "center", Some(value) => value.as_str().ok_or("zone must be a string")?, @@ -61,6 +60,14 @@ pub(crate) fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, param "center" | "tab" => Zone::Center, _ => return Err("unknown docking zone".into()), }; + let Some(anchor) = params.get("anchor") else { + if params.get("before").is_some() { + return Err("before needs an anchor panel".into()); + } + app.dock_to_edge(panel, zone)?; + return Ok(json!({"panel": panel.id()})); + }; + let anchor = anchor.as_str().and_then(PanelKind::from_name).ok_or("a known anchor panel is required")?; let placement = match params.get("before") { Some(value) => Placement::Tab { before: Some(value.as_str().and_then(PanelKind::from_name).ok_or("unknown before panel")?) }, None => Placement::Split(zone), @@ -90,14 +97,10 @@ impl FilmcraftApp { Action::Activate { panel } => Some(*panel), _ => None, }; - let mut shared = crate::dock_shared::encode(&self.ui.dock, &self.ui.floating)?; - shared.validate().map_err(|error| error.to_string())?; - crate::dock_shared::check_last_docked(&shared, &action)?; - shared.apply(action).map_err(|error| error.to_string())?; - let layout = crate::dock_shared::decode(shared)?; - crate::dock_shared::encode(&layout.root, &layout.floating)?; - self.ui.dock = layout.root; - self.ui.floating = layout.floating; + self.commit_dock(|shared| { + crate::dock_shared::check_last_docked(shared, &action)?; + shared.apply(action).map_err(|error| error.to_string()) + })?; if let Some(panel) = reveal { self.pending_panel_raise = Some(panel); } @@ -109,6 +112,24 @@ impl FilmcraftApp { Ok(()) } + /// Dock a panel along an edge of the whole workspace rather than beside one group. + pub fn dock_to_edge(&mut self, panel: PanelKind, zone: Zone) -> Result<(), String> { + self.commit_dock(|shared| crate::dock_shared::dock_at_edge(shared, panel, zone))?; + self.ui.keys.maximized = None; + Ok(()) + } + + fn commit_dock(&mut self, change: impl FnOnce(&mut craft_ui::docking::Layout) -> Result<(), String>) -> Result<(), String> { + let mut shared = crate::dock_shared::encode(&self.ui.dock, &self.ui.floating)?; + shared.validate().map_err(|error| error.to_string())?; + change(&mut shared)?; + let layout = crate::dock_shared::decode(shared)?; + crate::dock_shared::encode(&layout.root, &layout.floating)?; + self.ui.dock = layout.root; + self.ui.floating = layout.floating; + Ok(()) + } + fn panel_body(&mut self, ui: &mut egui::Ui, panel: PanelKind, content: Rect) { self.auto.add(&format!("panel.{}", panel.id()), content, panel.title()); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", panel.id()))); @@ -256,7 +277,7 @@ impl FilmcraftApp { } } } - self.panel_drag(&ctx, &targets); + self.panel_drag(&ctx, body, &targets); panels::panel_menu_popup(self, ui); self.cancel_value_drag(ui.ctx()); } @@ -317,7 +338,7 @@ impl FilmcraftApp { output } - fn panel_drag(&mut self, ctx: &egui::Context, targets: &[DropTarget]) { + fn panel_drag(&mut self, ctx: &egui::Context, body: Rect, targets: &[DropTarget]) { let Some(panel) = ctx.data(|data| data.get_temp::(drag_id())) else { return }; let canceled = ctx.input(|input| input.key_pressed(egui::Key::Escape) || !input.focused || (!input.pointer.primary_down() && !input.pointer.primary_released())); @@ -327,9 +348,14 @@ impl FilmcraftApp { } let Some(pos) = ctx.pointer_latest_pos() else { return }; let force_float = ctx.input(|input| input.modifiers.command); - let proposal = (!force_float).then(|| resolve_drop(&panel, pos, targets)).flatten(); + let edge = (!force_float).then(|| workspace_edge(body, pos, targets)).flatten(); + let proposal = (!force_float && edge.is_none()).then(|| resolve_drop(&panel, pos, targets)).flatten(); let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop"))); let t = self.tokens; + if let Some((_, _, preview)) = edge { + painter.rect_filled(preview.shrink(2.0), t.radius, t.focus.gamma_multiply(0.2)); + painter.rect_stroke(preview.shrink(2.0), t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside); + } if let Some(proposal) = &proposal { painter.rect_filled(proposal.preview.shrink(2.0), t.radius, t.focus.gamma_multiply(0.2)); painter.rect_stroke(proposal.preview.shrink(2.0), t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside); @@ -345,6 +371,12 @@ impl FilmcraftApp { ctx.request_repaint(); if ctx.input(|input| input.pointer.primary_released()) { ctx.data_mut(|data| data.remove::(drag_id())); + if let Some((_, zone, _)) = edge { + if crate::menus::invoke(self, ctx, "window.dockPanel", json!({"panel": panel.id(), "zone": zone})).is_ok() { + self.ui.focused = panel; + } + return; + } let action = match proposal { Some(proposal) => Some(Action::Move { panel, anchor: proposal.anchor, placement: proposal.placement }), None if force_float || !targets.iter().any(|target| target.rect.contains(pos)) => { @@ -361,6 +393,36 @@ impl FilmcraftApp { } } +/// Width of the band along the workspace border that docks beside the whole layout. +const EDGE_BAND: f32 = 8.0; + +/// The outermost drop zone: near the workspace border a drop splits the root, not the group +/// under the pointer. A floating group over the border (it rejects splits) keeps the drop. +fn workspace_edge(body: Rect, pos: egui::Pos2, targets: &[DropTarget]) -> Option<(Zone, &'static str, Rect)> { + if !body.is_finite() || !body.contains(pos) || targets.iter().any(|target| !target.allow_splits && target.rect.contains(pos)) { + return None; + } + let band = EDGE_BAND.min(body.width() / 4.0).min(body.height() / 4.0); + let edges = [ + (pos.x - body.left(), Zone::Left, "left"), + (body.right() - pos.x, Zone::Right, "right"), + (pos.y - body.top(), Zone::Top, "top"), + (body.bottom() - pos.y, Zone::Bottom, "bottom"), + ]; + let (distance, zone, name) = edges.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?; + if distance > band { + return None; + } + let size = body.size() * 0.25; + let preview = match zone { + Zone::Left => Rect::from_min_size(body.min, vec2(size.x, body.height())), + Zone::Right => Rect::from_min_max(pos2(body.right() - size.x, body.top()), body.max), + Zone::Top => Rect::from_min_size(body.min, vec2(body.width(), size.y)), + Zone::Bottom | Zone::Center => Rect::from_min_max(pos2(body.left(), body.bottom() - size.y), body.max), + }; + Some((zone, name, preview)) +} + fn target(group: &Group, mut tabs: Vec<(PanelKind, Rect)>, allow_splits: bool) -> Option> { let strip = Rect::from_min_max(group.rect.min, pos2(group.rect.max.x, group.content.min.y)); // A compact grip still identifies the source group to the shared resolver. diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/shared_docking_ui.rs index fcabbdd0..3df87b90 100644 --- a/crates/ui-egui/tests/shared_docking_ui.rs +++ b/crates/ui-egui/tests/shared_docking_ui.rs @@ -275,6 +275,52 @@ fn real_panel_drag_merges_splits_floats_and_redocks_without_editing_documents() assert!(h.state().ui_error.is_none()); } +#[test] +fn dropping_on_the_window_edge_docks_beside_the_whole_workspace() { + let mut app = FilmcraftApp::new(Session::default()); + app.ui.dock = layout().root; + let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| app); + for _ in 0..4 { + step(&mut h); + } + let from = rect(&h, "panel.tab.Info").center(); + let leftmost = rect(&h, "panel.Info"); + // The outer edge of the leftmost group: without the window-edge zone this splits only that group. + drag(&mut h, from, pos2(leftmost.left() + 2.0, leftmost.center().y), Modifiers::NONE); + assert_eq!( + h.state().ui.dock, + DockNode::Split { + vertical: false, + size: SplitSize::Ratio(0.25), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Info], active: 0 }), + b: Box::new(DockNode::Split { + vertical: false, + size: SplitSize::FixedA(400.0), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::History], active: 1 }), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Source, PanelKind::Program], active: 1 }), + }), + } + ); +} + +#[test] +fn dock_panel_without_anchor_docks_along_a_workspace_edge() { + let mut app = FilmcraftApp::new(Session::default()); + app.ui.dock = layout().root; + let ctx = egui::Context::default(); + menus::invoke(&mut app, &ctx, "window.dockPanel", json!({"panel": "Program", "zone": "bottom"})).unwrap(); + let DockNode::Split { vertical: true, size: SplitSize::Ratio(ratio), a, b } = &app.ui.dock else { panic!("root not split: {:?}", app.ui.dock) }; + assert_eq!(*ratio, 0.75); + assert_eq!(**b, DockNode::Tabs { panels: vec![PanelKind::Program], active: 0 }); + assert!(!a.contains(PanelKind::Program) && a.contains(PanelKind::Source) && a.contains(PanelKind::Info)); + let before = app.ui.dock.clone(); + assert!(menus::invoke(&mut app, &ctx, "window.dockPanel", json!({"panel": "Info", "zone": "center"})).is_err()); + assert_eq!(app.ui.dock, before); + app.ui.dock = DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }; + assert!(menus::invoke(&mut app, &ctx, "window.dockPanel", json!({"panel": "Project", "zone": "left"})).is_err()); + assert_eq!(app.ui.dock, DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }); +} + fn same_group(root: &DockNode, first: PanelKind, second: PanelKind) -> bool { match root { DockNode::Tabs { panels, .. } => panels.contains(&first) && panels.contains(&second), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4196a42e..80802cf2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -177,7 +177,7 @@ Time is in ticks (254 016 000 000 per second); commands also accept `seconds`, ` | Command | Parameters | Result | | --- | --- | --- | | `window.floatPanel` | `{panel?, rect?: [x,y,width,height]}` | Move an existing panel into a floating group. Omitted panel uses the focused panel; omitted rect uses a centered 420×320 group. | -| `window.dockPanel` | `{panel?, anchor, zone?: "center"\|"left"\|"right"\|"top"\|"bottom", before?}` | Move into the anchor's tab group or a side split; `before` names an existing destination tab for precise ordering. Floating destinations accept tabs only. | +| `window.dockPanel` | `{panel?, anchor?, zone?: "center"\|"left"\|"right"\|"top"\|"bottom", before?}` | Move into the anchor's tab group or a side split; `before` names an existing destination tab for precise ordering. Floating destinations accept tabs only. Without `anchor`, a side `zone` docks the panel along that edge of the whole workspace, at a quarter of its width or height. | | `window.closePanel` | `{panel?}` | Close a docked or floating panel without closing its project documents. | Invalid source/target identities and non-finite or out-of-range geometry return an error with