mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 21:56:50 -06:00
feat(ui): a dragged dialog may go partly off-screen
This commit is contained in:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user