diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 49b5bc30..efb933df 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -133,13 +133,22 @@ pub fn begin_drag(ctx: &Context, area_id: Id, true } -/// Discard all pending gestures before replacing a workspace or its layout. The host owns -/// the replacement snapshot, so this does not restore a group's previous rectangle. +/// Discard pending tab/group gestures and an actively dragged renderer-owned control before +/// replacing a workspace. Body/custom-header/menu gestures and other areas are untouched. +/// Ownership is recorded as controls render; before an initial render or drag threshold there +/// may be no active egui drag to cancel. Keyboard focus is unchanged. The host owns the +/// replacement snapshot, so this does not restore a group's previous rectangle. pub fn cancel_drag(ctx: &Context, area_id: Id) { - ctx.data_mut(|d| { + let dragged = ctx.dragged_id(); + let owned = ctx.data_mut(|d| { d.remove::>(area_id.with("drag")); d.remove::>(area_id.with("group-drag")); + let ids = d.remove_temp::>(area_id.with("owned-interactions")); + dragged.is_some_and(|id| ids.is_some_and(|ids| ids.contains(&id))) }); + if owned { + ctx.stop_dragging(); + } } /// One mutable application adapter owns bodies, menus and shaped text, avoiding competing @@ -301,6 +310,7 @@ impl DockArea

{ blockers: self.blockers, layout, targets: Vec::new(), + owned_interactions: Vec::new(), output: DockOutput::default(), }; if let Some(root) = &layout.root { @@ -310,6 +320,7 @@ impl DockArea

{ renderer.floating(ui.ctx()); renderer.finish_group_drag(ui.ctx()); renderer.finish_drag(ui.ctx()); + ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions)); renderer.output } } @@ -325,10 +336,28 @@ struct Renderer<'a, P> { blockers: Vec, layout: &'a Layout

, targets: Vec>, + owned_interactions: Vec, output: DockOutput

, } impl Renderer<'_, P> { + fn register_interaction(&mut self, ctx: &Context, id: Id) { + // A validated tree has at most 2*MAX_PANELS nodes. Tabs, stack dividers, + // floating grips/corners and splits fit comfortably inside this fixed bound. + const CAP: usize = 8 * super::MAX_PANELS; + if self.owned_interactions.len() < CAP && !self.owned_interactions.contains(&id) { + self.owned_interactions.push(id); + } + // Retain prior-frame controls until return, and publish current controls before + // app callbacks. No Context lock spans a callback or a stop_dragging call. + ctx.data_mut(|d| { + let ids = d.get_temp_mut_or_default::>(self.id.with("owned-interactions")); + if ids.len() < CAP && !ids.contains(&id) { + ids.push(id); + } + }); + } + fn node(&mut self, ui: &mut Ui, node: &Node

