From c4fbe220bc48d4d5a18cf62ebda8aa1443f6093a Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 9 Oct 2026 22:45:31 -0600 Subject: [PATCH] UI.7: resize accordion bodies with pointer and keyboard handles --- crates/ui/src/docking/surface.rs | 39 ++++++++++++++++++++++++++++++- crates/ui/src/docking/tests.rs | 40 ++++++++++++++++++++++++++++++++ docs/ui-docking.md | 2 +- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 1e7284a..cc4e7b0 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -246,7 +246,8 @@ impl Renderer<'_, P> { let total_wanted: f64 = entries.iter().filter(|e| e.open).map(|e| f64::from(e.height.unwrap_or(self.style.min_pane).max(1.0))).sum(); let header = self.style.tab_height.min(rect.height() / entries.len().max(1) as f32); let mut top = rect.top(); - for entry in entries { + let mut dividers = Vec::new(); + for (index, entry) in entries.iter().enumerate() { let share = if entry.open && total_wanted > 0.0 { (f64::from(remaining) * f64::from(entry.height.unwrap_or(self.style.min_pane).max(1.0)) / total_wanted) as f32 } else { @@ -283,6 +284,42 @@ impl Renderer<'_, P> { accepts_tabs: (self.permissions)(&entry.panel).accepts_tabs, }); top = panel_rect.bottom(); + if entry.open && remaining > 0.0 { + dividers.push((index, Rect::from_min_max(pos2(rect.left(), top - 1.0), pos2(rect.right(), top + 1.0)).intersect(rect))); + } + } + // Register dividers after the headers so their narrow hit regions win at the + // shared boundary. Closed entries retain their saved sizes and are skipped. + for (index, divider) in dividers { + let Some(first) = entries.get(index) else { continue }; + let Some(second) = entries.iter().skip(index.saturating_add(1)).find(|entry| entry.open) else { continue }; + let a = f64::from(first.height.unwrap_or(self.style.min_pane).max(1.0)); + let b = f64::from(second.height.unwrap_or(self.style.min_pane).max(1.0)); + let scale = f64::from(remaining) / total_wanted; + let first_size = (a * scale) as f32; + let pair_size = ((a + b) * scale) as f32; + let body_limits = |panel: &P| { + let mut limits = (self.limits)(panel); + limits.min.y = limits.min.y.max(self.style.min_pane); + limits.max = limits.max.max(limits.min); + limits + }; + let (lo, hi) = split_bounds(pair_size, SplitAxis::Vertical, body_limits(&first.panel), body_limits(&second.panel)); + let response = ResizeHandle::new( + self.id.with(("stack-resize", &first.panel)), + SplitAxis::Vertical, + divider, + &self.style.resize_label, + ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }, + ) + .value(first_size, lo..=hi) + .show(ui); + if response.delta != 0.0 && scale > 0.0 { + let delta = f64::from((first_size + response.delta).clamp(lo, hi) - first_size) / scale; + let next_a = (a + delta).clamp((a + b - 1_000_000.0).max(1.0), (a + b - 1.0).min(1_000_000.0)); + self.output.actions.push(Action::ResizeStack { panel: first.panel.clone(), height: Some(next_a as f32) }); + self.output.actions.push(Action::ResizeStack { panel: second.panel.clone(), height: Some((a + b - next_a) as f32) }); + } } } } diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 68abcca..efc02b7 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -469,6 +469,46 @@ fn focused_floating_resize_grip_accepts_both_arrow_axes_and_releases_tab() { assert_ne!(ctx.memory(|memory| memory.focused()), Some(id)); } +#[test] +fn stack_dividers_resize_adjacent_open_bodies_by_pointer_and_keyboard() { + let ctx = Context::default(); + let mut layout = Layout { + root: Some(Node::Stack { + entries: vec![ + StackEntry { panel: 1, open: true, height: Some(150.0) }, + StackEntry { panel: 2, open: false, height: Some(91.0) }, + StackEntry { panel: 3, open: true, height: Some(150.0) }, + StackEntry { panel: 4, open: true, height: Some(120.0) }, + ], + }), + floating: Vec::new(), + }; + for _ in 0..3 { + step(&ctx, &mut layout, Vec::new()); + } + let id = Id::new("test-area").with(("stack-resize", &1u32)); + let start = ctx.read_response(id).unwrap().rect.center(); + step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); + step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(0.0, 24.0))]); + step(&ctx, &mut layout, vec![pointer_button(start + vec2(0.0, 24.0), false)]); + let Node::Stack { entries } = layout.root.as_ref().unwrap() else { panic!() }; + let after_pointer = entries[0].height.unwrap(); + assert!(after_pointer > 150.0); + assert!((entries[0].height.unwrap() + entries[2].height.unwrap() - 300.0).abs() < 0.001); + assert_eq!(entries[1].height, Some(91.0)); + assert!(!entries[1].open); + assert_eq!(entries[3].height, Some(120.0)); + ctx.memory_mut(|memory| memory.request_focus(id)); + step( + &ctx, + &mut layout, + vec![Event::Key { key: egui::Key::ArrowDown, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], + ); + let Node::Stack { entries } = layout.root.as_ref().unwrap() else { panic!() }; + assert!(entries[0].height.unwrap() > after_pointer); + assert!((entries[0].height.unwrap() + entries[2].height.unwrap() - 300.0).abs() < 0.001); +} + #[test] fn restoring_a_panel_beside_a_nested_subtree_preserves_the_complete_workspace() { let mut layout = Layout { diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 59a1f03..b8d8294 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -57,7 +57,7 @@ accepts tabs or splits. Supply targets back-to-front, including floating-window The frontmost containing group blocks targets underneath even when it rejects a drop. The optional `DockArea` renderer handles tab fitting and overflow, drag/drop previews, -Escape/focus-loss cancellation, split dividers and floating-group resize grips. It renders a +Escape/focus-loss cancellation, split/accordion dividers and floating-group resize grips. It renders a snapshot and returns `DockOutput.actions`; apply them through the application's command path. With `serde` enabled, `Action` also serializes so pointer, keyboard and control-channel requests can share that command path, including splitter changes, tab reorder and floating bounds.