Merge pull request #1467 from marcoarnulfo/fix/dock-to-window-edge

fix(dock): dock a panel along the edge of the whole workspace
This commit is contained in:
Brandon Thomas 2026-10-13 00:03:42 -04:00
commit f2ad4b9c94
4 changed files with 140 additions and 14 deletions

View File

@ -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<PanelKind>) -> 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<PanelKind>, 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<PanelKind> = 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<PanelKind>, action: &Action<PanelKind>) -> Result<(), String> {
let panel = match action {

View File

@ -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<PanelKind>) -> 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<PanelKind>]) {
fn panel_drag(&mut self, ctx: &egui::Context, body: Rect, targets: &[DropTarget<PanelKind>]) {
let Some(panel) = ctx.data(|data| data.get_temp::<PanelKind>(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::<PanelKind>(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<PanelKind>]) -> 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<DropTarget<PanelKind>> {
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.

View File

@ -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),

View File

@ -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