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();