From e3ee342a4addab0c92e0715dee694a605987b120 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 12:12:16 +0000 Subject: [PATCH 1/2] feat(ui): drag a dialog by its title to move it --- ROADMAP.md | 1 + crates/ui-egui/src/about.rs | 12 +++++- crates/ui-egui/src/dialogs.rs | 71 +++++++++++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/modal_drag.rs | 57 +++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/modal_drag.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..f626b2f28 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +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-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/about.rs b/crates/ui-egui/src/about.rs index 4eae2e7c6..d0323a999 100644 --- a/crates/ui-egui/src/about.rs +++ b/crates/ui-egui/src/about.rs @@ -106,8 +106,18 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } let t = Tokens::get(ctx); let mut open: Option<&'static str> = None; - let resp = egui::Modal::new(egui::Id::new("about")).show(ctx, |ui| { + let area = egui::Id::new("about"); + let instance = area.with("placement"); + let resp = crate::modal_drag::modal(ctx, area, instance).show(ctx, |ui| { ui.set_width(if app.ui.about_tab == 0 { 460.0 } else { 640.0 }); + // The tab row is the title row. The tabs register after the drag area, so they stay clickable. + let margin = egui::Frame::popup(ui.style()).inner_margin; + let top = ui.cursor().top(); + let title_row = Rect::from_min_max( + pos2(ui.max_rect().left() - margin.leftf(), top - margin.topf()), + pos2(ui.max_rect().right() + margin.rightf(), top + ui.spacing().interact_size.y), + ); + crate::modal_drag::title_drag(ui, instance, title_row); ui.horizontal(|ui| { for (i, l) in ABOUT_TABS.iter().enumerate() { let i = i as u8; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 695a66af1..c24c2ba0f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1155,7 +1155,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { None => crate::i18n::tr(&app.ui.language, "Dialog"), }, }; - egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| { + let host = egui::Id::new("dialog"); + let instance = host.with(&d.id); + crate::modal_drag::modal(ctx, host, instance).show(ctx, |ui| { let frame_margin = egui::Frame::popup(ui.style()).total_margin().sum(); let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); let preferred_width: f32 = match d.id.as_str() { @@ -1167,6 +1169,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let width = preferred_width.min(available.x); ui.set_min_width(380.0_f32.min(width)); ui.set_max_width(width); + let title_top = ui.cursor().top(); if crate::i18n::is_rtl(&app.ui.language) { ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -1176,6 +1179,12 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } else { crate::rtl::label(ui, egui::RichText::new(title).font(semibold(16.0))); } + let margin = egui::Frame::popup(ui.style()).inner_margin; + let title_row = egui::Rect::from_min_max( + egui::pos2(ui.max_rect().left() - margin.leftf(), title_top - margin.topf()), + egui::pos2(ui.max_rect().right() + margin.rightf(), ui.min_rect().bottom()), + ); + crate::modal_drag::title_drag(ui, instance, title_row); ui.add_space(10.0); // Keep the title and action buttons outside the scrollable body. Oversized // forms remain reachable at large UI scales, without shrinking their text. @@ -4776,4 +4785,64 @@ mod tests { assert_eq!(crate::menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+K")); assert_eq!(d.s("recording"), "", "recording ends with the recorded shortcut"); } + + #[test] + fn dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() { + use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; + use egui_kittest::{Harness, kittest::Queryable}; + fn drag(h: &mut Harness<'static, DesignApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.run_steps(1); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for step in 1..=8 { + h.event(Event::PointerMoved(from + (to - from) * (step as f32 / 8.0))); + h.run_steps(1); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + } + let rect = |h: &Harness<'static, DesignApp>| h.ctx.memory(|m| m.area_rect(egui::Id::new("dialog"))).unwrap(); + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.ui.dialog = Some(Dialog::new("insertTable", Value::Null)); + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), &app.ui.language); + crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::default())); + ready = true; + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + let centred = rect(&h); + assert!((centred.center() - egui::pos2(600.0, 400.0)).length() < 1.0, "{centred:?}"); + assert!(centred.width() < 1000.0 && centred.height() < 700.0, "the area is the dialog, not the backdrop: {centred:?}"); + + let title = h.get_by_label("Create Table").rect().center(); + drag(&mut h, title, title + vec2(100.0, 60.0)); + let moved = rect(&h); + assert!((moved.min - centred.min - vec2(100.0, 60.0)).length() < 2.0, "{centred:?} -> {moved:?}"); + + let body = egui::pos2(moved.right() - 40.0, moved.center().y); + 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. + 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)); + + // Opened again, the dialog starts centred. + h.state_mut().ui.dialog = None; + h.run_steps(2); + 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)); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e634..d53d2c490 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod dock; pub mod i18n; pub mod icons; pub mod menus; +mod modal_drag; mod panel_docking; pub mod panels; mod prefs; diff --git a/crates/ui-egui/src/modal_drag.rs b/crates/ui-egui/src/modal_drag.rs new file mode 100644 index 000000000..2828a58a8 --- /dev/null +++ b/crates/ui-egui/src/modal_drag.rs @@ -0,0 +1,57 @@ +//! 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. + +use egui::{Align2, Context, CursorIcon, Id, Modal, Rect, Sense, Ui, Vec2, vec2}; + +#[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, +} + +/// A modal centred in the window and shifted by the user's drag. +/// +/// `area` is the modal's area id. `instance` names what it shows (for example the dialog kind), +/// so a different dialog in the same host starts centred. +pub(crate) fn modal(ctx: &Context, area: Id, instance: Id) -> Modal { + let pass = ctx.cumulative_pass_nr(); + let size = ctx.memory(|m| m.area_rect(area)).map(|r| r.size()); + 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(); + } + p.pass = pass; + if let Some(size) = size { + p.offset = clamp(p.offset, size, content); + } + p.offset + }); + Modal::new(area).area(Modal::default_area(area).anchor(Align2::CENTER_CENTER, offset)) +} + +/// Makes `rect`, the modal's title row, drag the modal opened with [`modal`] for `instance`. +/// +/// 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()); + 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)) +} From c9a09989c8cccb8f23be576892bbeec6ec52428b Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 12:28:23 +0000 Subject: [PATCH 2/2] feat(ui): a dragged dialog may go partly off-screen --- ROADMAP.md | 2 +- crates/ui-egui/src/dialogs.rs | 9 ++++-- crates/ui-egui/src/modal_drag.rs | 51 +++++++++++++++++++++++--------- 3 files changed, 45 insertions(+), 17 deletions(-) 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)) }