mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 03:06:56 -06:00
Merge movable-dialogs: drag a dialog by its title to move it
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user