From f9fc20b3b9ff1c7f420bcba7047a0e0cf15aa0ca Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 20:39:12 -0700 Subject: [PATCH 1/2] Part of #4326: a drag whose mouse-up was lost ends once macOS reports no button down egui ends a drag only on a button release or Escape. When the window never receives the mouse-up, a drag such as a dock column's resize keeps following the pointer with no button held. On macOS, each frame's input now gets a release for every button egui still holds when NSEvent.pressedMouseButtons reports no button down, at the pointer's last position. Frames that carry a button event, and control-channel input that holds a button between steps, are left as they are. --- apps/vectorcraft/src/app.rs | 4 ++ crates/ui-egui/src/app/input.rs | 32 ++++++++++++++ crates/ui-egui/src/panel_docking/tests.rs | 51 +++++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index c093a1bac..3161d1a37 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -46,6 +46,10 @@ impl eframe::App for App { #[cfg(windows)] windows_stylus::raw_input_hook(raw); file_access::unconfined(|| self.app.raw_input_hook(ctx, raw)); + // A mouse-up that never reached the window (#4326) is delivered once AppKit reports + // that no mouse button is down. + #[cfg(target_os = "macos")] + self.app.release_lost_buttons(ctx, raw, objc2_app_kit::NSEvent::pressedMouseButtons() != 0); } #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index f8b42f406..9fd967ccd 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -124,6 +124,38 @@ impl VectorcraftApp { self.synthetic_frame = false; } + /// Release the pointer buttons egui still holds down when the system reports that no mouse + /// button is down (`system_held` is false), at the pointer's last position. egui ends a drag + /// only on a button release or Escape, so a release the window never received leaves a drag, + /// such as a dock column's resize, following the pointer (#4326). Frames that carry a button + /// event, and control-channel input that holds a button between steps, are left alone. + pub fn release_lost_buttons(&self, ctx: &egui::Context, raw: &mut egui::RawInput, system_held: bool) { + if system_held + || self.synthetic_injected + || !self.synthetic.is_empty() + || raw.events.iter().any(|e| matches!(e, egui::Event::PointerButton { .. })) + { + return; + } + let (down, pos, modifiers) = ctx.input_for(raw.viewport_id, |i| { + let p = &i.pointer; + let down: Vec = [ + egui::PointerButton::Primary, + egui::PointerButton::Secondary, + egui::PointerButton::Middle, + egui::PointerButton::Extra1, + egui::PointerButton::Extra2, + ] + .into_iter() + .filter(|b| p.button_down(*b)) + .collect(); + (down, p.latest_pos().or_else(|| p.interact_pos()).or_else(|| p.press_origin()), i.modifiers) + }); + let Some(pos) = pos else { return }; + let releases = down.into_iter().map(|button| egui::Event::PointerButton { pos, button, pressed: false, modifiers }); + raw.events.splice(0..0, releases); + } + /// Inject synthetic events (one press/release step, wheel turn or short key sequence per host /// iteration), carrying control provenance until a settled UI pass has taken the batch. The /// batch belongs to the one root viewport the shipped hosts draw (see diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index f83efb68d..eb0b10bd3 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -28,6 +28,19 @@ impl Harness { fn frame(&mut self, events: Vec) { self.time += 0.05; let input = egui::RawInput { time: Some(self.time), events, screen_rect: Some(SCREEN), ..Default::default() }; + self.run(input); + } + + /// A frame whose input first goes through [`VectorcraftApp::release_lost_buttons`], with the + /// system reporting a mouse button down or not. + fn frame_with_system_buttons(&mut self, events: Vec, system_held: bool) { + self.time += 0.05; + let mut input = egui::RawInput { time: Some(self.time), events, screen_rect: Some(SCREEN), ..Default::default() }; + self.app.release_lost_buttons(&self.ctx, &mut input, system_held); + self.run(input); + } + + fn run(&mut self, input: egui::RawInput) { let app = &mut self.app; let mut out = self.ctx.run_ui(input, |ui| { app.logic(ui.ctx()); @@ -398,6 +411,44 @@ fn ui_docking_reports_the_layout_and_hit_rects() { } } +/// Press on the main column's divider and drag it 60 pt toward the canvas, holding the button. +fn press_on_the_main_column_divider(h: &mut Harness) -> f32 { + let edge = + hit_rects(&h.ctx).into_iter().filter(|(hit, _)| matches!(hit, Hit::Divider(_))).map(|(_, r)| r.center().x).fold(f32::NEG_INFINITY, f32::max); + h.press_and_move(pos2(edge, 600.0), pos2(edge - 60.0, 600.0)); + dock_columns(&h.layout())[0].width +} + +#[test] +fn a_column_resize_whose_release_was_lost_ends_when_the_system_reports_no_button_down() { + let mut h = Harness::new(); + let width = press_on_the_main_column_divider(&mut h); + for x in [900.0, 1000.0, 850.0, 1100.0] { + h.frame_with_system_buttons(vec![Event::PointerMoved(pos2(x, 600.0))], false); + assert_eq!(dock_columns(&h.layout())[0].width, width, "the column followed the pointer to {x}"); + } + assert!(!h.ctx.input(|i| i.pointer.any_down())); +} + +#[test] +fn a_column_resize_continues_while_the_system_reports_the_button_down() { + let mut h = Harness::new(); + let width = press_on_the_main_column_divider(&mut h); + h.frame_with_system_buttons(vec![Event::PointerMoved(pos2(900.0, 600.0))], true); + assert_ne!(dock_columns(&h.layout())[0].width, width); + assert!(h.ctx.input(|i| i.pointer.primary_down())); +} + +#[test] +fn control_channel_drags_keep_their_button_between_steps() { + let mut h = Harness::new(); + h.frame(vec![Event::PointerMoved(pos2(800.0, 600.0)), button(pos2(800.0, 600.0), true)]); + h.app.synthetic.push(button(pos2(820.0, 600.0), false)); + let mut input = egui::RawInput { events: vec![Event::PointerMoved(pos2(810.0, 600.0))], ..Default::default() }; + h.app.release_lost_buttons(&h.ctx, &mut input, false); + assert_eq!(input.events, [Event::PointerMoved(pos2(810.0, 600.0))]); +} + #[test] fn window_menu_panels_without_a_place_open_in_their_icon_group_or_float() { let mut h = Harness::new(); From fa75b6f53dfa329af6d04882b8b2798b153e8060 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 22:11:10 -0700 Subject: [PATCH 2/2] Part of #4326: a lost mouse-up after the pointer moved does not click at the press The released buttons go after the frame's last pointer movement, at its position. Before, they went first, at the press position, so a press that moved past the click distance in the same frame ended as a click on the widget under the press. --- crates/ui-egui/src/app/input.rs | 12 +++++++++++- crates/ui-egui/src/panel_docking/tests.rs | 9 +++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index 9fd967ccd..986ec60ae 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -151,9 +151,19 @@ impl VectorcraftApp { .collect(); (down, p.latest_pos().or_else(|| p.interact_pos()).or_else(|| p.press_origin()), i.modifiers) }); + // The releases go after this frame's last pointer movement, at its position, so a pointer + // that moved away from the press does not end it with a click at the press position. + let moved = raw.events.iter().enumerate().rev().find_map(|(i, e)| match e { + egui::Event::PointerMoved(p) => Some((i + 1, *p)), + _ => None, + }); + let (at, pos) = match moved { + Some((at, p)) => (at, Some(p)), + None => (0, pos), + }; let Some(pos) = pos else { return }; let releases = down.into_iter().map(|button| egui::Event::PointerButton { pos, button, pressed: false, modifiers }); - raw.events.splice(0..0, releases); + raw.events.splice(at..at, releases); } /// Inject synthetic events (one press/release step, wheel turn or short key sequence per host diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index eb0b10bd3..097da2999 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -439,6 +439,15 @@ fn a_column_resize_continues_while_the_system_reports_the_button_down() { assert!(h.ctx.input(|i| i.pointer.primary_down())); } +#[test] +fn a_lost_release_after_the_pointer_moved_away_is_not_a_click() { + let mut h = Harness::new(); + h.frame(vec![Event::PointerMoved(pos2(800.0, 600.0)), button(pos2(800.0, 600.0), true)]); + h.frame_with_system_buttons(vec![Event::PointerMoved(pos2(1000.0, 600.0))], false); + assert!(!h.ctx.input(|i| i.pointer.any_down())); + assert!(!h.ctx.input(|i| i.pointer.any_click()), "the release at the press position made a click"); +} + #[test] fn control_channel_drags_keep_their_button_between_steps() { let mut h = Harness::new();