diff --git a/ROADMAP.md b/ROADMAP.md index f626b2f28..9f5bc414c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c24c2ba0f..364e5323c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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)); } } diff --git a/crates/ui-egui/src/modal_drag.rs b/crates/ui-egui/src/modal_drag.rs index 2828a58a8..aabe103d2 100644 --- a/crates/ui-egui/src/modal_drag.rs +++ b/crates/ui-egui/src/modal_drag.rs @@ -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::(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::(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::(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)) }