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.
This commit is contained in:
dotCooCoo
2026-10-29 10:02:50 +07:00
committed by bflatastic
parent 914a109dc9
commit f9fc20b3b9
3 changed files with 87 additions and 0 deletions
+4
View File
@@ -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) {
+32
View File
@@ -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> = [
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
+51
View File
@@ -28,6 +28,19 @@ impl Harness {
fn frame(&mut self, events: Vec<Event>) {
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<Event>, 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();