, rect: Rect, path: &mut Vec) { if !super::targets::usable(rect) { return; @@ -356,6 +385,7 @@ impl Renderer<'_, P> { ) .value(split.first_size(), lo..=hi) .show(ui); + self.register_interaction(ui.ctx(), response.response.id); if response.delta != 0.0 && let Some(size) = split.resized(*size, (split.first_size() + response.delta).clamp(lo, hi) - split.first_size(), 0.0) { @@ -437,6 +467,7 @@ impl Renderer<'_, P> { ) .value(first_size, lo..=hi) .show(ui); + self.register_interaction(ui.ctx(), response.response.id); 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)); @@ -529,6 +560,7 @@ impl Renderer<'_, P> { if floating { let grip = Rect::from_min_max(strip.min, pos2((strip.left() + 12.0).min(strip.right()), strip.bottom())); let response = ui.interact(grip, self.id.with(("move-group", panels.first())), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); + self.register_interaction(ui.ctx(), response.id); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label)); if panels.iter().all(|p| (self.permissions)(p).movable) && response.drag_started() @@ -648,6 +680,7 @@ impl Renderer<'_, P> { painter.rect_filled(rect, 0.0, if active || response.hovered() { self.style.active_background } else { self.style.tab_background }); painter.galley(rect.center() - galley.size() * 0.5, galley.clone(), color); }); + self.register_interaction(ui.ctx(), response.id); if response.clicked() { self.output.actions.push(Action::Activate { panel: panel.clone() }); self.output.focused = Some(panel.clone()); @@ -715,6 +748,7 @@ impl Renderer<'_, P> { self.group(ui, &group.panels, group.active, rect, true); let corner = Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max); let response = ui.interact(corner, id.with("resize"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeNwSe); + self.register_interaction(ui.ctx(), response.id); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.resize_window_label)); if response.drag_started() { response.request_focus(); diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 064bc2d4..9a069466 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -924,3 +924,114 @@ fn chrome_focus_classifies_rendered_accordion_and_split_and_floating_resizers() assert!(DockArea::new(area).chrome_has_focus(&ctx, &layout)); } } + +#[test] +fn workspace_cancel_stops_owned_resize_controls_until_new_press() { + let area = Id::new("test-area"); + let fixtures = [ + (sample(), area.with(("split", Vec::::new())), vec2(24.0, 0.0)), + ( + Layout { + root: Some(Node::Stack { + entries: vec![StackEntry { panel: 1, open: true, height: Some(150.0) }, StackEntry { panel: 2, open: true, height: Some(150.0) }], + }), + floating: vec![], + }, + area.with(("stack-resize", &1u32)), + vec2(0.0, 24.0), + ), + (sample(), area.with(("floating", &5u32)).with("resize"), vec2(24.0, 24.0)), + ]; + for (mut layout, id, delta) in fixtures { + let ctx = Context::default(); + for _ in 0..3 { + step(&ctx, &mut layout, vec![]); + } + let start = ctx.read_response(id).unwrap().rect.center(); + step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); + let actions = step(&ctx, &mut layout, vec![Event::PointerMoved(start + delta)]); + assert!(!actions.is_empty(), "gesture fixture must actually resize: {id:?}"); + assert_eq!(ctx.dragged_id(), Some(id)); + // Keep all identities present in a replacement snapshot. + let replacement = layout.clone(); + cancel_drag::(&ctx, area); + assert_eq!(ctx.dragged_id(), None); + for multiplier in [2.0, 3.0] { + let actions = step(&ctx, &mut layout, vec![Event::PointerMoved(start + delta * multiplier)]); + assert!(actions.is_empty(), "held old gesture produced {actions:?}"); + assert_eq!(layout, replacement); + } + step(&ctx, &mut layout, vec![pointer_button(start + delta * 3.0, false)]); + for _ in 0..2 { + step(&ctx, &mut layout, vec![]); + } + let start = ctx.read_response(id).unwrap().rect.center(); + step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); + assert!(!step(&ctx, &mut layout, vec![Event::PointerMoved(start + delta)]).is_empty()); + } +} + +#[test] +fn workspace_cancel_preserves_other_area_and_body_drag_ownership() { + for body_drag in [false, true] { + let ctx = Context::default(); + let area = Id::new("cancel-area"); + let other = Id::new("other-area"); + let layout = Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(180.0), + first: Box::new(tabs(&[1], 0)), + second: Box::new(tabs(&[2], 0)), + }), + floating: vec![], + }; + let body_id = Id::new("application-body-drag"); + let mut value = 0.0; + let mut run = |events: Vec| { + let mut outputs = Vec::new(); + ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, ..Default::default() }, |ui| { + let style = DockStyle::from_ui(ui); + for (id, x) in [(area, 0.0), (other, 400.0)] { + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(x, 0.0), vec2(390.0, 580.0)))); + outputs.push(DockArea::new(id).show( + &mut child, + &layout, + &style, + |p| format!("Panel {p}"), + |_| Permissions::default(), + |ui, p| { + if body_drag && id == area && *p == 1 { + let rect = Rect::from_min_size(ui.max_rect().min + vec2(10.0, 10.0), vec2(100.0, 40.0)); + let response = ui.interact(rect, body_id, egui::Sense::drag()); + if response.dragged() { + value += response.drag_delta().x; + } + } + }, + )); + } + }) + .textures_delta + .clear(); + outputs + }; + for _ in 0..3 { + run(vec![]); + } + let owned = if body_drag { body_id } else { other.with(("split", Vec::::new())) }; + let start = ctx.read_response(owned).unwrap().rect.center(); + run(vec![Event::PointerMoved(start), pointer_button(start, true)]); + run(vec![Event::PointerMoved(start + vec2(20.0, 0.0))]); + assert_eq!(ctx.dragged_id(), Some(owned)); + cancel_drag::(&ctx, area); + assert_eq!(ctx.dragged_id(), Some(owned)); + let outputs = run(vec![Event::PointerMoved(start + vec2(40.0, 0.0))]); + assert_eq!(ctx.dragged_id(), Some(owned)); + if body_drag { + assert!(value >= 40.0); + } else { + assert!(!outputs[1].actions.is_empty()); + } + } +}