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..986ec60ae 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -124,6 +124,48 @@ 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) + }); + // 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(at..at, 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..097da2999 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,53 @@ 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 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(); + 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();