Fix window dragging with modal dialogs (#4913)

This commit is contained in:
imSlicedBread 2026-10-13 09:11:50 -07:00 committed by GitHub
parent f43d019a72
commit a0627311ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 79 additions and 1 deletions

View File

@ -14,6 +14,11 @@ fn rects(ctx: &egui::Context) -> Vec<egui::Rect> {
ctx.data(|m| m.get_temp(egui::Id::new(RECTS))).unwrap_or_default()
}
/// The window drag target must not cover a dialog which has been moved into the title bar.
pub(crate) fn titlebar_drag_area_is_clear(ctx: &egui::Context, area: egui::Rect) -> bool {
rects(ctx).iter().all(|dialog| !dialog.intersects(area))
}
/// With a dialog open the rest of the window is inert (egui's modal layer), but like Photoshop the
/// image can still be panned and zoomed under it. The pointer position when it is over `canvas`
/// and not over a dialog or one of its popups.

View File

@ -1840,6 +1840,9 @@ impl eframe::App for PhotocraftApp {
notices::show(self, &ctx);
gpu_status::show_fallback(self, &ctx);
kys_import::show_offer(self, &ctx);
if chrome && self.ui.panels.menu_bar && self.custom_titlebar && !self.ui.dialogs.is_empty() {
titlebar::modal_drag_overlay(&ctx);
}
if self.custom_titlebar {
titlebar::resize_zones(ui);
}

View File

@ -587,6 +587,7 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let span_id = ui.id().with("titledrag-span");
let (gap_l, gap_r) = ui.ctx().data(|d| d.get_temp::<(f32, f32)>(span_id)).unwrap_or((full.right(), full.right()));
let gap = egui::Rect::from_x_y_ranges(gap_l.max(full.left())..=gap_r.min(full.right()).max(gap_l), full.y_range());
ui.ctx().data_mut(|d| d.insert_temp(crate::titlebar::modal_drag_area_id(), gap));
let drag = ui.interact(gap, ui.id().with("titledrag"), Sense::click_and_drag());
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);

View File

@ -22,6 +22,7 @@ pub const BUTTON_WIDTH: f32 = 46.0;
/// (#1581); 8 pt is about a native resize border, and only trims the toolbar buttons' outer 3 pt.
const EDGE: f32 = 8.0;
const CORNER: f32 = 12.0;
const MODAL_DRAG_AREA: &str = "titlebar-modal-drag-area";
/// Breeze's Close button on hover and press (KDE's Breeze decoration colours).
const BREEZE_CLOSE_HOVER: Color32 = Color32::from_rgb(0xff, 0x98, 0xa2);
const BREEZE_CLOSE_PRESSED: Color32 = Color32::from_rgb(0x6d, 0x22, 0x29);
@ -50,6 +51,32 @@ pub enum CaptionFamily {
Generic,
}
/// The title bar's unused gap, saved by `panels::title_bar` for drags that begin while a modal
/// backdrop is on top of the normal title-bar widgets.
pub(crate) fn modal_drag_area_id() -> Id {
Id::new(MODAL_DRAG_AREA)
}
/// Restore window dragging over the title bar's empty gap after a modal has blocked the normal
/// title-bar layer. The hit target stays limited to the same gap, so menus and caption controls
/// remain owned by their widgets.
pub(crate) fn modal_drag_overlay(ctx: &egui::Context) {
let Some(area) = ctx.data(|d| d.get_temp::<Rect>(modal_drag_area_id())) else { return };
if !area.is_positive() || egui::Popup::is_any_open(ctx) || !crate::dialogs::titlebar_drag_area_is_clear(ctx, area) {
return;
}
egui::Area::new(Id::new("titlebar-modal-drag-overlay")).order(Order::Tooltip).fixed_pos(area.min).movable(false).sense(Sense::hover()).show(ctx, |ui| {
let response = ui.allocate_rect(area, Sense::click_and_drag());
if response.drag_started() {
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
}
if response.double_clicked() {
let max = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
ctx.send_viewport_cmd(ViewportCommand::Maximized(!max));
}
});
}
impl CaptionFamily {
/// `windows`, `adwaita`, `breeze` or `generic`, in any case.
pub fn parse(name: &str) -> Option<Self> {
@ -530,6 +557,10 @@ mod tests {
}
fn frame(&mut self, events: Vec<Event>) -> egui::FullOutput {
self.frame_with_modal(events, false)
}
fn frame_with_modal(&mut self, events: Vec<Event>, modal: bool) -> egui::FullOutput {
self.time += 0.05;
let mut input =
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 600.0))), time: Some(self.time), events, ..Default::default() };
@ -541,6 +572,12 @@ mod tests {
let app = &mut self.app;
let mut out = self.ctx.run_ui(input, |ui| {
crate::panels::title_bar(app, ui);
if modal {
egui::Modal::new(Id::new("titlebar-drag-test-modal")).backdrop_color(Color32::TRANSPARENT).show(ui.ctx(), |ui| {
ui.label("Modal dialog");
});
modal_drag_overlay(ui.ctx());
}
resize_zones(ui);
});
out.textures_delta.clear();
@ -550,10 +587,17 @@ mod tests {
/// Frames for a pointer gesture: move to `path[0]`, press, move along `path`, release. The
/// viewport commands the frames sent.
fn gesture(&mut self, path: &[Pos2]) -> Vec<ViewportCommand> {
self.gesture_with_modal(path, false)
}
fn gesture_with_modal(&mut self, path: &[Pos2], modal: bool) -> Vec<ViewportCommand> {
let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true)]];
events.extend(path[1..].iter().map(|p| vec![Event::PointerMoved(*p)]));
events.push(vec![button(*path.last().unwrap_or(&path[0]), false)]);
events.into_iter().flat_map(|e| self.frame(e).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()).collect()
events
.into_iter()
.flat_map(|e| self.frame_with_modal(e, modal).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default())
.collect()
}
fn click(&mut self, p: Pos2) -> Vec<ViewportCommand> {
@ -675,6 +719,31 @@ mod tests {
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::StartDrag)), "{cmds:?}");
}
#[test]
fn empty_bar_space_still_drags_with_a_modal_open() {
let mut w = Win::new(1440.0);
w.frame(vec![]);
w.frame_with_modal(vec![], true);
let empty = pos2(980.0, 16.0);
let drag_area = w.ctx.data(|d| d.get_temp::<Rect>(modal_drag_area_id()));
assert!(drag_area.is_some_and(|r| r.contains(empty)), "the test point must be in the current title-bar gap: {drag_area:?}");
assert!(w.bar_widgets().iter().all(|r| !r.contains(empty)), "the probe point must be empty bar space");
let cmds = w.gesture_with_modal(&[empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)], true);
assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}");
}
#[test]
fn modal_drag_overlay_does_not_cover_a_dialog_moved_into_the_gap() {
let mut w = Win::new(1440.0);
w.frame(vec![]);
w.frame_with_modal(vec![], true);
let area = w.ctx.data(|d| d.get_temp::<Rect>(modal_drag_area_id())).unwrap_or(Rect::NOTHING);
assert!(area.contains(pos2(980.0, 16.0)), "the probe point must be in the gap: {area:?}");
w.ctx.data_mut(|d| d.insert_temp(Id::new("pc-dialog-rects"), vec![area]));
let cmds = w.gesture_with_modal(&[area.center(), area.center() + vec2(12.0, 4.0)], true);
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::StartDrag)), "{cmds:?}");
}
/// #1581: KDE Wayland keeps the button release of an OS resize. egui then kept the edge
/// pressed (no hover until the next click) and egui-winit never re-sent the resize cursor that
/// the compositor had replaced: the cursor came back only on every other press.