Merge movable-dialogs: drag a dialog by its title to move it

This commit is contained in:
aiongg
2026-10-13 12:38:09 +00:00
5 changed files with 168 additions and 2 deletions
+1
View File
@@ -128,6 +128,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 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** · Placed Excel tables and Excel data-merge records keep numeric precision, including small values, and preserve numeric-looking cached formula strings as text. No readiness percentage change.
- **2026-10-13** · Links rows show their source paths; selecting a link shows its full path wrapped inside the panel, so same-named files from different folders can be distinguished. No readiness percentage change.
- **2026-10-13** · HTML and reflowable EPUB order grouped stories and graphics by their transformed spread position, including nested groups and rotated frames. Explicit Article order is unchanged. No readiness percentage change.
+11 -1
View File
@@ -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;
+75 -1
View File
@@ -1530,7 +1530,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
},
};
// No dimmed backdrop: the page behind a dialog is what its settings and colours are judged against.
egui::Modal::new(egui::Id::new("dialog")).backdrop_color(egui::Color32::TRANSPARENT).show(ctx, |ui| {
let host = egui::Id::new("dialog");
let instance = host.with(&d.id);
crate::modal_drag::modal(ctx, host, instance).backdrop_color(egui::Color32::TRANSPARENT).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() {
@@ -1544,6 +1546,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| {
@@ -1553,6 +1556,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.
@@ -7365,4 +7374,69 @@ 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 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().abs() < 1.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));
// 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
View File
@@ -18,6 +18,7 @@ pub mod effects_dialog;
pub mod i18n;
pub mod icons;
pub mod menus;
mod modal_drag;
mod new_document;
mod object_control;
mod panel_docking;
+80
View File
@@ -0,0 +1,80 @@
//! 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, 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.
///
/// `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::<Placement>(instance);
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 != Vec2::ZERO
{
p.offset = clamp(p.offset, size, p.title_height, content);
}
p.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`.
///
/// 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() {
ui.ctx().set_cursor_icon(CursorIcon::Grabbing);
} else if resp.hovered() {
ui.ctx().set_cursor_icon(CursorIcon::Grab);
}
}
/// 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))
}