This commit is contained in:
Brandon Thomas
2026-10-14 22:05:11 -04:00
5 changed files with 168 additions and 2 deletions
+1
View File
@@ -163,6 +163,7 @@ Newest first.
- **2026-10-13** · Layout ▸ Table of Contents opens a real dialog: title and title style, Include Paragraph Styles and Other Styles lists with Add and Remove, and per style its entry style, page number position (after, before, none), separator and level. Replace Existing regenerates the current table of contents in place; otherwise OK makes a new frame on the page in view. IDML export writes the definition as a TOC style that import reads back, so Update works after a round trip. No readiness percentage change.
- **2026-10-13** · Control-channel clicks and drags validate positive counts and the complete pending-event budget before queuing input (#732). Oversized requests leave pending gestures intact, preventing unbounded click-count allocation. No readiness percentage change.
- **2026-10-13** · Backspace between two consecutive tables, pasting a table next to a table anchor, and pasting several paragraphs right before one keep every table on its own anchor paragraph. Previously a table was dropped or left without its anchor, and the saved file failed to reopen. 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** · Text strokes can sit behind the fill: the stroke lies wholly outside the letters at its full weight on screen, in PNG and in PDF, set from the Properties panel or Character Style Options. IDML `StrokeAlignment` on text imports and exports as this setting, so imported text with outside-aligned strokes keeps its letter shapes (#619). No readiness percentage change.
- **2026-10-12** · Placed Word documents keep their bullets and numbering (#626): `numbering.xml` levels become bullet or numbered paragraphs with Word's number formats, label text, start numbers, restarts and list indents, also when a paragraph style carries the list. 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
@@ -1165,7 +1165,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() {
@@ -1177,6 +1179,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| {
@@ -1186,6 +1189,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.
@@ -5963,4 +5972,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
@@ -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;
+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))
}