feat(ui): a dragged dialog may go partly off-screen

This commit is contained in:
aiongg
2026-10-13 12:28:23 +00:00
parent e3ee342a4a
commit c9a09989c8
3 changed files with 45 additions and 17 deletions
+1 -1
View File
@@ -129,7 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-13** · Dialogs and the About window move when you drag their title row, so the page behind them can be seen. A dragged dialog stays inside the window, and each dialog opens centred. No readiness percentage change.
- **2026-10-13** · Dialogs and the About window move when you drag their title row, so the page behind them can be seen. A dragged dialog may go partly off-screen, but its title row stays reachable; each dialog opens centred, and again after the window is resized. No readiness percentage change.
- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
+7 -2
View File
@@ -4833,10 +4833,10 @@ mod tests {
drag(&mut h, body, body + vec2(-150.0, 80.0));
assert_eq!(rect(&h), moved, "a drag in the body leaves the dialog in place");
// The dialog stays inside the window.
// The title row stays inside the top edge.
let title = h.get_by_label("Create Table").rect().center();
drag(&mut h, title, title - vec2(0.0, 1000.0));
assert!(rect(&h).top() >= 0.0, "{:?}", rect(&h));
assert!(rect(&h).top().abs() < 1.0, "{:?}", rect(&h));
// Opened again, the dialog starts centred.
h.state_mut().ui.dialog = None;
@@ -4844,5 +4844,10 @@ mod tests {
h.state_mut().ui.dialog = Some(Dialog::new("insertTable", Value::Null));
h.run_steps(4);
assert!((rect(&h).center() - centred.center()).length() < 1.0, "{:?}", rect(&h));
// Past the left edge, about 100 px of the title row stays inside.
let title = h.get_by_label("Create Table").rect().center();
drag(&mut h, title, title - vec2(900.0, 0.0));
assert!((rect(&h).right() - 100.0).abs() < 2.0, "{:?}", rect(&h));
}
}
+37 -14
View File
@@ -1,16 +1,24 @@
//! Modal windows (dialogs, About) that the user moves by dragging their title row.
//!
//! The modal stays anchored at the centre of the window and is shifted by an offset kept in
//! egui's temporary memory. A modal that was not shown in the previous pass starts centred.
//! egui keeps the whole modal inside the window, so its title row stays reachable.
//! egui's temporary memory. A modal that was not shown in the previous pass, or whose window
//! changed size, starts centred. An undragged modal keeps egui's constraint to the window; a
//! dragged one may go partly off-screen, but its title row stays reachable.
use egui::{Align2, Context, CursorIcon, Id, Modal, Rect, Sense, Ui, Vec2, vec2};
/// How much of the title row's width stays inside the window.
const KEEP_VISIBLE: f32 = 100.0;
#[derive(Clone, Copy, Default)]
struct Placement {
offset: Vec2,
/// The pass the modal was last shown in: a gap means it was closed and opened again.
pass: u64,
/// The window content size the offset belongs to.
content: Vec2,
/// From the top of the modal to the bottom of its title row.
title_height: f32,
}
/// A modal centred in the window and shifted by the user's drag.
@@ -23,16 +31,20 @@ pub(crate) fn modal(ctx: &Context, area: Id, instance: Id) -> Modal {
let content = ctx.content_rect();
let offset = ctx.data_mut(|d| {
let p = d.get_temp_mut_or_default::<Placement>(instance);
if p.pass.saturating_add(1) < pass {
*p = Placement::default();
if p.pass.saturating_add(1) < pass || p.content != content.size() {
*p = Placement { content: content.size(), ..Placement::default() };
}
p.pass = pass;
if let Some(size) = size {
p.offset = clamp(p.offset, size, content);
if let Some(size) = size
&& p.offset != Vec2::ZERO
{
p.offset = clamp(p.offset, size, p.title_height, content);
}
p.offset
});
Modal::new(area).area(Modal::default_area(area).anchor(Align2::CENTER_CENTER, offset))
// Dragged: `clamp` keeps the title row reachable instead of egui keeping the whole modal inside.
let constrained = offset == Vec2::ZERO;
Modal::new(area).area(Modal::default_area(area).anchor(Align2::CENTER_CENTER, offset).constrain(constrained))
}
/// Makes `rect`, the modal's title row, drag the modal opened with [`modal`] for `instance`.
@@ -40,18 +52,29 @@ pub(crate) fn modal(ctx: &Context, area: Id, instance: Id) -> Modal {
/// Widgets registered after this call sit on top of the drag area and keep their own input.
pub(crate) fn title_drag(ui: &Ui, instance: Id, rect: Rect) {
let resp = ui.interact(rect, instance.with("title_drag"), Sense::drag());
let delta = if resp.dragged() { resp.drag_delta() } else { Vec2::ZERO };
let top = ui.ctx().memory(|m| m.area_rect(ui.layer_id().id)).map_or(rect.top(), |r| r.top());
ui.ctx().data_mut(|d| {
let p = d.get_temp_mut_or_default::<Placement>(instance);
p.offset += delta;
p.title_height = (rect.bottom() - top).max(0.0);
});
if resp.dragged() {
let delta = resp.drag_delta();
ui.ctx().data_mut(|d| d.get_temp_mut_or_default::<Placement>(instance).offset += delta);
ui.ctx().set_cursor_icon(CursorIcon::Grabbing);
} else if resp.hovered() {
ui.ctx().set_cursor_icon(CursorIcon::Grab);
}
}
/// Limits `offset` to the range in which a modal of `size` fits inside `content`, the range egui
/// constrains it to, so dragging back from beyond an edge moves it at once.
fn clamp(offset: Vec2, size: Vec2, content: Rect) -> Vec2 {
let room = ((content.size() - size) * 0.5).max(Vec2::ZERO);
vec2(offset.x.min(room.x).max(-room.x), offset.y.min(room.y).max(-room.y))
/// Limits `offset` so the title row of a modal of `size` stays inside `content` vertically and
/// [`KEEP_VISIBLE`] of its width stays inside horizontally. A modal taller than the window keeps
/// its top edge inside.
fn clamp(offset: Vec2, size: Vec2, title_height: f32, content: Rect) -> Vec2 {
let centred = Align2::CENTER_CENTER.align_size_within_rect(size, content);
let keep = KEEP_VISIBLE.min(size.x);
let x_lo = content.left() + keep - size.x - centred.left();
let x_hi = content.right() - keep - centred.left();
let y_lo = content.top() - centred.top();
let y_hi = content.bottom() - title_height.min(size.y) - centred.top();
vec2(offset.x.min(x_hi).max(x_lo), offset.y.min(y_hi).max(y_lo))
}