fix(dock): dock a panel along the edge of the whole workspace (fixes #1463)
This commit is contained in:
parent
71438c627b
commit
e5b1ed90a9
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue