From fdf5cd668ba755a9a0de8ab3d2c6d4657fdf43ff Mon Sep 17 00:00:00 2001 From: Fabio Lussi Date: Sat, 10 Oct 2026 19:39:36 +0200 Subject: [PATCH] Push neighbouring dock groups when dragging a splitter (#2573) (#2624) Dragging a dock splitter only traded height with the next expanded group, so it hard-stopped once that group hit its minimum. resize() now shrinks the groups on the side the splitter moves into in turn, nearest first, each down to its minimum (skipping collapsed groups, the filler taking its share implicitly), and grows the group on the other side by what they gave. Works both ways; the column height stays the same. Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/dock.rs | 42 ++++++++--- crates/ui-egui/src/dock_tests.rs | 119 ++++++++++++++++++++++++++++++- 2 files changed, 147 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 7e275df612..bd7f617542 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -492,7 +492,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod ui.close(); } }); - // Splitter in the gap below this group: resizes it against the next expanded group. + // Splitter in the gap below this group: resizes it, pushing the neighbouring groups. if i + 1 < rects.len() && !collapsed && rects.iter().skip(i + 1).any(|(n, _)| !app.ui.dock.is_collapsed(*n)) { let gap = Rect::from_min_size(pos2(rect.left(), rect.bottom()), vec2(rect.width(), GAP)).expand2(vec2(0.0, 2.0)); let sresp = ui.interact(gap, ui.id().with(("dock-splitter", g.key())), Sense::drag()); @@ -574,16 +574,24 @@ fn drop_before(order: &[Group], rects: &[(Group, Rect)], dragged: Group, y: f32) } } -/// Splitter `i` (below group `i`) dragged by `dy`: group `i` grows or shrinks against the next -/// expanded group (or the filler, which absorbs the difference). +/// Splitter `i` (below group `i`) dragged by `dy`. Like Photoshop, the groups on the side the +/// splitter moves into shrink in turn, nearest first, each down to its minimum (#2573), and the +/// group on the other side grows by what they gave. The filler absorbs its share implicitly, +/// so the column height stays the same. fn resize(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize, dy: f32) { if !dy.is_finite() || dy == 0.0 { return; } - let Some(&(g, h)) = heights.get(i) else { return }; - let filler = heights.iter().rposition(|(g, _)| !layout.is_collapsed(*g)); - let Some(j) = heights.iter().enumerate().skip(i + 1).find(|(_, (n, _))| !layout.is_collapsed(*n)).map(|(j, _)| j) else { return }; - let Some(&(n, nh)) = heights.get(j) else { return }; + let expanded = |k: &usize| heights.get(*k).is_some_and(|(g, _)| !layout.is_collapsed(*g)); + let filler = (0..heights.len()).rev().find(expanded); + let Some(j) = (i + 1..heights.len()).find(expanded) else { return }; + if !expanded(&i) { + return; + } + // Dragging down grows group `i` at the expense of the groups below; dragging up grows the + // next expanded group at the expense of `i` and the groups above it. + let (grow, shrink): (usize, Vec) = + if dy > 0.0 { (i, (j..heights.len()).filter(expanded).collect()) } else { (j, (0..=i).rev().filter(expanded).collect()) }; // The first drag pins the other groups at the heights they show, so groups still at their // defaults (which give way to the filler) don't shift while this one is resized. for (k, (o, oh)) in heights.iter().enumerate() { @@ -591,10 +599,22 @@ fn resize(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize, dy: f32) layout.heights.entry(*o).or_insert(*oh); } } - let new_h = (h + dy).clamp(g.min_height(), (h + nh - n.min_height()).max(g.min_height())); - layout.heights.insert(g, new_h); - if Some(j) != filler { - layout.heights.insert(n, (nh - (new_h - h)).max(n.min_height())); + let mut left = dy.abs(); + for k in shrink { + if left <= 0.0 { + break; + } + let Some(&(o, oh)) = heights.get(k) else { continue }; + let give = (oh - o.min_height()).max(0.0).min(left); + left -= give; + if Some(k) != filler { + layout.heights.insert(o, oh - give); + } + } + if let Some(&(o, oh)) = heights.get(grow) + && Some(grow) != filler + { + layout.heights.insert(o, oh + (dy.abs() - left)); } } diff --git a/crates/ui-egui/src/dock_tests.rs b/crates/ui-egui/src/dock_tests.rs index 3d629c9850..30e02c5dbe 100644 --- a/crates/ui-egui/src/dock_tests.rs +++ b/crates/ui-egui/src/dock_tests.rs @@ -158,14 +158,17 @@ fn dragging_the_splitter_resizes_and_survives_a_ui_state_round_trip() { assert!((layers2.top() - (layers.top() - 60.0)).abs() < 2.0, "{layers:?} -> {layers2:?}"); assert_eq!(rect_of(&h, Group::Color), color, "the group above keeps its place"); assert_eq!(layers2.bottom(), layers.bottom()); - let stored = h.state().ui.dock.heights.get(&Group::Properties).copied().unwrap(); - // Dragging past the minimum stops at it. + // Dragging past the minimum pushes the group above down to its minimum too (#2573), then stops. let split = Pos2::new(props2.center().x, props2.bottom() + GAP / 2.0); drag(&mut h, split, split - vec2(0.0, 600.0)); assert_eq!(rect_of(&h, Group::Properties).height(), Group::Properties.min_height()); + assert_eq!(rect_of(&h, Group::Color).height(), Group::Color.min_height()); + assert_eq!(rect_of(&h, Group::Layers).bottom(), layers.bottom()); + // Dragging back down grows Properties alone; Color stays where it was pushed. let from = Pos2::new(split.x, rect_of(&h, Group::Properties).bottom() + GAP / 2.0); drag(&mut h, from, split); - assert!((h.state().ui.dock.heights[&Group::Properties] - stored).abs() < 2.0); + assert!((rect_of(&h, Group::Properties).bottom() - props2.bottom()).abs() < 2.0); + assert_eq!(rect_of(&h, Group::Color).height(), Group::Color.min_height()); // Save and restore the UI state (what `ui.inspect` / `ui.set` and workspaces carry). let saved = serde_json::to_value(&h.state().ui).unwrap(); @@ -190,6 +193,116 @@ fn dragging_the_splitter_resizes_and_survives_a_ui_state_round_trip() { assert_eq!(app4.ui.dock, DockLayout::default()); } +/// `resize` on `shown` laid out in a column `avail` tall, then the new layout. +fn resized(l: &mut DockLayout, shown: &[Group], avail: f32, i: usize, dy: f32) -> Vec<(Group, f32)> { + let hs = l.heights_for(shown, avail, 28.0); + resize(l, &hs, i, dy); + l.heights_for(shown, avail, 28.0) +} + +fn total(hs: &[(Group, f32)]) -> f32 { + hs.iter().map(|(_, h)| h).sum::() + GAP * hs.len().saturating_sub(1) as f32 +} + +#[test] +fn dragging_a_splitter_down_pushes_every_group_below_in_turn() { + let shown = [Group::Color, Group::Properties, Group::Navigator, Group::Layers]; + let mut l = DockLayout::default(); + let before = l.heights_for(&shown, 1200.0, 28.0); + // Properties gives way first, then Navigator, then the filler; the column stays 1200. + let hs = resized(&mut l, &shown, 1200.0, 0, 300.0); + assert_eq!(hs[0].1, before[0].1 + 300.0, "{hs:?}"); + assert_eq!(hs[1].1, Group::Properties.min_height()); + assert_eq!(hs[2].1, before[2].1 - (300.0 - (before[1].1 - Group::Properties.min_height()))); + assert_eq!(hs[3].1, before[3].1, "the filler is untouched until the groups above it are at minimum"); + assert!((total(&hs) - 1200.0).abs() < 1e-3); + // Far past the end: everything below is at its minimum and the drag stops there. + let hs = resized(&mut l, &shown, 1200.0, 0, 5000.0); + assert_eq!(hs[1].1, Group::Properties.min_height()); + assert_eq!(hs[2].1, Group::Navigator.min_height()); + assert_eq!(hs[3].1, Group::Layers.min_height()); + assert!((total(&hs) - 1200.0).abs() < 1e-3, "{hs:?}"); + let full = hs[0].1; + assert_eq!(resized(&mut l, &shown, 1200.0, 0, 50.0)[0].1, full); +} + +#[test] +fn dragging_a_splitter_up_pushes_every_group_above_in_turn() { + let shown = [Group::Color, Group::Properties, Group::Navigator, Group::Layers]; + let mut l = DockLayout::default(); + let before = l.heights_for(&shown, 1200.0, 28.0); + // The splitter under Navigator: Navigator shrinks first, then Properties, then Color. + let hs = resized(&mut l, &shown, 1200.0, 2, -200.0); + assert_eq!(hs[2].1, Group::Navigator.min_height()); + assert_eq!(hs[1].1, before[1].1 - (200.0 - (before[2].1 - Group::Navigator.min_height()))); + assert_eq!(hs[0].1, before[0].1); + assert_eq!(hs[3].1, before[3].1 + 200.0, "the filler takes the room"); + let hs = resized(&mut l, &shown, 1200.0, 2, -5000.0); + assert!(hs[..3].iter().all(|(g, h)| *h == g.min_height()), "{hs:?}"); + assert!((total(&hs) - 1200.0).abs() < 1e-3); + // The group grown when the filler isn't next: the splitter under Color grows Properties. + let mut l = DockLayout::default(); + let hs = resized(&mut l, &shown, 1200.0, 0, -500.0); + assert_eq!(hs[0].1, Group::Color.min_height()); + assert_eq!(hs[1].1, before[1].1 + (before[0].1 - Group::Color.min_height())); + assert_eq!(hs[2].1, before[2].1); +} + +#[test] +fn pushing_skips_collapsed_groups_and_respects_the_filler() { + let shown = [Group::Color, Group::Properties, Group::Navigator, Group::History, Group::Layers]; + let mut l = DockLayout::default(); + l.set_collapsed(Group::Properties, true); + l.set_collapsed(Group::Layers, true); + let before = l.heights_for(&shown, 1200.0, 28.0); + // History is the filler now; Properties keeps its strip and Layers stays collapsed. + let hs = resized(&mut l, &shown, 1200.0, 0, 5000.0); + assert_eq!(hs[1].1, 28.0); + assert_eq!(hs[4].1, 28.0); + assert_eq!(hs[2].1, Group::Navigator.min_height()); + assert_eq!(hs[3].1, Group::History.min_height()); + assert_eq!(hs[0].1, before[0].1 + (before[2].1 - Group::Navigator.min_height()) + (before[3].1 - Group::History.min_height())); + assert!((total(&hs) - 1200.0).abs() < 1e-3); + assert!(!l.heights.contains_key(&Group::History), "the filler's height is never stored"); + // Dragging up from Navigator over the collapsed Properties shrinks Color. + let hs = resized(&mut l, &shown, 1200.0, 2, -5000.0); + assert_eq!(hs[0].1, Group::Color.min_height()); + assert_eq!(hs[1].1, 28.0); + assert_eq!(hs[2].1, Group::Navigator.min_height()); + assert!((total(&hs) - 1200.0).abs() < 1e-3); + // Nothing to resize against, bad deltas and out-of-range splitters are ignored. + let snapshot = l.clone(); + for (i, dy) in [(3, 50.0), (4, 50.0), (9, 50.0), (0, f32::NAN), (0, f32::INFINITY), (0, 0.0)] { + let hs = l.heights_for(&shown, 1200.0, 28.0); + resize(&mut l, &hs, i, dy); + assert_eq!(l, snapshot, "splitter {i} by {dy}"); + } +} + +#[test] +fn dragging_swatches_down_pushes_properties_then_layers() { + let (app, _, _) = app_with_layers(); + let mut h = harness(app, vec2(1200.0, 800.0), ThemeKind::ProMedium); + let color = rect_of(&h, Group::Color); + let props = rect_of(&h, Group::Properties); + let layers = rect_of(&h, Group::Layers); + let room = (props.height() - Group::Properties.min_height()) + (layers.height() - Group::Layers.min_height()); + let split = Pos2::new(color.center().x, color.bottom() + GAP / 2.0); + // Past Properties' minimum: Layers gives way too instead of the drag hard-stopping. + let dy = props.height() - Group::Properties.min_height() + 40.0; + drag(&mut h, split, split + vec2(0.0, dy)); + assert!((rect_of(&h, Group::Color).height() - (color.height() + dy)).abs() < 2.0, "{color:?} -> {:?}", rect_of(&h, Group::Color)); + assert_eq!(rect_of(&h, Group::Properties).height(), Group::Properties.min_height()); + assert!((rect_of(&h, Group::Layers).height() - (layers.height() - 40.0)).abs() < 2.0); + assert_eq!(rect_of(&h, Group::Layers).bottom(), layers.bottom()); + // All the way: everything below at its minimum. + let split = Pos2::new(color.center().x, rect_of(&h, Group::Color).bottom() + GAP / 2.0); + drag(&mut h, split, split + vec2(0.0, 2000.0)); + assert!((rect_of(&h, Group::Color).height() - (color.height() + room)).abs() < 2.0); + assert_eq!(rect_of(&h, Group::Layers).height(), Group::Layers.min_height()); + assert_eq!(rect_of(&h, Group::Layers).bottom(), layers.bottom()); +} + #[test] fn reset_workspace_restores_the_default_layout_and_new_workspaces_keep_theirs() { let (app, _, _) = app_with_layers();