UI.8: cancel renderer-owned resize gestures on workspace replacement

This commit is contained in:
skylar taylor-barrick
2026-10-10 11:48:50 -06:00
parent 34b4adfcfb
commit b1406e3e6a
2 changed files with 148 additions and 3 deletions
+37 -3
View File
@@ -133,13 +133,22 @@ pub fn begin_drag<P: Clone + Send + Sync + 'static>(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<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id) {
ctx.data_mut(|d| {
let dragged = ctx.dragged_id();
let owned = ctx.data_mut(|d| {
d.remove::<Drag<P>>(area_id.with("drag"));
d.remove::<GroupDrag<P>>(area_id.with("group-drag"));
let ids = d.remove_temp::<Vec<Id>>(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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
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<Rect>,
layout: &'a Layout<P>,
targets: Vec<DropTarget<P>>,
owned_interactions: Vec<Id>,
output: DockOutput<P>,
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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::<Vec<Id>>(self.id.with("owned-interactions"));
if ids.len() < CAP && !ids.contains(&id) {
ids.push(id);
}
});
}
fn node(&mut self, ui: &mut Ui, node: &Node<P>, rect: Rect, path: &mut Vec<bool>) {
if !super::targets::usable(rect) {
return;
@@ -356,6 +385,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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();
+111
View File
@@ -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::<bool>::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::<u32>(&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<Event>| {
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::<bool>::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::<u32>(&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());
}
}
}