diff --git a/ROADMAP.md b/ROADMAP.md index f3515c2eb1..9fe97284ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -199,6 +199,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. - **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. diff --git a/crates/ui-egui/src/window_controls.rs b/crates/ui-egui/src/window_controls.rs index d02ea5dc8c..70df814222 100644 --- a/crates/ui-egui/src/window_controls.rs +++ b/crates/ui-egui/src/window_controls.rs @@ -248,20 +248,41 @@ pub fn desktop_grab(ctx: &egui::Context, command: egui::ViewportCommand) { /// After `desktop_grab`, add the primary button's release to the next input, unless it brings one. /// The release goes before the input's first press (the next resize or move, or a click), which -/// egui would otherwise end straight away; a release after that press is the press's own. +/// egui would otherwise end straight away; a release after that press is the press's own. It also +/// goes before the pointer leaving, as a button event would bring the gone pointer back; a release +/// the input brings after the pointer left is the grab's own, and moves there instead. pub fn release_after_desktop_grab(ctx: &egui::Context, raw: &mut egui::RawInput) { if ctx.data_mut(|d| d.remove_temp::<()>(grab_id())).is_none() { return; } - let first = raw.events.iter().enumerate().find_map(|(n, e)| match e { - egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => Some((n, *pressed)), - _ => None, - }); - let at = match first { - Some((_, false)) => return, - Some((n, true)) => n, - None => raw.events.len(), - }; + let mut at = raw.events.len(); + let mut gone = None; + let mut brought = None; + for (n, e) in raw.events.iter().enumerate() { + match e { + egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, .. } => { + brought = Some(n); + break; + } + egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: true, .. } => { + at = n; + break; + } + egui::Event::PointerGone => { + gone.get_or_insert(n); + } + _ => {} + } + } + if let Some(n) = brought { + // `gone` was found before `n`, so both are in range. + if let Some(g) = gone { + let release = raw.events.remove(n); + raw.events.insert(g, release); + } + return; + } + let at = gone.unwrap_or(at); let (pos, modifiers) = ctx.input(|i| (i.pointer.interact_pos().unwrap_or_default(), i.modifiers)); raw.events.insert(at, egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers }); } @@ -389,4 +410,52 @@ mod tests { frame(vec![], Some(egui::ViewportCommand::StartDrag)); assert_eq!(frame(vec![button(true), button(false)], None), [false, true, false]); } + + #[test] + fn the_release_goes_before_the_pointer_leaving() { + let ctx = egui::Context::default(); + let frame = |events: Vec, grab: bool| { + let mut raw = egui::RawInput { events, ..Default::default() }; + super::release_after_desktop_grab(&ctx, &mut raw); + let order = raw.events.clone(); + ctx.run_ui(raw, |ui| { + if grab { + super::desktop_grab(ui.ctx(), egui::ViewportCommand::StartDrag); + } + }) + .textures_delta + .clear(); + order + }; + let release = |e: &egui::Event| matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, .. }); + let press = egui::Event::PointerButton { + pos: Pos2::new(5.0, 5.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }; + // On Wayland the input after a move brings only that the pointer left. + frame(vec![egui::Event::PointerMoved(Pos2::new(5.0, 5.0)), press], true); + let order = frame(vec![egui::Event::PointerGone], false); + assert!(matches!(order.as_slice(), [r, egui::Event::PointerGone] if release(r)), "{order:?}"); + assert_eq!(ctx.input(|i| i.pointer.latest_pos()), None, "the pointer stays gone"); + assert!(!ctx.input(|i| i.pointer.primary_down())); + + // A release after the pointer left is the one the desktop kept, not to be doubled. + frame(vec![], true); + let order = frame( + vec![ + egui::Event::PointerGone, + egui::Event::PointerButton { + pos: Pos2::new(5.0, 5.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }, + ], + false, + ); + assert!(matches!(order.as_slice(), [r, egui::Event::PointerGone] if release(r)), "moved, not doubled: {order:?}"); + assert_eq!(ctx.input(|i| i.pointer.latest_pos()), None, "the pointer stays gone"); + } } diff --git a/docs/roadmap.md b/docs/roadmap.md index 02ee7b2125..832fabf54f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -252,6 +252,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. | | 2026-10-23 | minor | M10: covered table slots retain their text in paragraph flow across Office exporters; emitted superscripts remain in their cells. Synthetic source and CLI regressions; estimates unchanged. |