diff --git a/ROADMAP.md b/ROADMAP.md index 69bc2f78a..b2a52dd7e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/ui-egui/src/about.rs b/crates/ui-egui/src/about.rs index e91899e93..14745607a 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 010157e8f..5e273f13f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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)); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4328210d3..76c4dba00 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/modal_drag.rs b/crates/ui-egui/src/modal_drag.rs new file mode 100644 index 000000000..aabe103d2 --- /dev/null +++ b/crates/ui-egui/src/modal_drag.rs @@ -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::(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::(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)) +}