From 677cafbf9c3454e037ac6fd58fd498b9cc00f060 Mon Sep 17 00:00:00 2001 From: Govinda Roy Date: Wed, 14 Oct 2026 00:26:02 +0600 Subject: [PATCH 01/32] Images: EPS shows through its proxy at display time; simple PostScript (QR codes) is drawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An EPS that came in with a document (InDesign embeds its QR codes as EPS) drew nothing: only File › Place converted EPS to a proxy. Now `decode_rgba` draws any EPS through `eps_proxy` and `natural_size` measures it by its bounding box in points, so screen, PNG and PDF show it while the document keeps the EPS itself. `eps_proxy` also draws PostScript that only fills rectangles in solid colours (setcmykcolor, setrgbcolor, setgray, rectfill and procedures that stand for them, as InDesign's QR codes are written), at 8 px per point so modules stay crisp. Anything else keeps the preview or placeholder. Input is bounded: tokens, rectangles, painted area and procedure definitions. Co-Authored-By: Claude Opus 5.5 --- crates/images/src/eps.rs | 147 ++++++++++++++++++++++++++++++++++++++- crates/images/src/lib.rs | 22 ++++++ 2 files changed, 166 insertions(+), 3 deletions(-) diff --git a/crates/images/src/eps.rs b/crates/images/src/eps.rs index 01d95929..f86c4eae 100644 --- a/crates/images/src/eps.rs +++ b/crates/images/src/eps.rs @@ -1,6 +1,7 @@ -//! EPS: there's no PostScript interpreter here, so a placed EPS shows (and prints) through a -//! proxy, as InDesign does on screen: the file's TIFF preview when it has one, else a placeholder -//! the size of its bounding box. +//! EPS: there's no general PostScript interpreter here, so a placed EPS shows (and prints) +//! through a proxy, as InDesign does on screen: the file's TIFF preview when it has one, else the +//! rectangles of a simple one drawn here (InDesign's QR codes), else a placeholder the size of its +//! bounding box. /// An EPS file (plain, or DOS EPS with a binary header)? pub fn is_eps(bytes: &[u8]) -> bool { @@ -45,6 +46,10 @@ pub fn eps_proxy(bytes: &[u8]) -> Option<(Vec, (f64, f64))> { { return Some((tiff.to_vec(), (w, h))); } + // Simple PostScript drawn here: InDesign's QR codes are coloured rectangles only. + if let Some(png) = render_rectangles(sections(bytes).0, bb) { + return Some((png, (w, h))); + } let (pw, ph) = ((w * 2.0).round().clamp(2.0, 4000.0) as u32, (h * 2.0).round().clamp(2.0, 4000.0) as u32); let mut img = image::RgbaImage::from_pixel(pw, ph, image::Rgba([228, 228, 228, 255])); // Diagonals and a border mark it as a stand-in. @@ -68,10 +73,146 @@ pub fn eps_proxy(bytes: &[u8]) -> Option<(Vec, (f64, f64))> { Some((out, (w, h))) } +/// Pixels per point for rendered EPS (a QR code's modules stay crisp when scaled up on screen). +const RENDER_SCALE: f64 = 8.0; +/// The most rectangles and operators read from an EPS before giving up on drawing it. +const MAX_RECTS: usize = 200_000; +const MAX_TOKENS: usize = 2_000_000; +/// The most pixels painted, in multiples of the image's area (overlapping rectangles repaint). +const MAX_PAINT_AREAS: u64 = 64; + +/// PostScript that only fills rectangles in solid colours (`setcmykcolor`, `setrgbcolor`, +/// `setgray`, `rectfill`, and procedures that stand for one of them, such as InDesign's +/// `/rf { rectfill } bind def`), drawn as a PNG of the bounding box `bb`. None for anything else +/// (paths, text, images, transforms), which keeps its placeholder, or when nothing is drawn. +fn render_rectangles(ps: &[u8], bb: [f64; 4]) -> Option> { + let text = std::str::from_utf8(ps).ok()?; + let (w, h) = (bb[2] - bb[0], bb[3] - bb[1]); + if !(w > 0.0 && h > 0.0) { + return None; + } + let scale = RENDER_SCALE.min(4000.0 / w.max(h)); + let (pw, ph) = ((w * scale).round().max(1.0) as u32, (h * scale).round().max(1.0) as u32); + let mut img = image::RgbaImage::from_pixel(pw, ph, image::Rgba([0, 0, 0, 0])); + let mut aliases: std::collections::HashMap = std::collections::HashMap::new(); + let mut stack: Vec = Vec::new(); + let mut color = [0u8, 0, 0, 255]; + let mut drawn = 0usize; + let mut painted = 0u64; + let paint_budget = u64::from(pw) * u64::from(ph) * MAX_PAINT_AREAS; + // Tokens without comments; `{ … }` procedures kept whole. + let mut tokens: Vec = Vec::new(); + for line in text.lines() { + let line = line.split('%').next().unwrap_or(""); + let spaced = line.replace('{', " { ").replace('}', " } "); + tokens.extend(spaced.split_whitespace().map(str::to_string)); + if tokens.len() > MAX_TOKENS { + return None; + } + } + let unit = |v: f64| v.clamp(0.0, 1.0); + let mut i = 0; + while let Some(tok) = tokens.get(i) { + i += 1; + if let Ok(v) = tok.parse::() { + if !v.is_finite() { + return None; + } + stack.push(v); + continue; + } + // `/name { op } bind def`: name stands for op. + if let Some(name) = tok.strip_prefix('/') { + // At most `{ op } bind`: anything longer isn't an alias this reader knows. + let body: Vec<&String> = tokens.iter().skip(i).take(5).take_while(|t| *t != "def").collect(); + let op = match body.as_slice() { + [open, op, close] | [open, op, close, _] if *open == "{" && *close == "}" => (*op).clone(), + _ => return None, + }; + aliases.insert(name.to_string(), op); + i += body.len() + 1; + continue; + } + let op = aliases.get(tok).map_or(tok.as_str(), String::as_str); + let mut pop = |n: usize| -> Option> { (stack.len() >= n).then(|| stack.split_off(stack.len() - n)) }; + match op { + "setcmykcolor" => { + let v = pop(4)?; + let k = unit(v[3]); + let ch = |c: f64| ((1.0 - unit(c)) * (1.0 - k) * 255.0).round() as u8; + color = [ch(v[0]), ch(v[1]), ch(v[2]), 255]; + } + "setrgbcolor" => { + let v = pop(3)?; + let ch = |c: f64| (unit(c) * 255.0).round() as u8; + color = [ch(v[0]), ch(v[1]), ch(v[2]), 255]; + } + "setgray" => { + let v = pop(1)?; + let g = (unit(v[0]) * 255.0).round() as u8; + color = [g, g, g, 255]; + } + "rectfill" => { + let v = pop(4)?; + drawn += 1; + if drawn > MAX_RECTS { + return None; + } + // PostScript y runs up; the image's down. Edges round to whole pixels, so + // touching rectangles (QR code modules) meet without gaps. + let (x0, x1) = ((v[0] - bb[0]) * scale, (v[0] + v[2] - bb[0]) * scale); + let (y0, y1) = ((bb[3] - (v[1] + v[3])) * scale, (bb[3] - v[1]) * scale); + let px = |a: f64, b: f64, max: u32| { + let (lo, hi) = (a.min(b).round().max(0.0), a.max(b).round().min(max as f64)); + (lo as u32, hi.max(lo) as u32) + }; + let ((xa, xb), (ya, yb)) = (px(x0, x1, pw), px(y0, y1, ph)); + painted = painted.saturating_add(u64::from(xb - xa) * u64::from(yb - ya)); + if painted > paint_budget { + return None; + } + for y in ya..yb { + for x in xa..xb { + img.put_pixel(x, y, image::Rgba(color)); + } + } + } + "bind" | "gsave" | "grestore" | "newpath" | "showpage" | "save" | "restore" | "pop" | "end" | "begin" => {} + _ => return None, + } + } + if drawn == 0 { + return None; + } + let mut out = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + Some(out) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn simple_rectangle_eps_is_drawn_like_a_qr_code() { + // As InDesign writes QR codes: a procedure for rectfill, a colour, then modules. + let eps = b"%!PS-Adobe-3.1 EPSF-3.0\n%%BoundingBox: 0 0 4 4\n%%EndComments\n/rf { rectfill } bind def\n0 0 0 1 setcmykcolor\n0 2 2 2 rf\n2 0 2 2 rf\n%%EOF\n"; + let (png, size) = eps_proxy(eps).unwrap(); + assert_eq!(size, (4.0, 4.0)); + let img = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (32, 32), "8 pixels per point"); + // PostScript y runs up: (0, 2)–(2, 4) is the image's top left. + assert_eq!(img.get_pixel(4, 4).0, [0, 0, 0, 255]); + assert_eq!(img.get_pixel(28, 28).0, [0, 0, 0, 255]); + assert_eq!(img.get_pixel(28, 4).0[3], 0, "undrawn stays transparent"); + assert_eq!(img.get_pixel(4, 28).0[3], 0); + // Anything else (here a path) keeps the placeholder. + let curve = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 4 4\n0 0 moveto 4 4 lineto stroke\n%%EOF\n"; + let (png, _) = eps_proxy(curve).unwrap(); + let img = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(img.get_pixel(2, 4).0, [228, 228, 228, 255]); + } + #[test] fn eps_bounding_box_and_proxies() { let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%HiResBoundingBox: 0 0 100.5 50.25\nnewpath\n%%EOF\n"; diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 4751263a..7ce48c0a 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -140,6 +140,10 @@ pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> { let s = svg_tree(bytes)?.size(); return Some((s.width() as f64 * PT_PER_PX, s.height() as f64 * PT_PER_PX)); } + if is_eps(bytes) { + let bb = eps::bounding_box(bytes)?; + return Some((bb[2] - bb[0], bb[3] - bb[1])).filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0); + } let (w, h) = pixel_size(bytes)?; Some((w as f64, h as f64)) } @@ -159,6 +163,11 @@ pub fn decode_rgba(bytes: &[u8]) -> Option { if is_psd(bytes) { return psd::decode(bytes); } + // An EPS is drawn as its proxy (preview, simple PostScript or placeholder); the document + // keeps the EPS itself. + if is_eps(bytes) { + return decode_rgba(&eps_proxy(bytes)?.0); + } Some(decode_to_rgba8(&image::load_from_memory(bytes).ok()?)) } @@ -386,6 +395,19 @@ mod tests { b } + /// A placed EPS is measured by its bounding box in points and drawn through its proxy; the + /// bytes stay the EPS. + #[test] + fn eps_draws_through_its_proxy_at_its_bounding_box_size() { + let eps = + b"%!PS-Adobe-3.1 EPSF-3.0\n%%BoundingBox: 0 0 4 2\n%%EndComments\n/rf { rectfill } bind def\n0 0 0 1 setcmykcolor\n0 0 2 2 rf\n%%EOF\n"; + assert_eq!(natural_size(eps), Some((4.0, 2.0))); + let img = decode_rgba(eps).expect("drawn"); + assert_eq!(img.dimensions(), (32, 16)); + assert_eq!(img.get_pixel(1, 1).0, [0, 0, 0, 255], "the rectangle"); + assert_eq!(img.get_pixel(30, 1).0[3], 0, "outside it, transparent"); + } + /// CMYK TIFFs read back as their ink values; RGB ones aren't CMYK. #[test] fn cmyk_tiff_keeps_inks() { From ba4519c3821a5dfb15d27ff5819fd7a2c8527e84 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:31:54 +0200 Subject: [PATCH 02/32] Preferences: Story Editor size stays finite and in range (#821) Preferences OK stored whatever number the dialog held as the Story Editor font size. One that is not finite ("nan", "inf", or a number too large for an f32) was written to ui.json as null; the next launch could not read the file, started with the default UI settings and saved them over it on exit. The size is now kept between 8 and 36 pt, the slider's range, and a value that is not a finite number leaves the size as it was. --- crates/ui-egui/src/dialogs.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 695a66af..e9776928 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2009,7 +2009,10 @@ pub fn confirm(app: &mut DesignApp) -> Result { let words: Vec = d.s("userWords").lines().map(str::to_string).collect(); app.run("spelling.setWords", json!({"words": words}))?; } - app.ui.story_editor_size = d.n("storyEditorSize").unwrap_or(14.0) as f32; + // The slider's range. A size that isn't finite is saved as `null`, and ui.json then fails to load. + if let Some(size) = d.n("storyEditorSize").filter(|v| v.is_finite()) { + app.ui.story_editor_size = size.clamp(8.0, 36.0) as f32; + } // Highlight options change the screen view only. app.canvas.shown = None; let view = match d.s("displayQuality").as_str() { @@ -4095,6 +4098,22 @@ mod tests { assert!(app.ui.dialog.as_ref().unwrap().b("absolutePageNumbers")); } + #[test] + fn preferences_ok_saves_a_story_editor_size_that_loads_back() { + // `ui.dialog.set` takes any value; a size that isn't finite is saved as `null`. + let sizes = [(json!("nan"), 20.0), (json!("inf"), 20.0), (json!(null), 20.0), (json!(1e39), 36.0), (json!(3), 8.0), (json!(11), 11.0)]; + for (size, want) in sizes { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.ui.story_editor_size = 20.0; + app.run("app.preferences", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("storyEditorSize".into(), size); + confirm(&mut app).unwrap(); + let saved = serde_json::to_vec(&app.ui).unwrap(); + assert_eq!(crate::UiState::from_preferences_json(&saved).unwrap().story_editor_size, want); + } + } + #[test] fn new_document_vertical_scroll_reaches_last_field() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From dc712baeca972c6732af9e0af33aac4f156afb61 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 20:38:38 +0200 Subject: [PATCH 03/32] fix(color): gradients between process colours blend as inks, as InDesign shows them LYH Studio episode 6 builds every gradient from C=0 M=100 Y=0 K=0 to C=100 M=0 Y=0 K=0. InDesign blends a gradient's process stops as inks: halfway is C 50 M 50, a light lilac. DesignCraft converted both ends to RGB and blended there, so the middle came out a darker violet (117, 88, 189 instead of 135, 119, 181), and every Episode 6 gradient looked different. A midpoint between CMYK stops also became an RGB colour, so the PDF shading turned RGB with a conversion warning. - color: `Color::lerp` blends CMYK with CMYK (or grey), and grey with grey, in ink space; anything with RGB still blends in RGB. - `Gradient::display_stops`: the expanded stops plus samples through each span that isn't RGB at both ends, for renderers that interpolate in RGB; the screen renderer uses it. - pdf: a midpoint's expanded stop now keeps the stops' space; the colour-space test expects no conversion for CMYK or grey midpoints. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/color/src/gradient.rs | 39 +++++++++++++++++++++++ crates/color/src/lib.rs | 10 ++++++ crates/pdf/tests/gradient_color_spaces.rs | 5 ++- crates/render/src/lib.rs | 4 ++- crates/render/tests/gradient_inks.rs | 36 +++++++++++++++++++++ docs/target-app-parity.md | 2 +- 7 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 crates/render/tests/gradient_inks.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f8207..1fa84857 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** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. 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/color/src/gradient.rs b/crates/color/src/gradient.rs index 4da4f2d8..6c19aabb 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -103,6 +103,26 @@ impl Gradient { } out } + /// Stops for a renderer that blends between them in RGB: the expanded stops, plus samples + /// through each span that isn't RGB at both ends, so process colours keep blending as inks. + pub fn display_stops(&self) -> Vec<(f32, Color, f32)> { + const STEPS: usize = 16; + let stops = self.expanded_stops(); + let mut out: Vec<(f32, Color, f32)> = Vec::with_capacity(stops.len() * STEPS); + for (i, s) in stops.iter().enumerate() { + out.push(*s); + let Some(n) = stops.get(i + 1) else { continue }; + if matches!((s.1, n.1), (Color::Rgb { .. }, Color::Rgb { .. })) || n.0 - s.0 <= 1e-4 { + continue; + } + for j in 1..STEPS { + let t = s.0 + (n.0 - s.0) * j as f32 / STEPS as f32; + let (c, a) = self.sample(t); + out.push((t, c, a)); + } + } + out + } pub fn reverse(&mut self) { // A midpoint belongs to the segment after its stop, so it moves with that segment and mirrors. let mids: Vec<_> = self.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).collect(); @@ -199,6 +219,25 @@ mod tests { assert!((mid - 0.5).abs() < 1e-5); } + #[test] + fn process_stops_blend_as_inks() { + // LYH Studio episode 6: magenta to cyan is C 50 M 50 halfway (a light lilac), not the RGB mix. + let mut g = Gradient::default(); + g.stops[0].color = Color::cmyk(0.0, 1.0, 0.0, 0.0); + g.stops[1].color = Color::cmyk(1.0, 0.0, 0.0, 0.0); + assert_eq!(g.sample(0.5).0, Color::cmyk(0.5, 0.5, 0.0, 0.0)); + let d = g.display_stops(); + assert_eq!(d.len(), 17, "sampled through the span"); + assert!(d.iter().any(|(o, c, _)| (*o - 0.5).abs() < 1e-6 && *c == Color::cmyk(0.5, 0.5, 0.0, 0.0))); + // A midpoint stays an ink too; RGB stops aren't sampled. + g.stops[0].midpoint = 0.25; + assert!(matches!(g.expanded_stops()[1].1, Color::Cmyk { .. })); + assert_eq!(Gradient::default().display_stops().len(), 2); + // Grey and CMYK meet as inks; RGB with anything else blends in RGB. + assert_eq!(Color::gray(1.0).lerp(&Color::cmyk(1.0, 0.0, 0.0, 0.0), 0.5), Color::cmyk(0.5, 0.0, 0.0, 0.5)); + assert!(matches!(Color::rgb(1.0, 0.0, 0.0).lerp(&Color::cmyk(1.0, 0.0, 0.0, 0.0), 0.5), Color::Rgb { .. })); + } + #[test] fn midpoint_shifts() { let mut g = Gradient::default(); diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index bd5e915d..73a8cb26 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -170,7 +170,17 @@ impl Color { Color::rgb(1.0 - r, 1.0 - g, 1.0 - b) } /// Linear interpolation in display RGB. + /// The colour `t` of the way to `other`. Process inks blend as inks (CMYK with CMYK or grey, + /// grey with grey), as InDesign blends a gradient's stops; anything else blends in RGB. pub fn lerp(&self, other: &Color, t: f32) -> Color { + let l = |a: f32, b: f32| a + (b - a) * t; + match (*self, *other) { + (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => return Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)), + (Color::Gray { k }, Color::Gray { k: k2 }) => return Color::gray(l(k, k2)), + (Color::Gray { k }, Color::Cmyk { .. }) => return Color::cmyk(0.0, 0.0, 0.0, k).lerp(other, t), + (Color::Cmyk { .. }, Color::Gray { k }) => return self.lerp(&Color::cmyk(0.0, 0.0, 0.0, k), t), + _ => {} + } let a = self.to_rgb(); let b = other.to_rgb(); Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t) diff --git a/crates/pdf/tests/gradient_color_spaces.rs b/crates/pdf/tests/gradient_color_spaces.rs index c523a418..f9892965 100644 --- a/crates/pdf/tests/gradient_color_spaces.rs +++ b/crates/pdf/tests/gradient_color_spaces.rs @@ -78,15 +78,14 @@ fn mixed_gradient_export_survives_in_subprocess() { } } } - // Expansion inserts an RGB midpoint even when the authoring stops share CMYK or Gray. + // A midpoint's expanded stop blends in the stops' own space: CMYK or Gray stays homogeneous. for colors in [[cmyk, Color::cmyk(0.0, 1.0, 0.0, 0.0)], [gray, Color::gray(0.8)]] { for kind in [GradientKind::Linear, GradientKind::Radial] { for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] { let report = export_pdf_with_report(&document(&colors, kind, 0.25, 1.0), &Cache::new(), &PdfOptions { standard, ..Default::default() }) .unwrap(); - let archival_cmyk = standard == Standard::PdfA2b && matches!(colors[0], Color::Cmyk { .. }); - assert_eq!(conversion_warnings(&report.warnings), usize::from(!archival_cmyk), "{standard:?}: {:?}", report.warnings); + assert_eq!(conversion_warnings(&report.warnings), 0, "{standard:?}: {:?}", report.warnings); assert_eq!(Pdf::new(report.bytes).unwrap().pages().len(), 1); } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548..c49b3caf 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -880,7 +880,9 @@ pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color { fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_doc::Fill, bounds: Rect) -> bool { let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); if let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch) { - let stops: Vec = g.expanded_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect(); + // Sampled so process colours blend as inks, as InDesign shows them (and as the PDF's CMYK + // shading prints). + let stops: Vec = g.display_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect(); if stops.is_empty() { return false; } diff --git a/crates/render/tests/gradient_inks.rs b/crates/render/tests/gradient_inks.rs new file mode 100644 index 00000000..8bb407a6 --- /dev/null +++ b/crates/render/tests/gradient_inks.rs @@ -0,0 +1,36 @@ +//! A gradient between process colours shows the inks' blend, as InDesign shows it. +use designcraft_color::swatch::Swatch; +use designcraft_color::{Color, Gradient, GradientKind, GradientStop, SwatchValue}; +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef, Stroke}; +use designcraft_geom::{Affine, Rect, shapes}; +use designcraft_render::{Placed, RenderOptions, Renderer}; + +#[test] +fn a_cmyk_gradient_is_c50_m50_halfway() { + let mut d = Document::new(&NewDocument { width: 100.0, height: 20.0, facing_pages: false, ..Default::default() }); + let stop = |offset: f32, color: Color| GradientStop { offset, color, opacity: 1.0, midpoint: 0.5 }; + let gradient = + Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, Color::cmyk(0.0, 1.0, 0.0, 0.0)), stop(1.0, Color::cmyk(1.0, 0.0, 0.0, 0.0))] }; + d.swatches.push(Swatch { name: "Magenta to Cyan".into(), value: SwatchValue::Gradient { gradient }, locked: false, named: true, hidden: false }); + let id = ItemId(d.alloc()); + let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 20.0))); + item.fill = Fill::swatch("Magenta to Cyan"); + item.stroke = Stroke::default(); + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + let img = Renderer::new().render( + &d, + &Cache::new(), + &[Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }], + 100, + 20, + Affine::IDENTITY, + &RenderOptions { paper: false, background: Some([255; 4]), ..Default::default() }, + ); + let want = Color::cmyk(0.5, 0.5, 0.0, 0.0).to_rgba8(1.0); + let got = img.pixel(50, 10); + for ch in 0..3 { + assert!(got[ch].abs_diff(want[ch]) <= 4, "halfway {got:?}, the inks' blend is {want:?}"); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c..0d2f8b0f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -436,7 +436,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level Task notes that headed ROADMAP.md: -- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. +- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Process stops (CMYK, grey) blend as inks on screen and at midpoints, so a midpoint keeps the shading CMYK. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. - CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby remain unfinished. Warichu sets the selected run in smaller lines inside the line after the line is broken. From 08386d03b115dc1bd20ebf4f3c99158b5334a186 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 21:12:57 +0200 Subject: [PATCH 04/32] feat(ui): Object > Fitting with InDesign's shortcuts LYH Studio episode 6 (7:00) fills its photo frames with the caption "Fit Content to Frame (Ctrl + Shift + Alt + C)", InDesign's shortcut for Fill Frame Proportionally. DesignCraft's Fitting items were object.fit variants without shortcuts (a menu shortcut belongs to a command id, and the five modes share one). - Five UI commands, one per mode, with InDesign's keys: fit.fillFrameProportionally Cmd+Alt+Shift+C, fit.contentProportionally Cmd+Alt+Shift+E, fit.frameToContent Cmd+Alt+C, fit.contentToFrame Cmd+Alt+E, fit.centerContent Cmd+Shift+E (Ctrl on Windows and Linux); each runs object.fit. - Object > Fitting lists them (with their shortcuts), enabled as object.fit is. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/menus.rs | 72 +++++++++++++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f8207..ec341060 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** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. 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/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81d..657ccd3c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -163,6 +163,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("window.panel.close", "Close Panel", None, "{panel} — hide a panel"), ("window.dockPanel", "Dock Panel", None, "{panel} — back into the dock's icon column"), ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"), + // Object › Fitting with InDesign's shortcuts (one `object.fit` mode each). + ("fit.fillFrameProportionally", "Fill Frame Proportionally", Some("Cmd+Alt+Shift+C"), "{} — object.fit {mode: fillProportionally}"), + ("fit.contentProportionally", "Fit Content Proportionally", Some("Cmd+Alt+Shift+E"), "{} — object.fit {mode: fitProportionally}"), + ("fit.frameToContent", "Fit Frame to Content", Some("Cmd+Alt+C"), "{} — object.fit {mode: fitFrameToContent}"), + ("fit.contentToFrame", "Fit Content to Frame", Some("Cmd+Alt+E"), "{} — object.fit {mode: fitContentToFrame}"), + ("fit.centerContent", "Center Content", Some("Cmd+Shift+E"), "{} — object.fit {mode: centerContent}"), ("window.split", "Split Window", None, "{on?: bool} — two views of the document side by side, each with its own zoom and scroll"), ("window.newWindow", "New Window", None, "{on?: bool} — another view of the active document in its own window"), ("window.taskBar", "Contextual Task Bar", None, "{}"), @@ -552,11 +558,11 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:anchored.release", "<", ">Fitting", - "cmd:object.fit|Fill Frame Proportionally|{\"mode\":\"fillProportionally\"}", - "cmd:object.fit|Fit Content Proportionally|{\"mode\":\"fitProportionally\"}", - "cmd:object.fit|Fit Frame to Content|{\"mode\":\"fitFrameToContent\"}", - "cmd:object.fit|Fit Content to Frame|{\"mode\":\"fitContentToFrame\"}", - "cmd:object.fit|Center Content|{\"mode\":\"centerContent\"}", + "ui:fit.fillFrameProportionally", + "ui:fit.contentProportionally", + "ui:fit.frameToContent", + "ui:fit.contentToFrame", + "ui:fit.centerContent", "-", "ui:app.fittingOptions", "<", @@ -1269,6 +1275,16 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + let mode = match id { + "fit.fillFrameProportionally" => "fillProportionally", + "fit.contentProportionally" => "fitProportionally", + "fit.frameToContent" => "fitFrameToContent", + "fit.contentToFrame" => "fitContentToFrame", + _ => "centerContent", + }; + app.run("object.fit", json!({"mode": mode})) + } "app.keyboardShortcuts" => { app.ui.dialog = Some(crate::dialogs::Dialog::new("keyboardShortcuts", json!({"query": "", "recording": ""}))); Ok(Value::Null) @@ -1856,6 +1872,9 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { /// Enablement for menu display (UI commands need a document unless they're app/window-level). pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { + if id.starts_with("fit.") { + return enabled(app, "object.fit"); + } if ui_label(id).is_some() { return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window."); } @@ -2872,6 +2891,47 @@ mod tests { assert!(run_ui(&mut app, "view.proofSetup", &json!({"target": "nope"})).unwrap().is_err()); } + #[test] + fn fitting_has_indesigns_shortcuts() { + // LYH Studio episode 6 (7:00): "Fit Content to Frame (Ctrl + Shift + Alt + C)" fills the + // photo frames proportionally. + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + let f = app.run("frame.create", json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap(); + app.run("file.place", json!({"base64": designcraft_engine::cmd::base64_encode(&png), "name": "a.png", "frame": f})).unwrap(); + app.run("selection.set", json!({"ids": [f]})).unwrap(); + let width = |app: &crate::DesignApp| { + let it = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().clone(); + let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("graphic") }; + it.xf.transform_rect_bbox(g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1))).width() + }; + for (id, keys) in [ + ("fit.fillFrameProportionally", "Cmd+Alt+Shift+C"), + ("fit.contentProportionally", "Cmd+Alt+Shift+E"), + ("fit.frameToContent", "Cmd+Alt+C"), + ("fit.contentToFrame", "Cmd+Alt+E"), + ("fit.centerContent", "Cmd+Shift+E"), + ] { + assert_eq!(shortcut_of(&app, id).as_deref(), Some(keys)); + } + let ctx = egui::Context::default(); + let press = |app: &mut crate::DesignApp, key: egui::Key| { + let modifiers = + egui::Modifiers { alt: true, ctrl: !cfg!(target_os = "macos"), shift: true, mac_cmd: cfg!(target_os = "macos"), command: true }; + let input = egui::RawInput { + events: vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| shortcuts(app, &ui.ctx().clone())); + out.textures_delta.clear(); + }; + press(&mut app, egui::Key::C); + assert!((width(&app) - 400.0).abs() < 1e-6, "filled: {}", width(&app)); + press(&mut app, egui::Key::E); + assert!((width(&app) - 200.0).abs() < 1e-6, "fitted: {}", width(&app)); + } + #[test] fn tab_hides_panels_and_documents_cycle() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -3358,7 +3418,7 @@ mod tests { }); assert!(special.is_some_and(|c| c.iter().any(|i| matches!(i, Item::Sub(n, _) if n == "Symbols")))); // Fixed-parameter variants keep their own labels. - assert!(all.iter().any(|(l, id, p)| l == "Fill Frame Proportionally" && id == "object.fit" && p["mode"] == "fillProportionally")); + assert!(all.iter().any(|(l, id, _)| l == "Fill Frame Proportionally" && id == "fit.fillFrameProportionally")); } #[test] From 7a89954937937b371b29aa6d089de5cccc086105 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 13 Oct 2026 12:06:41 -0700 Subject: [PATCH 05/32] Placed PDFs: render with pdfcraft's patched hayro crates Placed PDFs rendered through hayro 0.7 from crates.io, whose hayro-interpret divides integers in calculator functions without a check, does not limit how deep functions nest and samples each mesh triangle over its whole bounding box. A placed PDF whose calculator function runs `1 0 idiv` or `-2147483648 -1 idiv` panicked the render. A chain of 100,000 functions, each stitching function naming the next, overflowed the stack and aborted the process. Mesh shadings with points far off the page, ten thousand overlapping triangles or forty thousand patches ran for over a minute or passed 2 GiB of memory. [patch.crates-io] now takes hayro, hayro-interpret, hayro-syntax and hayro-jbig2 from pdfcraft's vendor/ directory at b5467d93, with the patches its vendor/README.md lists. The crate versions do not change. The patched hayro-syntax sizes a page with a /UserUnit in points, and pdf_page_box scales the page's boxes by the same unit, so with Crop to the frame still shows the chosen box. NOTICE names the new source and the licenses. The function and mesh tests in crates/render/tests/placed_pdf.rs follow pdfcraft's tests in crates/render/src/raster.rs. --- Cargo.lock | 27 ++-- Cargo.toml | 5 + NOTICE | 5 + ROADMAP.md | 1 + crates/render/src/lib.rs | 12 +- crates/render/tests/placed_pdf.rs | 249 ++++++++++++++++++++++++++++++ 6 files changed, 285 insertions(+), 14 deletions(-) create mode 100644 crates/render/tests/placed_pdf.rs diff --git a/Cargo.lock b/Cargo.lock index ef525c84..a87f01c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2004,13 +2004,13 @@ dependencies = [ [[package]] name = "hayro" version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4caa128ab87fd48ffb7490617cf93f77f606820dffbc9fd9ef6ab0ed077f56d" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "bytemuck", "hayro-interpret", "image", "kurbo", + "log", "pic-scale", "vello_cpu 0.0.8", ] @@ -2034,8 +2034,7 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "bitflags 2.13.2", "hayro-cmap", @@ -2045,7 +2044,7 @@ dependencies = [ "phf", "rustc-hash 2.1.3", "siphasher", - "skrifa 0.42.1", + "skrifa 0.47.0", "smallvec", "yoke", ] @@ -2053,8 +2052,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -2078,8 +2076,7 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "flate2", "hayro-ccitt", @@ -2088,6 +2085,7 @@ dependencies = [ "memchr", "rustc-hash 2.1.3", "smallvec", + "stringprep", "zune-jpeg", ] @@ -4199,6 +4197,17 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "subsetter" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index b08a82fc..9ce011b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,3 +101,8 @@ debug = 0 # Opt-in cancelable macOS termination on the pinned winit 0.30 line. [patch.crates-io] winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } +# The hayro crates from pdfcraft's vendor/ directory, with the patches its README lists. +hayro = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } +hayro-interpret = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } +hayro-syntax = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } +hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } diff --git a/NOTICE b/NOTICE index 087bb619..21123eea 100644 --- a/NOTICE +++ b/NOTICE @@ -58,6 +58,11 @@ list (author, source, license) is in ASSETS.md. Shippori Mincho), under the SIL Open Font License 1.1. Their licence texts ship with the packages as OFL-.txt. Builds without CRAFT_FONTS_DIR do not include them. - All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0. +- hayro PDF crates (placed PDFs): hayro, hayro-interpret, hayro-syntax and hayro-jbig2 + (Apache-2.0 OR MIT) are pdfcraft's patched copies, fetched by git from its vendor/ directory at + the commit that Cargo.toml pins (https://github.com/storytold/pdfcraft, MIT OR Apache-2.0). + hayro-interpret embeds PDFium's Foxit standard-14 fonts under PDFium's BSD-style license + (vendor/hayro-interpret/assets/LICENSE_FOXIT) and a CMYK profile under CC0-1.0. Rust dependencies (vello_cpu, kurbo, skrifa, harfrust, image, egui, eframe, …) are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io. diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f8207..2c8c2ec0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -147,6 +147,7 @@ Newest first. - **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read `\r` and `\r\n` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change. - **2026-10-11** · The View menu follows InDesign's: proofing first, then zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras, Grids & Guides and Structure; the view items that sat at the end of the Type menu moved there. No readiness percentage change. - **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. No readiness percentage change. +- **2026-10-13** · Placed PDFs are drawn through pdfcraft's patched hayro crates: calculator functions whose `idiv` divides by zero or overflows, chains of stitching functions a hundred thousand deep, and mesh shadings with points far off the page, ten thousand overlapping triangles or forty thousand patches no longer panic, overflow the stack, run for over a minute or use more than 2 GiB of memory. A page with a `/UserUnit`, and its trim, bleed and art boxes, are sized in points. No readiness percentage change. - **2026-10-12** · Properties › Transform shows rotation, shear and X/Y scale, and Rotate 90° and Flip Horizontal/Vertical buttons, as InDesign's does. No readiness percentage change. - **2026-10-12** · Edit › Step and Repeat has its InDesign dialog: Repeat (Count, or Rows and Columns with Create as a grid), Offset (Vertical, Horizontal, in ruler units) and Preview, which shows the copies while the dialog is open; Cancel takes them away and OK makes them as one undo step. No readiness percentage change. - **2026-10-12** · Double-clicking the Fill or Stroke box in the Tools panel opens the Color Picker with the selection's colour, as in InDesign (it opened the Swatches panel). No readiness percentage change. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548..b94281e7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1081,18 +1081,20 @@ pub fn pdf_page_box(bytes: &[u8], page: usize, kind: &str) -> Option<(f64, f64, let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?; let p = pdf.pages().get(page)?; let visible = p.intersected_crop_box(); + // The boxes are in user space units; `/UserUnit` is the size of one in points. + let unit = p.user_unit() as f64; let key: &[u8] = match kind { "trim" => keys::TRIM_BOX, "bleed" => keys::BLEED_BOX, "art" => keys::ART_BOX, - "media" => return Some(rel(visible, p.media_box())), - _ => return Some(rel(visible, visible)), + "media" => return Some(rel(visible, p.media_box(), unit)), + _ => return Some(rel(visible, visible, unit)), }; let b = p.raw().get::(key).map(|b| b.intersect(p.media_box())).unwrap_or(visible); - fn rel(v: PRect, b: PRect) -> (f64, f64, f64, f64) { - (b.x0 - v.x0, v.y1 - b.y1, b.width(), b.height()) + fn rel(v: PRect, b: PRect, unit: f64) -> (f64, f64, f64, f64) { + ((b.x0 - v.x0) * unit, (v.y1 - b.y1) * unit, b.width() * unit, b.height() * unit) } - Some(rel(visible, b)) + Some(rel(visible, b, unit)) } /// Separations Preview on a rendered image: `plate` 0–3 shows that process plate (C, M, Y, K) as diff --git a/crates/render/tests/placed_pdf.rs b/crates/render/tests/placed_pdf.rs new file mode 100644 index 00000000..d7487086 --- /dev/null +++ b/crates/render/tests/placed_pdf.rs @@ -0,0 +1,249 @@ +//! Placed PDFs are drawn through pdfcraft's patched hayro crates. Calculator functions whose `idiv` +//! divides by zero or overflows, stitching functions nested a hundred thousand deep, and mesh +//! shadings with points far off the page, ten thousand overlapping triangles or forty thousand +//! patches do not panic, overflow the stack or take more than a minute, and the rest of the page is +//! drawn. A page with a `/UserUnit` is sized in points, and so are its boxes. The function and mesh +//! cases follow pdfcraft's tests in `crates/render/src/raster.rs`. + +use std::sync::Arc; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::time::Duration; + +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, Shape, SpreadRef}; +use designcraft_geom::{Affine, Rect, shapes}; +use designcraft_render::{Rendered, Renderer, pdf_page_box, pdf_page_size}; +use hayro::hayro_interpret::Function; +use hayro::hayro_syntax::Pdf; +use hayro::hayro_syntax::object::{Object, ObjectIdentifier}; + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [255, 0, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +/// An exponential function of one input with one output. +const LEAF: &str = "<>"; + +/// Mesh entries common to the shadings below: RGB colors, 8-bit coordinates and components. +const MESH: &str = "/ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8"; + +/// One triangle of a free-form triangle mesh: three vertices of a flag, x, y and a color. +const TRIANGLE: [u8; 18] = [0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 255, 0, 0, 0, 255, 0, 0, 255]; + +/// A stream object with the `dict` entries, its `/Length` and `data`. +fn stream(dict: &str, data: &[u8]) -> Vec { + let mut s = format!("<<{dict} /Length {}>>\nstream\n", data.len()).into_bytes(); + s.extend_from_slice(data); + s.extend_from_slice(b"\nendstream"); + s +} + +/// A one-page PDF whose page dictionary holds the `page` entries (boxes, resources) and whose page +/// draws `content`. The `objects` are numbered from 5. +fn pdf(page: &str, content: &str, objects: &[Vec]) -> Vec { + let mut bodies = vec![ + b"<>".to_vec(), + b"<>".to_vec(), + format!("<>").into_bytes(), + stream("", content.as_bytes()), + ]; + bodies.extend_from_slice(objects); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", bodies.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + out +} + +/// The page of a document `size` points square with `pdf` placed to fill it, rendered by +/// `Renderer::render_page` at one pixel per point. Fails when the render panics or takes more +/// than a minute. +fn placed(size: f64, pdf: Vec) -> Rendered { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut d = Document::new(&NewDocument { width: size, height: size, facing_pages: false, ..Default::default() }); + let id = AssetId(d.alloc()); + let asset = + d.add_asset(Asset { id, name: "art.pdf".into(), mime: "application/pdf".into(), link: None, data: Arc::new(pdf), pixels: None, page: 0 }); + let id = ItemId(d.alloc()); + let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, size, size))); + item.content = + Content::Graphic(Graphic { asset, size: (size, size), xf: Affine::IDENTITY, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4] }); + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + let mut renderer = Renderer::new(); + renderer.threads = 0; + let _ = tx.send(renderer.render_page(&d, &Cache::new(), 0, 1.0, false, &Default::default())); + }); + match rx.recv_timeout(Duration::from_secs(60)) { + Ok(page) => page.expect("the page renders"), + Err(RecvTimeoutError::Timeout) => panic!("the placed PDF did not render within a minute"), + Err(RecvTimeoutError::Disconnected) => panic!("the render of the placed PDF panicked"), + } +} + +/// A 40-point page that paints an axial shading through the first of `functions` (objects 6 +/// onwards), then a blue square over the whole page. +fn shaded(functions: &[Vec]) -> Vec { + let mut objects = vec![b"<>".to_vec()]; + objects.extend_from_slice(functions); + pdf("/MediaBox[0 0 40 40]/Resources<>>>", "/S sh 0 0 1 rg 0 0 40 40 re f", &objects) +} + +/// A calculator (Type 4) function of one input that runs `program`. +fn calculator(program: &str) -> Vec { + stream("/FunctionType 4/Domain[0 1]/Range[0 1 0 1 0 1]", program.as_bytes()) +} + +/// The function that is object 5 of a PDF holding `objects`. +fn function(objects: &[&str]) -> Option { + let objects: Vec> = objects.iter().map(|o| o.as_bytes().to_vec()).collect(); + let parsed = Pdf::new(pdf("/MediaBox[0 0 20 20]", "", &objects)).unwrap(); + let object = parsed.xref().get::>(ObjectIdentifier::new(5, 0)).unwrap(); + Function::new(&object) +} + +#[test] +fn placed_pdf_function_dividing_by_zero_leaves_the_page_drawn() { + assert_eq!(placed(40.0, shaded(&[calculator("{ pop 1 0 idiv }")])).pixel(20, 20), BLUE); +} + +#[test] +fn placed_pdf_function_dividing_the_smallest_integer_by_minus_one_leaves_the_page_drawn() { + assert_eq!(placed(40.0, shaded(&[calculator("{ pop -2147483648 -1 idiv }")])).pixel(20, 20), BLUE); +} + +/// A chain of 100,000 functions: each stitching function names the next by reference, and the last +/// is an exponential function. +#[test] +fn placed_pdf_chain_of_stitching_functions_leaves_the_page_drawn() { + let mut functions: Vec> = + (7..100_006).map(|next| format!("<>").into_bytes()).collect(); + functions.push(b"<>".to_vec()); + assert_eq!(placed(40.0, shaded(&functions)).pixel(20, 20), BLUE); +} + +/// Stitching functions that each name the next through `N 0 R`: 64 evaluate, 65 are refused. +#[test] +fn function_limits_depth_guard_refuses_a_chain_of_references() { + for functions in [64, 65] { + let objects: Vec = (5..5 + functions) + .map(|number| { + if number < 4 + functions { + format!("<>", number + 1) + } else { + LEAF.to_owned() + } + }) + .collect(); + let objects: Vec<&str> = objects.iter().map(String::as_str).collect(); + let chain = function(&objects); + if functions == 64 { + assert_eq!(chain.unwrap().eval([0.5].into_iter().collect()).unwrap().as_slice(), &[0.5]); + } else { + assert!(chain.is_none(), "65 functions are one past the function-depth guard"); + } + } +} + +/// Stitching functions with a cycle, no children or a missing child are refused; one child named +/// twice still evaluates. +#[test] +fn function_limits_stitching_cycles_are_refused_and_shared_children_work() { + for objects in [ + &["<>"][..], + &[ + "<>", + "<>", + ], + &["<>"], + &["<>"], + &["<>", LEAF], + ] { + assert!(function(objects).is_none(), "accepted invalid children: {objects:?}"); + } + let shared = function(&["<>", LEAF]).unwrap(); + let output = shared.eval([0.75].into_iter().collect()).unwrap(); + assert!((output[0] - 0.5).abs() < 0.001); +} + +/// Ten thousand triangles that each cover half of the placed page, which is drawn 1000 pixels +/// square. +#[test] +fn many_overlapping_mesh_triangles_finish() { + let shading = stream(&format!("/ShadingType 4 {MESH} /BitsPerFlag 8 /Decode [0 125 0 125 0 1 0 1 0 1]"), &TRIANGLE.repeat(10_000)); + let page = placed(125.0, pdf("/MediaBox[0 0 125 125]/Resources<>>>", "/Sh0 sh", &[shading])); + let p = page.pixel(12, 112); + assert!(p != WHITE && p[3] == 255, "the triangles are drawn: {p:?}"); +} + +/// One Coons patch: its flag, twelve points around a square of 8-bit coordinates, four colors. +fn coons() -> Vec { + let mut patch = vec![0u8]; + for p in [(0, 0), (0, 85), (0, 170), (0, 255), (85, 255), (170, 255), (255, 255), (255, 170), (255, 85), (255, 0), (170, 0), (85, 0)] { + patch.extend_from_slice(&[p.0, p.1]); + } + patch.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0]); + patch +} + +/// A 40-point page that paints `shading` (object 5), then a red square at its bottom left. +fn meshed(shading: Vec) -> Vec { + pdf("/MediaBox[0 0 40 40]/Resources<>>>", "/Sh0 sh 1 0 0 rg 0 0 4 4 re f", &[shading]) +} + +/// Meshes whose points lie far off the page are sampled only where the page shows them. +#[test] +fn mesh_shadings_far_off_the_page_render() { + let far = "/Decode [-40000 40000 -40000 40000 0 1 0 1 0 1]"; + for (what, shading) in [ + ("a Coons patch", stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 {far}"), &coons())), + ("a triangle mesh", stream(&format!("/ShadingType 4 {MESH} /BitsPerFlag 8 {far}"), &TRIANGLE)), + ] { + assert_eq!(placed(40.0, meshed(shading)).pixel(1, 38), RED, "{what} far off the page: the rest of the page draws"); + } + for (what, decode) in [("on the page", "[0 40 0 40 0 1 0 1 0 1]"), ("covering the page", "[-40000 40000 -40000 40000 0 1 0 1 0 1]")] { + let page = placed(40.0, meshed(stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 /Decode {decode}"), &coons()))); + let center = page.pixel(20, 20); + assert!(center != WHITE && center[3] == 255, "a patch {what} is shaded: {center:?}"); + } + // A patch filling a 4 × 4 pt rectangle as a pattern is still shaded. + let shading = stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 /Decode [10 14 10 14 0 1 0 1 0 1]"), &coons()); + let page = placed( + 40.0, + pdf("/MediaBox[0 0 40 40]/Resources<>>>>>", "/Pattern cs /P0 scn 10 10 4 4 re f", &[shading]), + ); + let middle = page.pixel(12, 28); + assert!(middle != WHITE && middle[3] == 255, "a small patch is shaded: {middle:?}"); +} + +/// Forty thousand Coons patches beside the page, and a lattice with no vertices per row. +#[test] +fn mesh_shadings_with_many_patches_or_no_rows_finish() { + for shading in [ + stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 /Decode [100 140 100 140 0 1 0 1 0 1]"), &coons().repeat(40_000)), + stream(&format!("/ShadingType 5 {MESH} /VerticesPerRow 0 /Decode [0 40 0 40 0 1 0 1 0 1]"), &[0; 64]), + ] { + assert_eq!(placed(40.0, meshed(shading)).pixel(1, 38), RED, "the rest of the page draws"); + } +} + +/// A page with `/UserUnit 2` is twice its media box in points, and Image Import Options › Crop +/// to measures the trim box in points as well. +#[test] +fn a_page_with_a_user_unit_and_its_boxes_are_sized_in_points() { + let page = pdf("/MediaBox[0 0 100 50]/TrimBox[10 10 90 40]/UserUnit 2", "", &[]); + assert_eq!(pdf_page_size(&page, 0), Some((200.0, 100.0))); + assert_eq!(pdf_page_box(&page, 0, "trim"), Some((20.0, 20.0, 160.0, 60.0))); + assert_eq!(pdf_page_box(&page, 0, "media"), Some((0.0, 0.0, 200.0, 100.0))); +} From 4760a000dbcf42515a915c6c1ef2ec542f111a87 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:16:28 +1000 Subject: [PATCH 06/32] PARITY-FIND-SCOPE: respect search scope and first-match limits Co-Authored-By: Codex --- crates/engine/src/cmd/find.rs | 921 ++++++++++++++++- crates/engine/src/cmd/mod.rs | 3 +- crates/engine/src/cmd/text.rs | 2 + crates/engine/src/lib.rs | 20 + crates/ui-egui/src/control.rs | 8 +- crates/ui-egui/src/dialogs.rs | 1837 ++++++++++++++++++++++++--------- crates/ui-egui/src/lib.rs | 10 +- crates/ui-egui/src/menus.rs | 18 + 8 files changed, 2280 insertions(+), 539 deletions(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 5dc0bc8a..39f85b0a 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -1,17 +1,22 @@ //! Edit → Find/Change: plain text and GREP (regular expressions) with InDesign's metacharacters, //! optional formatting in the change, scoped to the document, a story or the selection. -use designcraft_doc::{StoryId, TextSel, story}; +use std::ops::Range; +use std::sync::{Arc, Weak}; + +use designcraft_doc::story::floor_char_boundary; +use designcraft_doc::{Document, Selection, StoryId, TextSel}; use serde_json::{Value, json}; use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param}; -use crate::{Result, Session}; +use crate::{DocState, Result, Session}; pub fn specs() -> Vec { vec![ cmd!(noundo "find.objects", "Find Object", [], None, "{fill?, stroke?: swatch, strokeWeight?, opacity? (0–1), kind?: text|graphic|shape|line|group, layer?, label?, select?: bool (default true)} → {ids} — Find/Change › Object, through the document", has_doc, |s, p| { + s.reset_find_scope(); let ids = find_objects(&s.doc()?.doc, p); if p.get("select").and_then(Value::as_bool).unwrap_or(true) && !ids.is_empty() { let st = s.doc_mut()?; @@ -28,6 +33,7 @@ pub fn specs() -> Vec { "{…find.objects criteria, change: {fill?, stroke?, strokeWeight?, opacity?}} → {changed}", has_doc, |s, p| { + s.reset_find_scope(); let ids = find_objects(&s.doc()?.doc, p); let ch = p.get("change").cloned().unwrap_or(Value::Null); if !ch.is_object() { @@ -65,7 +71,7 @@ pub fn specs() -> Vec { has_doc, change ), - cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, …} — selects the next match after the caret", has_doc, find_next), + cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), ] } @@ -125,34 +131,182 @@ fn build(p: &Value) -> Result { .map_err(|e| bad("find", format!("bad GREP: {e}"))) } -fn scope(s: &Session, p: &Value) -> Result)>> { +#[derive(Clone, Debug, PartialEq, Eq)] +enum Domain { + Document, + Story, + Selection, + Explicit(StoryId), +} + +impl Domain { + fn from_params(p: &Value) -> Self { + if let Some(id) = p.get("story").and_then(Value::as_u64) { + return Self::Explicit(StoryId(id)); + } + match str_param(p, "scope").unwrap_or("document") { + "story" => Self::Story, + "selection" => Self::Selection, + _ => Self::Document, + } + } +} + +/// Whether two requests use the same search domain, including explicit-story precedence. +pub fn same_find_domain(a: &Value, b: &Value) -> bool { + Domain::from_params(a) == Domain::from_params(b) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Matcher { + find: String, + grep: bool, + case_sensitive: bool, + whole_word: bool, +} + +impl Matcher { + fn from_params(p: &Value) -> Self { + Self { + find: str_param(p, "find").unwrap_or("").to_string(), + grep: bool_or(p, "grep", false), + case_sensitive: bool_or(p, "caseSensitive", false), + whole_word: bool_or(p, "wholeWord", false), + } + } +} + +/// A search owns its original domain, not the selection used to paint the latest hit. +/// Weak identity avoids retaining an old document merely for this transient cache. +#[derive(Clone)] +pub(crate) struct FindContext { + document: Weak, + uid: u64, + revision: u64, + selection: Selection, + domain: Domain, + matcher: Matcher, + targets: Vec<(StoryId, Range)>, + order: Vec, + target: usize, + offset: usize, + last_match_end: Option, +} + +impl FindContext { + pub(crate) fn is_current(&self, st: &DocState) -> bool { + self.uid == st.uid && Weak::ptr_eq(&self.document, &Arc::downgrade(&st.doc)) && self.revision == st.revision && self.selection == st.selection + } + + fn acknowledge(&mut self, st: &DocState) { + self.revision = st.revision; + self.selection = st.selection.clone(); + } + + fn restart(&mut self, st: &DocState) -> Result<()> { + self.target = 0; + self.last_match_end = None; + self.order = (0..self.targets.len()).collect(); + let caret = st.selection.text.filter(|t| t.cell.is_none() && st.selection.cells.is_none()); + if self.domain == Domain::Document + && let Some(t) = st.selection.text + && let Some(i) = self.targets.iter().position(|(sid, _)| *sid == t.story) + { + self.order.rotate_left(i); + } + let first = self.order.first().and_then(|i| self.targets.get(*i)); + self.offset = match first { + Some((sid, r)) if self.domain != Domain::Selection => caret.filter(|t| t.story == *sid).map_or(r.start, |t| t.range().end.min(r.end)), + Some((_, r)) => r.start, + None => 0, + }; + if let Some((sid, r)) = first { + let text = target_text(&st.doc, *sid, r)?; + if text.get(self.offset.saturating_sub(r.start)..).is_none() { + return Err(bad("find", "invalid UTF-8 search cursor")); + } + } + Ok(()) + } + + fn advance_target(&mut self) { + self.target = self.target.saturating_add(1); + self.last_match_end = None; + self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, r)| r.start); + } + + fn advance_empty(&mut self, text: &str, relative_end: usize, end: usize) { + // Move one Unicode scalar, not one byte; an endpoint is returned at most once. + if let Some(ch) = text.get(relative_end..).and_then(|rest| rest.chars().next()) { + self.offset = end + ch.len_utf8(); + } else { + self.advance_target(); + } + } +} + +fn target_text<'a>(d: &'a Document, sid: StoryId, r: &Range) -> Result<&'a str> { + d.story(sid) + .ok_or_else(|| bad("find", "no such story"))? + .text + .get(r.clone()) + .ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary")) +} + +fn scope(s: &Session, p: &Value) -> Result { let st = s.doc()?; + let domain = Domain::from_params(p); + let matcher = Matcher::from_params(p); + if let Some(previous) = s.find_context.as_ref().filter(|c| c.is_current(st) && c.domain == domain) { + let mut context = previous.clone(); + if context.matcher != matcher { + context.matcher = matcher; + context.restart(st)?; + } + return Ok(context); + } let d = &st.doc; - if let Some(sid) = p.get("story").and_then(Value::as_u64) { - let sid = StoryId(sid); - let len = d.story(sid).map(|x| x.len()).ok_or_else(|| bad("find", "no such story"))?; - return Ok(vec![(sid, 0..len)]); - } - match str_param(p, "scope").unwrap_or("document") { - "story" => { - let t = st.selection.text.ok_or_else(|| bad("find", "no story selected"))?; - Ok(vec![(t.story, 0..d.story(t.story).map(|x| x.len()).unwrap_or(0))]) + let targets = match domain { + Domain::Explicit(sid) => { + let len = d.story(sid).ok_or_else(|| bad("find", "no such story"))?.len(); + vec![(sid, 0..len)] } - "selection" => { + Domain::Story | Domain::Selection => { + if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) { + return Err(bad("find", "Story/Selection search in table cells or notes is not supported")); + } let t = st.selection.text.ok_or_else(|| bad("find", "no text selected"))?; - Ok(vec![(t.story, t.range())]) + let story = d.story(t.story).ok_or_else(|| bad("find", "no such story"))?; + let r = if domain == Domain::Selection { t.range() } else { 0..story.len() }; + target_text(d, t.story, &r)?; + vec![(t.story, r)] } - _ => Ok(d.stories.values().map(|x| (x.id, 0..x.len())).collect()), - } + Domain::Document => d.stories.values().map(|x| (x.id, 0..x.len())).collect(), + }; + let mut context = FindContext { + document: Arc::downgrade(d), + uid: st.uid, + revision: st.revision, + selection: st.selection.clone(), + domain, + matcher, + targets, + order: Vec::new(), + target: 0, + offset: 0, + last_match_end: None, + }; + context.restart(st)?; + Ok(context) } fn find(s: &mut Session, p: &Value) -> Result { let re = build(p)?; + let context = scope(s, p)?; let st = s.doc()?; let mut out = Vec::new(); - for (sid, r) in scope(s, p)? { - let Some(story) = st.doc.story(sid) else { continue }; - let text = &story.text[r.clone()]; + for (sid, r) in &context.targets { + let text = target_text(&st.doc, *sid, r)?; for m in re.find_iter(text) { out.push(json!({"story": sid.0, "start": r.start + m.start(), "end": r.start + m.end(), "text": m.as_str()})); if out.len() >= 10_000 { @@ -160,6 +314,7 @@ fn find(s: &mut Session, p: &Value) -> Result { } } } + s.find_context = Some(context); Ok(Value::Array(out)) } @@ -182,13 +337,13 @@ fn change(s: &mut Session, p: &Value) -> Result { } } super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; - let targets = scope(s, p)?; + let targets = scope(s, p)?.targets; s.edit(|d, sel| { let mut count = 0usize; for (sid, r) in targets { let Some(story) = d.story(sid) else { continue }; // Collect matches first (positions refer to the original text), apply back to front. - let text = story.text[r.clone()].to_string(); + let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?.to_string(); let mut edits: Vec<(std::ops::Range, String)> = Vec::new(); for caps in re.captures_iter(&text) { let Some(m) = caps.get(0) else { continue }; @@ -211,10 +366,21 @@ fn change(s: &mut Session, p: &Value) -> Result { } count += 1; } - if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) { - let len = st.len(); - t.anchor = t.anchor.min(len); - t.focus = t.focus.min(len); + if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell.is_none()) { + // Repair byte validity once against the final story, without remapping valid offsets. + let range = t.range(); + let start = floor_char_boundary(&st.text, range.start); + let mut end = range.end.min(st.len()); + if t.is_caret() { + end = start; + } else if !st.text.is_char_boundary(end) { + let floor = floor_char_boundary(&st.text, end); + end = st.text.get(floor..).and_then(|rest| rest.chars().next()).map_or(floor, |ch| floor + ch.len_utf8()); + } + (t.anchor, t.focus) = if t.anchor <= t.focus { (start, end) } else { (end, start) }; + } + if first_only && count > 0 { + break; } } Ok(json!({"count": count})) @@ -223,29 +389,44 @@ fn change(s: &mut Session, p: &Value) -> Result { fn find_next(s: &mut Session, p: &Value) -> Result { let re = build(p)?; + let mut context = scope(s, p)?; let st = s.doc()?; - let (start_story, start_pos) = st.selection.text.map(|t| (Some(t.story), t.range().end)).unwrap_or((None, 0)); - let mut order: Vec = st.doc.stories.keys().copied().collect(); - if let Some(ss) = start_story - && let Some(i) = order.iter().position(|x| *x == ss) - { - order.rotate_left(i); - } let mut hit = None; - for (k, sid) in order.iter().enumerate() { - let Some(story) = st.doc.story(*sid) else { continue }; - let from = if k == 0 && start_story == Some(*sid) { start_pos.min(story.len()) } else { 0 }; - if let Some(m) = re.find_at(&story.text, from) { - hit = Some((*sid, m.start(), m.end())); + while let Some((sid, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() { + let text = target_text(&st.doc, sid, &r)?; + let from = context.offset.checked_sub(r.start).ok_or_else(|| bad("find", "invalid search cursor"))?; + if text.get(from..).is_none() { + return Err(bad("find", "invalid UTF-8 search cursor")); + } + if let Some(m) = re.find_at(text, from) { + let (a, b) = (r.start + m.start(), r.start + m.end()); + // Match find_iter/captures_iter: suppress an empty match adjacent to the + // previous match's end, but keep adjacent nonempty matches. + if m.is_empty() && context.last_match_end == Some(b) { + context.advance_empty(text, m.end(), b); + continue; + } + hit = Some((sid, a, b)); + context.last_match_end = Some(b); + if m.is_empty() { + context.advance_empty(text, m.end(), b); + } else { + context.offset = b; + } break; } + context.advance_target(); } - let Some((sid, a, b)) = hit else { return Ok(Value::Null) }; + let Some((sid, a, b)) = hit else { + s.find_context = Some(context); + return Ok(Value::Null); + }; let frame = st.doc.story(sid).and_then(|x| x.frames.first().copied()); let stm = s.doc_mut()?; - stm.selection = designcraft_doc::Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None }); + stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None }); stm.revision += 1; - let _ = story::PAGE_NUMBER; + context.acknowledge(stm); + s.find_context = Some(context); Ok(json!({"story": sid.0, "start": a, "end": b})) } @@ -278,6 +459,664 @@ mod tests { s.execute("find.next", &json!({"find": "or"})).unwrap(); assert!(s.doc().unwrap().selection.text.is_some()); } + fn scope_session(text: &str) -> (Session, designcraft_doc::StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100], "content": "text", "text": text})).unwrap(); + (s, designcraft_doc::StoryId(frame["story"].as_u64().unwrap())) + } + + fn scope_select(s: &mut Session, sid: designcraft_doc::StoryId, a: usize, b: usize) { + s.execute("text.select", &json!({"story": sid.0, "anchor": a, "focus": b})).unwrap(); + } + + fn scope_hit(s: &mut Session, p: &serde_json::Value, sid: designcraft_doc::StoryId, a: usize, b: usize) { + assert_eq!(s.execute("find.next", p).unwrap(), json!({"story": sid.0, "start": a, "end": b})); + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.story, selected.range(), selected.cell), (sid, a..b, None)); + } + + #[test] + fn find_scope_first_change_stops_after_first_matching_story_and_repairs_selection() { + for (texts, first, explicit, grep, expected, count) in [ + (vec!["red red", "red"], Some(true), false, false, vec!["blue red", "red"], 1), + (vec!["keep", "red red", "red"], Some(true), false, false, vec!["keep", "blue red", "red"], 1), + (vec!["keep", "other"], Some(true), false, false, vec!["keep", "other"], 0), + (vec!["red red", "red"], Some(false), false, false, vec!["blue blue", "blue"], 3), + (vec!["red red", "red"], None, false, false, vec!["blue blue", "blue"], 3), + (vec!["red red", "red"], Some(true), true, false, vec!["blue red", "red"], 1), + (vec!["red red", "red"], Some(false), true, false, vec!["blue blue", "red"], 2), + (vec!["", "é🙂"], Some(true), false, true, vec!["X", "é🙂"], 1), + (vec!["é", "🙂"], Some(false), false, true, vec!["XéX", "X🙂X"], 4), + ] { + let (mut s, sid) = scope_session(texts[0]); + let mut sids = vec![sid]; + for text in texts.iter().skip(1) { + let frame = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": text})).unwrap(); + sids.push(designcraft_doc::StoryId(frame["story"].as_u64().unwrap())); + } + assert_eq!(s.doc().unwrap().doc.stories.keys().copied().collect::>(), sids); + // Document Change keeps canonical target order even with a later story current. + scope_select(&mut s, *sids.last().unwrap(), 0, 0); + let before = s.doc().unwrap().doc.clone(); + let mut p = json!({ + "find": if grep { "(?:)" } else { "red" }, + "change": if grep { "X" } else { "blue" }, + "scope": "document", "grep": grep + }); + if let Some(first) = first { + p["first"] = json!(first); + } + if explicit { + p["story"] = json!(sid.0); + p["scope"] = json!("selection"); + } + if first == Some(true) && !explicit && !grep && texts.last() == Some(&"red") { + let last = *sids.last().unwrap(); + scope_hit(&mut s, &p, last, 0, 3); + let first_hit = sids[if texts.len() == 2 { 0 } else { 1 }]; + assert_eq!( + s.execute("find.find", &p).unwrap(), + json!([ + {"story": first_hit.0, "start": 0, "end": 3, "text": "red"}, + {"story": first_hit.0, "start": 4, "end": 7, "text": "red"}, + {"story": last.0, "start": 0, "end": 3, "text": "red"} + ]), + "Count remains canonical after Next paints the later story" + ); + } + let selection = s.doc().unwrap().selection.clone(); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": count})); + let st = s.doc().unwrap(); + assert_eq!(st.selection, selection); + for (i, sid) in sids.iter().enumerate() { + assert_eq!(st.doc.story(*sid).unwrap().text, expected[i]); + if texts[i] == expected[i] { + assert_eq!(st.doc.story(*sid), before.story(*sid), "untouched story {i} stays entirely unchanged"); + } + } + st.doc.check().unwrap(); + } + + // Identical replacement is still one counted match, not a reason to visit another story. + let (mut s, sid) = scope_session("red red"); + let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "red"})).unwrap(); + let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, other_sid, 0, 3); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + assert_eq!( + s.execute("find.change", &json!({"find": "red", "change": "red", "scope": "document", "first": true})).unwrap(), + json!({"count": 1}) + ); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red"); + assert_eq!(s.doc().unwrap().doc.story(other_sid), before.story(other_sid)); + assert_eq!(s.doc().unwrap().selection, selection); + s.doc().unwrap().doc.check().unwrap(); + + for reverse in [false, true] { + let (mut s, sid) = scope_session("éX X"); + let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "X"})).unwrap(); + let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, sid, if reverse { 3 } else { 2 }, if reverse { 2 } else { 3 }); + let before = s.doc().unwrap().doc.clone(); + let mut selection = s.doc().unwrap().selection.clone(); + let t = selection.text.as_mut().unwrap(); + (t.anchor, t.focus) = if reverse { (6, 2) } else { (2, 6) }; + let p = json!({"find": "X", "change": "🙂", "scope": "document", "first": true}); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 3); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().text, "é🙂 X"); + assert_eq!(st.doc.story(other_sid), before.story(other_sid)); + assert_eq!(st.selection, selection, "first-match exit must follow final UTF-8 repair"); + st.doc.check().unwrap(); + assert_eq!( + s.execute("find.find", &json!({"find": "🙂", "scope": "selection"})).unwrap(), + json!([{"story": sid.0, "start": 2, "end": 6, "text": "🙂"}]) + ); + } + } + + #[test] + fn find_scope_exact_baseline_count_next_change() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s + .execute( + "frame.create", + &json!({ + "rect": [36, 36, 300, 200], "content": "text", "text": "red red" + }), + ) + .unwrap(); + let sid = frame["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); + assert_eq!( + s.execute("find.find", &json!({"find": "red", "scope": "selection"})).unwrap(), + json!([{"story": sid, "start": 0, "end": 3, "text": "red"}]) + ); + assert_eq!(s.execute("find.next", &json!({"find": "red", "scope": "selection"})).unwrap(), json!({"story": sid, "start": 0, "end": 3})); + assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "blue", "scope": "selection"})).unwrap(), json!({"count": 1})); + assert_eq!(s.execute("story.get", &json!({"story": sid})).unwrap()["text"], "blue red"); + } + + #[test] + fn find_scope_change_growth_preserves_direction_and_full_scalar_hits() { + for (text, find, change, changed, start, old_end, new_end) in [("a", "a", "é", "é", 0, 1, 2), ("éX", "X", "🙂", "é🙂", 2, 3, 6)] { + for reverse in [false, true] { + let (mut s, sid) = scope_session(text); + // Real caret movement keeps the frame identity, unlike text.select. + s.execute("text.move", &json!({"dir": "left", "extend": reverse})).unwrap(); + if !reverse { + s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); + } + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.anchor, selected.focus), if reverse { (old_end, start) } else { (start, old_end) }); + assert!(selected.frame.is_some()); + let mut expected = s.doc().unwrap().selection.clone(); + let t = expected.text.as_mut().unwrap(); + (t.anchor, t.focus) = if reverse { (new_end, start) } else { (start, new_end) }; + let p = json!({"find": find, "change": change, "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": start, "end": old_end, "text": find}])); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().text, changed); + assert_eq!(st.selection, expected); + let t = st.selection.text.unwrap(); + assert!(changed.is_char_boundary(t.anchor) && changed.is_char_boundary(t.focus)); + assert!(s.find_context.is_none()); + let p = json!({"find": change, "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": start, "end": new_end, "text": change}])); + scope_hit(&mut s, &p, sid, start, new_end); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + } + } + } + + #[test] + fn find_scope_change_repairs_final_boundaries_without_remapping_valid_offsets() { + for (text, (a, b), find, change, changed, (after_a, after_b), count) in [ + ("aX", (1, 1), "a", "é", "éX", (0, 0), 1), // A caret stays collapsed and floors. + ("aX", (2, 2), "a", "é", "éX", (2, 2), 1), // A valid caret stays at its byte offset. + ("abc", (1, 2), "abc", "🙂", "🙂", (0, 4), 1), // Both endpoints need repair. + ("abc", (2, 1), "abc", "🙂", "🙂", (4, 0), 1), + ("é🙂", (2, 6), "🙂", "X", "éX", (2, 3), 1), // Existing shortening behavior. + ("é🙂", (6, 2), "🙂", "X", "éX", (3, 2), 1), + ("red", (0, 3), "red", "blue", "blue", (0, 3), 1), // Do not extend a valid upper bound. + ("aX", (1, 2), "a", "zz", "zzX", (1, 2), 1), // Do not track the original X. + ("aaX", (1, 2), "a", "é", "ééX", (0, 2), 2), // Per-edit repair would wrongly yield 0..4. + ("aaX", (2, 3), "a", "é", "ééX", (2, 4), 2), + ("abc", (1, 3), "abc", "", "", (0, 0), 1), + ("abc", (2, 3), "abc", "é", "é", (2, 2), 1), // EOF clamp may collapse a nonempty range. + ] { + let (mut s, sid) = scope_session(text); + scope_select(&mut s, sid, a, b); + let mut expected = s.doc().unwrap().selection.clone(); + let t = expected.text.as_mut().unwrap(); + (t.anchor, t.focus) = (after_a, after_b); + assert_eq!(s.execute("find.change", &json!({"find": find, "change": change, "scope": "document"})).unwrap(), json!({"count": count})); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().text, changed, "{text} {a}->{b}"); + assert_eq!(st.selection, expected, "{text} {a}->{b}"); + assert!(changed.is_char_boundary(after_a) && changed.is_char_boundary(after_b)); + if after_a == after_b { + assert_eq!( + s.execute("find.find", &json!({"find": "(?:)", "grep": true, "scope": "selection"})).unwrap(), + json!([{"story": sid.0, "start": after_a, "end": after_a, "text": ""}]) + ); + } + } + } + + #[test] + fn find_scope_change_other_story_and_no_hits_preserve_selection() { + for explicit in [false, true] { + let (mut s, sid) = scope_session("éX"); + let frame = s.doc().unwrap().selection.text.unwrap().frame.unwrap(); + let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "a"})).unwrap(); + let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, sid, 3, 2); + let before = s.doc().unwrap().selection.clone(); + let mut p = json!({"find": "a", "change": "🙂", "scope": "document"}); + if explicit { + p["story"] = json!(other_sid.0); + } + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); + assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "🙂"); + assert_eq!(s.doc().unwrap().selection, before); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 0})); + assert_eq!(s.doc().unwrap().selection, before); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); + assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "🙂"); + + s.execute("selection.set", &json!({"ids": [frame.0]})).unwrap(); + let before = s.doc().unwrap().selection.clone(); + assert!(before.text.is_none()); + p["find"] = json!("🙂"); + p["change"] = json!("é"); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().selection, before); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); + assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "é"); + } + } + + #[test] + fn find_scope_mixed_grep_matches_count_next_and_change() { + for (text, pattern, expected, changed) in [ + ("a", "a*", vec![(0, 1)], "X"), + ("aa", "a|(?:)", vec![(0, 1), (1, 2)], "XX"), + ("ab", "a*", vec![(0, 1), (2, 2)], "XbX"), + ("éa", "é|(?:)", vec![(0, 2), (3, 3)], "XaX"), + ] { + let (mut s, sid) = scope_session(text); + scope_select(&mut s, sid, 0, text.len()); + let p = json!({"find": pattern, "grep": true, "scope": "selection", "change": "X"}); + let counted = s.execute("find.find", &p).unwrap(); + let ranges: Vec<_> = + counted.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap() as usize, m["end"].as_u64().unwrap() as usize)).collect(); + assert_eq!(ranges, expected, "{pattern} on {text}"); + for (a, b) in &expected { + scope_hit(&mut s, &p, sid, *a, *b); + } + for _ in 0..2 { + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "{pattern} on {text}"); + } + assert_eq!(s.execute("find.find", &p).unwrap(), counted); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], expected.len()); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, changed); + } + } + + #[test] + fn find_scope_capture_replacement_and_first_keep_original_domain() { + let (mut s, sid) = scope_session("a1 a2 | a3"); + scope_select(&mut s, sid, 0, 5); + let p = json!({"find": r"a(\d)", "grep": true, "scope": "selection", "change": "b$1", "first": true}); + scope_hit(&mut s, &p, sid, 0, 2); + scope_hit(&mut s, &p, sid, 3, 5); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "b1 a2 | a3"); + } + + #[test] + fn find_scope_selection_retains_nonempty_hits_count_change_and_native_text() { + for (a, b) in [(0, 7), (7, 0)] { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, a, b); + let p = json!({"find": "red", "change": "blue", "scope": "selection", "attrs": {"fontStyle": "Italic"}}); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let revision = s.doc().unwrap().revision; + let history = s.doc().unwrap().history.undo.len(); + let strong = std::sync::Arc::strong_count(&before); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.doc().unwrap().revision, revision); + assert_eq!(std::sync::Arc::strong_count(&before), strong, "search stores weak document identity"); + scope_hit(&mut s, &p, sid, 0, 3); + scope_hit(&mut s, &p, sid, 4, 7); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 2); + let changed = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!(changed.text, "blue blue | red"); + assert_eq!(changed.char_format_at(0).over.font_style.as_deref(), Some("Italic")); + assert_eq!(s.doc().unwrap().history.undo.len(), history + 1); + assert!(s.find_context.is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + assert!(s.find_context.is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap(), &changed); + let native = s.execute("file.serialize", &json!({})).unwrap(); + s.execute("file.openBytes", &json!({"name": "scope.designcraft", "base64": native["base64"]})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.story(sid).unwrap()).unwrap(), serde_json::to_value(&changed).unwrap()); + assert!(s.find_context.is_none()); + } + } + + #[test] + fn find_scope_matcher_restart_replacement_continuation_and_external_reselection() { + let (mut s, sid) = scope_session("red blue red | blue red"); + scope_select(&mut s, sid, 0, 12); + let mut p = json!({"find": "red", "scope": "selection", "change": "one"}); + scope_hit(&mut s, &p, sid, 0, 3); + p["change"] = json!("two"); + p["attrs"] = json!({"fontStyle": "Italic"}); + scope_hit(&mut s, &p, sid, 9, 12); + p["find"] = json!("blue"); + scope_hit(&mut s, &p, sid, 4, 8); + p["find"] = json!("red"); + scope_hit(&mut s, &p, sid, 0, 3); + p["caseSensitive"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); + p["grep"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); // GREP alone changes matcher identity. + p["find"] = json!("r.d"); + scope_hit(&mut s, &p, sid, 0, 3); + p["wholeWord"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); + let hit = s.doc().unwrap().selection.text.unwrap(); + scope_select(&mut s, sid, hit.anchor, hit.focus); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1, "same-range external reselection starts a new domain"); + scope_hit(&mut s, &p, sid, 0, 3); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + scope_select(&mut s, sid, 20, 23); + p["find"] = json!("red"); + scope_hit(&mut s, &p, sid, 20, 23); + } + + #[test] + fn find_scope_word_reselection_at_same_hit_recaptures_bounds() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + scope_hit(&mut s, &p, sid, 0, 3); + let selected = s.doc().unwrap().selection.clone(); + let frame = selected.text.unwrap().frame.unwrap(); + let point = { + let st = s.doc().unwrap(); + let cs = s.cache.get(&st.doc, sid, None); + let (fi, x, baseline, ascent, _) = designcraft_compose::caret(&cs, 1).unwrap(); + let inner = designcraft_geom::Point::new(x, baseline - ascent * 0.5); + assert_eq!(cs.frames[fi].frame, frame); + assert_eq!(designcraft_compose::hit(&cs, fi, inner), Some(1), "real point lies inside the current hit"); + let loc = st.doc.find(frame).unwrap(); + st.doc.parent_xf(&loc) * st.doc.text_xf(st.doc.item(frame).unwrap()) * inner + }; + let before = s.doc().unwrap().doc.clone(); + let revision = s.doc().unwrap().revision; + let history = s.doc().unwrap().history.undo.len(); + s.execute("text.selectWord", &json!({"frame": frame.0, "point": [point.x, point.y]})).unwrap(); + assert!(s.text_drag, "exercise place's in-selection early return"); + assert_eq!(s.doc().unwrap().selection, selected, "same full selection, including frame"); + assert_eq!(s.doc().unwrap().revision, revision + 1); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert!(s.find_context.is_none()); + assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + assert_eq!(s.doc().unwrap().history.undo.len(), history + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + } + + #[test] + fn find_scope_story_explicit_story_document_order_and_scope_changes() { + let (mut s, a) = scope_session("black red"); + let other = s.execute("frame.create", &json!({"rect": [150, 36, 100, 100], "content": "text", "text": "red"})).unwrap(); + let b = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, a, 0, 9); + let story = json!({"find": "red", "scope": "story"}); + assert_eq!(s.execute("find.next", &story).unwrap(), serde_json::Value::Null, "Story does not enter B or wrap to its prefix"); + scope_select(&mut s, b, 0, 0); + let explicit = json!({"find": "black", "story": a.0, "scope": "selection"}); + scope_hit(&mut s, &explicit, a, 0, 5); + let selection = s.doc().unwrap().selection.clone(); + assert!(s.execute("find.next", &json!({"find": "red", "story": u64::MAX})).is_err()); + assert_eq!(s.doc().unwrap().selection, selection); + scope_select(&mut s, a, 0, 9); + scope_hit(&mut s, &json!({"find": "red"}), b, 0, 3); + let counted = s.execute("find.find", &json!({"find": "red"})).unwrap(); + assert_eq!(counted[0]["story"], a.0, "Count retains canonical story order"); + assert_eq!(counted[1]["story"], b.0); + scope_select(&mut s, a, 0, 9); + scope_hit(&mut s, &json!({"find": "red", "scope": "selection"}), a, 6, 9); + assert_eq!(s.execute("find.next", &story).unwrap(), serde_json::Value::Null, "scope change uses the current caret"); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + for command in ["find.find", "find.next", "find.change"] { + assert!(s.execute(command, &json!({"find": "red", "change": "blue", "scope": "selection"})).is_err()); + } + } + + #[test] + fn find_scope_substring_context_utf8_zero_width_and_invalid_ranges() { + let (mut s, sid) = scope_session("scat é🙂 red"); + scope_select(&mut s, sid, 1, 4); + let p = json!({"find": "cat", "wholeWord": true, "scope": "selection"}); + scope_hit(&mut s, &p, sid, 1, 4); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + scope_select(&mut s, sid, 1, 4); + let p = json!({"find": "^c|^a", "grep": true, "scope": "selection"}); + scope_hit(&mut s, &p, sid, 1, 2); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "cursor does not redefine ^"); + scope_select(&mut s, sid, 5, 11); + let empty = json!({"find": "(?:)", "grep": true, "scope": "selection"}); + for pos in [5, 7, 11] { + scope_hit(&mut s, &empty, sid, pos, pos); + } + for _ in 0..2 { + assert_eq!(s.execute("find.next", &empty).unwrap(), serde_json::Value::Null); + } + assert_eq!(s.execute("find.find", &empty).unwrap().as_array().unwrap().len(), 3); + scope_select(&mut s, sid, 7, 7); + scope_hit(&mut s, &empty, sid, 7, 7); + assert_eq!(s.execute("find.next", &empty).unwrap(), serde_json::Value::Null); + scope_select(&mut s, sid, 12, 15); + let nonempty = json!({"find": "red", "change": "blue", "scope": "selection"}); + scope_hit(&mut s, &nonempty, sid, 12, 15); + assert_eq!(s.execute("find.change", &nonempty).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "scat é🙂 blue"); + for range in [6..7, 0..usize::MAX] { + let st = s.doc_mut().unwrap(); + st.selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { + story: sid, + anchor: range.start, + focus: range.end, + frame: None, + cell: None, + }); + st.revision += 1; + for command in ["find.find", "find.next", "find.change"] { + let before = s.doc().unwrap().doc.clone(); + let err = s.execute(command, &json!({"find": "x", "change": "y", "scope": "selection"})).unwrap_err(); + assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + } + } + } + + #[test] + fn find_scope_invalid_query_keeps_continuation_and_cross_boundary_nonmatches() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "scope": "selection"}); + scope_hit(&mut s, &p, sid, 0, 3); + let selected = s.doc().unwrap().selection.clone(); + assert!(s.execute("find.next", &json!({"find": "[", "grep": true, "scope": "selection"})).is_err()); + assert_eq!(s.doc().unwrap().selection, selected); + scope_hit(&mut s, &p, sid, 4, 7); + assert_eq!(s.execute("find.find", &json!({"find": "red |", "scope": "selection"})).unwrap(), json!([])); + } + + #[test] + fn find_scope_document_switch_edit_and_reset() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "scope": "selection"}); + scope_hit(&mut s, &p, sid, 0, 3); + s.execute("file.new", &json!({})).unwrap(); + s.set_active(0); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + scope_select(&mut s, sid, 0, 7); + scope_hit(&mut s, &p, sid, 0, 3); + s.execute("story.setText", &json!({"story": sid.0, "text": "red"})).unwrap(); + assert!(s.find_context.is_none()); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + s.reset_find_scope(); + assert!(s.find_context.is_none()); + } + + #[test] + fn find_scope_real_preview_restores_before_resolving_bounds() { + let (mut s, sid) = scope_session(""); + s.execute( + "data.source.select", + &json!({ + "bytes": crate::cmd::base64_encode(b"x\nred red | red\n"), "name": "scope.csv" + }), + ) + .unwrap(); + s.execute("data.placeholder.add", &json!({"field": "x", "story": sid.0, "at": 0})).unwrap(); + let template = s.doc().unwrap().doc.clone(); + assert_eq!(template.story(sid).unwrap().text, "<>"); + s.execute("data.preview", &json!({"record": 1})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + // Install a viewport selection in the real preview. text.select is journaled + // and would itself restore the template before selecting. + let st = s.doc_mut().unwrap(); + st.selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: 0, focus: 7, frame: None, cell: None }); + st.revision += 1; + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + let history = s.doc().unwrap().history.undo.len(); + let err = s.execute("find.change", &p).unwrap_err(); + assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); + assert!(std::sync::Arc::ptr_eq(&template, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "<>"); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert!(s.doc().unwrap().preview_stash.is_none()); + assert!(s.find_context.is_none()); + // Valid fresh bounds are allowed after restoration; no cached preview range + // survives. A document query can see the preview, but Next sees the template. + s.execute("data.preview", &json!({"record": 1})).unwrap(); + let document = json!({"find": "red"}); + assert_eq!(s.execute("find.find", &document).unwrap().as_array().unwrap().len(), 3); + assert_eq!(s.execute("find.next", &document).unwrap(), serde_json::Value::Null); + assert!(std::sync::Arc::ptr_eq(&template, &s.doc().unwrap().doc)); + assert_eq!(s.execute("find.find", &document).unwrap(), json!([])); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + } + + fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) { + let (mut s, sid) = scope_session("red"); + scope_select(&mut s, sid, 3, 3); + if note { + s.execute("footnote.insert", &json!({"text": child})).unwrap(); + } else { + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("text.insert", &json!({"text": child, "raw": true})).unwrap(); + } + let selected = s.doc().unwrap().selection.text.unwrap(); + (s, sid, selected) + } + + #[test] + fn find_scope_children_error_atomically_and_parent_requests_stay_top_level() { + for note in [false, true] { + for child in ["red", "red x", "é red red red red red"] { + let (mut s, sid, mut selected) = child_scope_session(note, child); + selected.anchor = 0; + selected.focus = child.len(); + s.doc_mut().unwrap().selection = designcraft_doc::Selection::text(selected); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let history = s.doc().unwrap().history.undo.len(); + let revision = s.doc().unwrap().revision; + let journal = s.journal.clone(); + for scope in ["story", "selection"] { + for command in ["find.find", "find.next", "find.change"] { + let err = s.execute(command, &json!({"find": "red", "change": "blue", "scope": scope})).unwrap_err(); + assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); + assert!(err.to_string().contains("not supported")); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert_eq!(s.doc().unwrap().revision, revision); + assert_eq!(s.journal, journal); + } + } + for explicit in [false, true] { + let mut p = json!({"find": "red", "change": "blue"}); + if explicit { + p["story"] = json!(sid.0); + p["scope"] = json!("selection"); + } + s.doc_mut().unwrap().selection = selection.clone(); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + scope_hit(&mut s, &p, sid, 0, 3); + s.doc_mut().unwrap().selection = selection.clone(); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, child); + assert_eq!(s.doc().unwrap().selection, selection, "parent replacement does not clamp child offsets"); + p["find"] = json!("blue"); + p["change"] = json!("red"); + s.execute("find.change", &p).unwrap(); + } + } + } + let (mut s, _, _) = child_scope_session(false, "red"); + s.execute("table.selectTable", &json!({})).unwrap(); + for scope in ["story", "selection"] { + for command in ["find.find", "find.next", "find.change"] { + assert!( + s.execute(command, &json!({"find": "red", "change": "blue", "scope": scope})).unwrap_err().to_string().contains("not supported") + ); + } + } + } + + #[test] + fn find_scope_threaded_overset_story_uses_logical_offsets() { + let text = format!("{}red", "word ".repeat(100)); + let (mut s, sid) = scope_session(&text); + let a = s.doc().unwrap().doc.story(sid).unwrap().frames[0]; + let next = s.execute("frame.create", &json!({"rect": [150, 36, 20, 1], "content": "text"})).unwrap(); + let b = designcraft_doc::ItemId(next["id"].as_u64().unwrap()); + s.edit(|d, _| Ok(d.thread(a, b)?)).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().frames.len(), 2); + scope_select(&mut s, sid, 0, 0); + scope_hit(&mut s, &json!({"find": "red", "scope": "story"}), sid, 500, 503); + assert_eq!(s.execute("find.next", &json!({"find": "red", "scope": "story"})).unwrap(), serde_json::Value::Null); + } + + #[test] + fn find_scope_zero_width_story_explicit_and_document_endpoints_terminate() { + for scope in ["story", "explicit", "document"] { + let (mut s, sid) = scope_session("é🙂"); + scope_select(&mut s, sid, 0, 0); + let mut p = json!({"find": "(?:)", "grep": true, "scope": scope}); + if scope == "explicit" { + p["story"] = json!(sid.0); + } + for pos in [0, 2, 6] { + scope_hit(&mut s, &p, sid, pos, pos); + } + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn find_scope_revert_releases_context_with_same_document_uid() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("scope.designcraft"); + let (mut s, sid) = scope_session("red red | red"); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "scope": "selection"}); + scope_hit(&mut s, &p, sid, 0, 3); + let uid = s.doc().unwrap().uid; + s.execute("story.setText", &json!({"story": sid.0, "text": "red red"})).unwrap(); + scope_select(&mut s, sid, 0, 7); + scope_hit(&mut s, &p, sid, 0, 3); + s.execute("file.revert", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().uid, uid); + assert!(s.find_context.is_none()); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + assert!(s.execute("find.find", &p).is_err(), "revert's empty selection cannot inherit a previous range"); + } } /// Objects matching Find/Change › Object criteria, in document order (nested ones included). diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 31e4544f..a0a6ac32 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -15,7 +15,8 @@ mod editorial; mod endnotes; mod export; mod file; -mod find; +pub(crate) mod find; +pub use find::same_find_domain; mod fonts; mod guides; mod index; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b57e9e88..9c05ecfa 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -29,6 +29,8 @@ pub fn specs() -> Vec { let text = &st.doc.text_story(t.story, t.cell).map(|x| x.text.clone()).unwrap_or_default(); let (a, b) = word_bounds(text, t.focus); st.selection.text = Some(TextSel { anchor: a, focus: b, ..t }); + // A word reselection is external even when place kept the same range for dragging. + st.revision = st.revision.wrapping_add(1); ok() }), cmd!(noundo "text.selectSentence", "Select Sentence", [], None, "{frame, point} — the sentence under the point (triple click)", has_doc, |s, p| { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index caeeb80b..3064592f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -260,6 +260,7 @@ pub struct Session { pub cache: Arc, pub(crate) tool: Box, pub journal: Vec<(String, Value)>, + pub(crate) find_context: Option, pub clipboard: Option>, /// Crash-recovery folder (the app sets it; saving or closing a document removes its entry). pub recovery_dir: Option, @@ -300,6 +301,7 @@ impl Session { cache: Arc::new(Cache::new()), tool: designcraft_tools::create("selection"), journal: vec![], + find_context: None, clipboard: None, recovery_dir: None, text_clipboard: None, @@ -358,13 +360,26 @@ impl Session { pub fn doc_mut(&mut self) -> Result<&mut DocState> { self.active_mut().ok_or(EngineError::NoDocument) } + /// Release the transient Find/Change domain when its caller starts or closes a search. + pub fn reset_find_scope(&mut self) { + self.find_context = None; + } + + fn discard_stale_find_scope(&mut self) { + if self.find_context.as_ref().is_some_and(|c| self.active().is_none_or(|st| !c.is_current(st))) { + self.reset_find_scope(); + } + } + pub fn set_active(&mut self, i: usize) { if i < self.docs.len() && self.active != Some(i) { + self.reset_find_scope(); self.stop_data_preview(); self.active = Some(i); } } pub fn add_document(&mut self, d: DocState) -> usize { + self.reset_find_scope(); self.stop_data_preview(); self.docs.push(d); self.active = Some(self.docs.len() - 1); @@ -388,6 +403,7 @@ impl Session { recovery::discard_document(self, i, recovery_dir.as_deref()); } if i < self.docs.len() { + self.reset_find_scope(); self.docs.remove(i); self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) }; } @@ -418,6 +434,8 @@ impl Session { if id != "data.preview" && (spec.undoable || spec.journal) { self.stop_data_preview(); } + // Preview restoration can replace the document before a Find handler resolves bounds. + self.discard_stale_find_scope(); let before = self.active().map(|d| (d.uid, d.doc.clone())); let r = (spec.run)(self, params)?; // Undo, redo or deleting layers can take the active layer away: new objects would land on @@ -455,6 +473,7 @@ impl Session { if spec.journal { self.journal.push((id.to_string(), params.clone())); } + self.discard_stale_find_scope(); Ok(r) } @@ -542,6 +561,7 @@ impl Session { st.doc = Arc::new(doc); st.selection = sel; st.revision += 1; + self.reset_find_scope(); Ok(r) } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 5a4de005..c66bc927 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -288,13 +288,19 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> "ui.dialog.set" => match app.ui.dialog.as_mut() { Some(d) => { let Some(f) = s("field") else { return err("missing `field`") }; - d.fields.insert(f.to_string(), p.get("value").cloned().unwrap_or(Value::Null)); + let value = p.get("value").cloned().unwrap_or(Value::Null); + if d.set_field(f, value) { + app.session.reset_find_scope(); + } ok(serde_json::to_value(&*d).unwrap_or_default()) } None => err("no dialog open"), }, "ui.dialog.open" => { let Some(id) = s("id") else { return err("missing `id`") }; + if id == "findChange" || app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { + app.session.reset_find_scope(); + } let d = crate::dialogs::Dialog::new(id, p.get("fields").cloned().unwrap_or(Value::Null)); let v = serde_json::to_value(&d).unwrap_or_default(); app.ui.dialog = Some(d); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 695a66af..396cd250 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -77,6 +77,16 @@ impl Dialog { } Dialog { id: id.into(), fields } } + /// Set a field and report a genuine Find domain/mode transition to the caller. + pub(crate) fn set_field(&mut self, key: &str, value: Value) -> bool { + let before = + (self.id == "findChange" && matches!(key, "scope" | "story" | "objectMode")).then(|| (self.b("objectMode"), find_change_params(self))); + self.fields.insert(key.into(), value); + before.is_some_and(|(object_mode, params)| { + object_mode != self.b("objectMode") || !designcraft_engine::cmd::same_find_domain(¶ms, &find_change_params(self)) + }) + } + fn s(&self, k: &str) -> String { match self.fields.get(k) { Some(Value::String(s)) => s.clone(), @@ -1181,536 +1191,640 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { // forms remain reachable at large UI scales, without shrinking their text. let footer_height = ui.spacing().interact_size.y.max(ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y); let body_height = (available.y - ui.min_size().y - footer_height - 12.0 - 2.0 * ui.spacing().item_spacing.y).max(1.0); - egui::ScrollArea::both().id_salt(("dialog_body", &d.id)).min_scrolled_width(1.0).min_scrolled_height(1.0).max_width(width).max_height(body_height).show(ui, |ui| { - match d.id.as_str() { - "newDocument" => { - ui.horizontal(|ui| { - ui.with_layout(if crate::i18n::is_rtl(&app.ui.language) { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) }, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Preset")); - let cur = d.s("preset"); - egui::ComboBox::from_id_salt("preset").selected_text(&cur).width(180.0).show_ui(ui, |ui| { - for p in designcraft_doc::build::PRESETS { - if ui.selectable_label(cur == p.name, p.name).clicked() { - d.fields.insert("preset".into(), json!(p.name)); - d.fields.insert("width".into(), json!(format_measure(p.width, p.units))); - d.fields.insert("height".into(), json!(format_measure(p.height, p.units))); - d.fields.insert("facingPages".into(), json!(p.intent == designcraft_doc::Intent::Print)); - } - } + egui::ScrollArea::both() + .id_salt(("dialog_body", &d.id)) + .min_scrolled_width(1.0) + .min_scrolled_height(1.0) + .max_width(width) + .max_height(body_height) + .show(ui, |ui| match d.id.as_str() { + "newDocument" => { + ui.horizontal(|ui| { + ui.with_layout( + if crate::i18n::is_rtl(&app.ui.language) { + egui::Layout::right_to_left(egui::Align::Center) + } else { + egui::Layout::left_to_right(egui::Align::Center) + }, + |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Preset")); + let cur = d.s("preset"); + egui::ComboBox::from_id_salt("preset").selected_text(&cur).width(180.0).show_ui(ui, |ui| { + for p in designcraft_doc::build::PRESETS { + if ui.selectable_label(cur == p.name, p.name).clicked() { + d.fields.insert("preset".into(), json!(p.name)); + d.fields.insert("width".into(), json!(format_measure(p.width, p.units))); + d.fields.insert("height".into(), json!(format_measure(p.height, p.units))); + d.fields.insert("facingPages".into(), json!(p.intent == designcraft_doc::Intent::Print)); + } + } + }); + }, + ); }); - }); - }); - if crate::i18n::is_rtl(&app.ui.language) { - // egui Grid currently supports LTR only. Use explicit RTL rows; - // the numeric editors retain LTR alignment and logical values. - for (first, key, second, other, toggle) in [ - ("Width", "width", "Height", "height", false), - ("Pages", "pages", "Facing Pages", "facingPages", true), - ("Columns", "columns", "Gutter", "gutter", false), - ("Top", "marginTop", "Bottom", "marginBottom", false), - ("Inside", "marginInside", "Outside", "marginOutside", false), - ("Bleed", "bleed", "Primary Text Frame", "primaryTextFrame", true), - ] { - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, first)); - text_field(ui, &mut d, key, 80.0); - if toggle { - check(ui, &mut d, other, crate::i18n::tr(&app.ui.language, second)); - } else { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, second)); - text_field(ui, &mut d, other, 80.0); - } + if crate::i18n::is_rtl(&app.ui.language) { + // egui Grid currently supports LTR only. Use explicit RTL rows; + // the numeric editors retain LTR alignment and logical values. + for (first, key, second, other, toggle) in [ + ("Width", "width", "Height", "height", false), + ("Pages", "pages", "Facing Pages", "facingPages", true), + ("Columns", "columns", "Gutter", "gutter", false), + ("Top", "marginTop", "Bottom", "marginBottom", false), + ("Inside", "marginInside", "Outside", "marginOutside", false), + ("Bleed", "bleed", "Primary Text Frame", "primaryTextFrame", true), + ] { + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, first)); + text_field(ui, &mut d, key, 80.0); + if toggle { + check(ui, &mut d, other, crate::i18n::tr(&app.ui.language, second)); + } else { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, second)); + text_field(ui, &mut d, other, 80.0); + } + }); }); + } + } else { + egui::Grid::new("nd").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width")); + text_field(ui, &mut d, "width", 80.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height")); + text_field(ui, &mut d, "height", 80.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Pages")); + text_field(ui, &mut d, "pages", 80.0); + ui.label(""); + check(ui, &mut d, "facingPages", crate::i18n::tr(&app.ui.language, "Facing Pages")); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Columns")); + text_field(ui, &mut d, "columns", 80.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Gutter")); + text_field(ui, &mut d, "gutter", 80.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Top")); + text_field(ui, &mut d, "marginTop", 80.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bottom")); + text_field(ui, &mut d, "marginBottom", 80.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Inside")); + text_field(ui, &mut d, "marginInside", 80.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Outside")); + text_field(ui, &mut d, "marginOutside", 80.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bleed")); + text_field(ui, &mut d, "bleed", 80.0); + ui.label(""); + check(ui, &mut d, "primaryTextFrame", crate::i18n::tr(&app.ui.language, "Primary Text Frame")); + ui.end_row(); }); } - } else { - egui::Grid::new("nd").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width")); - text_field(ui, &mut d, "width", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height")); - text_field(ui, &mut d, "height", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Pages")); - text_field(ui, &mut d, "pages", 80.0); - ui.label(""); - check(ui, &mut d, "facingPages", crate::i18n::tr(&app.ui.language, "Facing Pages")); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Columns")); - text_field(ui, &mut d, "columns", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Gutter")); - text_field(ui, &mut d, "gutter", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Top")); - text_field(ui, &mut d, "marginTop", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bottom")); - text_field(ui, &mut d, "marginBottom", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Inside")); - text_field(ui, &mut d, "marginInside", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Outside")); - text_field(ui, &mut d, "marginOutside", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bleed")); - text_field(ui, &mut d, "bleed", 80.0); - ui.label(""); - check(ui, &mut d, "primaryTextFrame", crate::i18n::tr(&app.ui.language, "Primary Text Frame")); - ui.end_row(); - }); } - } - "frameSize" => { - egui::Grid::new("fs").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width")); - text_field(ui, &mut d, "width", 90.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height")); - text_field(ui, &mut d, "height", 90.0); - ui.end_row(); - }); - } - "findChange" => { - ui.horizontal(|ui| { - for (label, grep) in [("Text", false), ("GREP", true)] { - if ui.selectable_label(!d.b("objectMode") && d.b("grep") == grep, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - d.fields.insert("grep".into(), json!(grep)); - d.fields.insert("objectMode".into(), json!(false)); + "frameSize" => { + egui::Grid::new("fs").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width")); + text_field(ui, &mut d, "width", 90.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height")); + text_field(ui, &mut d, "height", 90.0); + ui.end_row(); + }); + } + "findChange" => { + ui.horizontal(|ui| { + for (label, grep) in [("Text", false), ("GREP", true)] { + if ui + .selectable_label( + !d.b("objectMode") && d.b("grep") == grep, + crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label)), + ) + .clicked() + { + if d.set_field("objectMode", json!(false)) { + app.session.reset_find_scope(); + } + d.fields.insert("grep".into(), json!(grep)); + } + } + if ui.selectable_label(d.b("objectMode"), crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Object"))).clicked() + && d.set_field("objectMode", json!(true)) + { + app.session.reset_find_scope(); + } + }); + if d.b("objectMode") { + // Find/Change › Object: objects by fill and kind; change their fill or opacity. + let swatches: Vec = app + .session + .active() + .map(|st| st.doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect()) + .unwrap_or_default(); + egui::Grid::new("fco").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, any) in [("objFill", "Find fill:", "(any)"), ("objChangeFill", "Change fill to:", "(unchanged)")] { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); + let cur = d.s(key); + egui::ComboBox::from_id_salt(key) + .selected_text(if cur.is_empty() { any } else { cur.as_str() }) + .width(180.0) + .show_ui(ui, |ui| { + if ui + .selectable_label(cur.is_empty(), crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, any))) + .clicked() + { + d.fields.insert(key.into(), json!("")); + } + for w in &swatches { + if ui.selectable_label(*w == cur, w).clicked() { + d.fields.insert(key.into(), json!(w)); + } + } + }); + ui.end_row(); + } + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Kind:")); + let cur = d.s("objKind"); + egui::ComboBox::from_id_salt("objKind").selected_text(if cur.is_empty() { "(any)" } else { cur.as_str() }).show_ui( + ui, + |ui| { + for v in ["", "text", "graphic", "shape", "line", "group"] { + if ui + .selectable_label( + cur == v, + crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if v.is_empty() { "(any)" } else { v })), + ) + .clicked() + { + d.fields.insert("objKind".into(), json!(v)); + } + } + }, + ); + ui.end_row(); + }); + let mut crit = json!({}); + if !d.s("objFill").is_empty() { + crit["fill"] = json!(d.s("objFill")); + } + if !d.s("objKind").is_empty() { + crit["kind"] = json!(d.s("objKind")); + } + ui.horizontal(|ui| { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find All"))).clicked() { + let n = app.run("find.objects", crit.clone()).ok().and_then(|r| r["ids"].as_array().map(Vec::len)).unwrap_or(0); + d.fields.insert("status".into(), json!(format!("{n} found"))); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change All"))).clicked() + && !d.s("objChangeFill").is_empty() + { + let mut p = crit.clone(); + p["change"] = json!({"fill": d.s("objChangeFill")}); + let n = app.run("find.changeObjects", p).ok().and_then(|r| r["changed"].as_u64()).unwrap_or(0); + d.fields.insert("status".into(), json!(format!("{n} changed"))); + } + }); + ui.label(d.s("status")); + } else { + egui::Grid::new("fc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); + text_field(ui, &mut d, "find", 260.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); + text_field(ui, &mut d, "change", 260.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); + let cur = d.s("scope"); + egui::ComboBox::from_id_salt("fcscope") + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))) + .show_ui(ui, |ui| { + for v in ["document", "story", "selection"] { + if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, v))).clicked() + && d.set_field("scope", json!(v)) + { + app.session.reset_find_scope(); + } + } + }); + ui.end_row(); + }); + ui.horizontal(|ui| { + check(ui, &mut d, "caseSensitive", crate::i18n::tr(&app.ui.language, "Case sensitive")); + check(ui, &mut d, "wholeWord", crate::i18n::tr(&app.ui.language, "Whole word")); + }); + ui.horizontal(|ui| { + let params0 = find_change_params(&d); + let params = || params0.clone(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find Next"))).clicked() { + let r = app.run("find.next", params()); + let msg = match r { + Ok(Value::Null) => "No matches".to_string(), + Ok(_) => "Found".to_string(), + Err(e) => e, + }; + d.fields.insert("status".into(), json!(msg)); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change All"))).clicked() { + let msg = match app.run("find.change", params()) { + Ok(v) => format!("{} replacement(s) made", v["count"]), + Err(e) => e, + }; + d.fields.insert("status".into(), json!(msg)); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Count"))).clicked() { + let msg = match app.run("find.find", params()) { + Ok(v) => format!("{} match(es)", v.as_array().map(|a| a.len()).unwrap_or(0)), + Err(e) => e, + }; + d.fields.insert("status".into(), json!(msg)); + } + }); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); } } - if ui.selectable_label(d.b("objectMode"),crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Object"))).clicked() { - d.fields.insert("objectMode".into(), json!(true)); + } + "paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d), + "characterStyleOptions" => character_style_options(app, ui, &mut d), + "footnoteOptions" => footnote_options(app, ui, &mut d), + "paragraphRules" => { + let current = d.fields.get("current").cloned().unwrap_or_default(); + paragraph_rules(app, ui, &mut d, "", ¤t); + } + "insertXref" => insert_xref(app, ui, &mut d), + "findFont" => find_font(app, ui, &mut d), + "colorPicker" => color_picker(ui, &mut d), + "preferences" => preferences(app, ui, &mut d, body_height), + "print" => print_dialog(app, ui, &mut d), + "pdfImport" => { + ui.label(egui::RichText::new(if d.b("async") { d.s("name") } else { d.s("path") }).size(11.0)); + ui.horizontal(|ui| { + crate::rtl::label( + ui, + crate::i18n::tr(&app.ui.language, "Page (1–{count}):").replace("{count}", &d.n("pages").unwrap_or(1.0).to_string()), + ); + text_field(ui, &mut d, "page", 60.0); + }); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Crop to:")); + let cur = d.s("crop"); + let label = |v: &str| match v { + "trim" => crate::i18n::tr(&app.ui.language, "Trim"), + "bleed" => crate::i18n::tr(&app.ui.language, "Bleed"), + "art" => crate::i18n::tr(&app.ui.language, "Art"), + "media" => crate::i18n::tr(&app.ui.language, "Media"), + _ => crate::i18n::tr(&app.ui.language, "Crop"), + }; + egui::ComboBox::from_id_salt("pdf_crop").selected_text(crate::rtl::widget(ui, label(&cur))).show_ui(ui, |ui| { + for v in ["crop", "trim", "bleed", "art", "media"] { + if ui.selectable_label(label(&cur) == label(v), label(v)).clicked() { + d.fields.insert("crop".into(), json!(v)); + } + } + }); + }); + } + "layerOptions" => { + let mut layers = d.fields.get("layers").and_then(Value::as_array).cloned().unwrap_or_default(); + if layers.is_empty() { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "This PDF has no layers.")); } - }); - if d.b("objectMode") { - // Find/Change › Object: objects by fill and kind; change their fill or opacity. - let swatches: Vec = app.session.active().map(|st| st.doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect()).unwrap_or_default(); - egui::Grid::new("fco").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (key, label, any) in [("objFill", "Find fill:", "(any)"), ("objChangeFill", "Change fill to:", "(unchanged)")] { + for l in &mut layers { + let mut on = l["visible"].as_bool().unwrap_or(true); + if ui.checkbox(&mut on, l["name"].as_str().unwrap_or("")).changed() { + l["visible"] = json!(on); + } + } + d.fields.insert("layers".into(), json!(layers)); + } + "colorSettings" => { + let profiles = d.fields.get("profiles").and_then(Value::as_array).cloned().unwrap_or_default(); + egui::Grid::new("color_settings").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, kind) in [("rgb", "RGB:", "rgb"), ("cmyk", "CMYK:", "cmyk")] { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); let cur = d.s(key); - egui::ComboBox::from_id_salt(key).selected_text(if cur.is_empty() { any } else { cur.as_str() }).width(180.0).show_ui(ui, |ui| { - if ui.selectable_label(cur.is_empty(), crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, any))).clicked() { - d.fields.insert(key.into(), json!("")); - } - for w in &swatches { - if ui.selectable_label(*w == cur, w).clicked() { - d.fields.insert(key.into(), json!(w)); + egui::ComboBox::from_id_salt(("ws", key)).selected_text(&cur).width(260.0).show_ui(ui, |ui| { + for pr in profiles.iter().filter(|p| p["kind"] == kind) { + let name = pr["name"].as_str().unwrap_or(""); + if ui.selectable_label(name == cur, name).clicked() { + d.fields.insert(key.into(), json!(name)); } } }); ui.end_row(); } - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Kind:")); - let cur = d.s("objKind"); - egui::ComboBox::from_id_salt("objKind").selected_text(if cur.is_empty() { "(any)" } else { cur.as_str() }).show_ui(ui, |ui| { - for v in ["", "text", "graphic", "shape", "line", "group"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if v.is_empty() { "(any)" } else { v }))).clicked() { - d.fields.insert("objKind".into(), json!(v)); - } - } - }); - ui.end_row(); - }); - let mut crit = json!({}); - if !d.s("objFill").is_empty() { - crit["fill"] = json!(d.s("objFill")); - } - if !d.s("objKind").is_empty() { - crit["kind"] = json!(d.s("objKind")); - } - ui.horizontal(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find All"))).clicked() { - let n = app.run("find.objects", crit.clone()).ok().and_then(|r| r["ids"].as_array().map(Vec::len)).unwrap_or(0); - d.fields.insert("status".into(), json!(format!("{n} found"))); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change All"))).clicked() && !d.s("objChangeFill").is_empty() { - let mut p = crit.clone(); - p["change"] = json!({"fill": d.s("objChangeFill")}); - let n = app.run("find.changeObjects", p).ok().and_then(|r| r["changed"].as_u64()).unwrap_or(0); - d.fields.insert("status".into(), json!(format!("{n} changed"))); - } - }); - ui.label(d.s("status")); - } else { - egui::Grid::new("fc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); - text_field(ui, &mut d, "find", 260.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); - text_field(ui, &mut d, "change", 260.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); - let cur = d.s("scope"); - egui::ComboBox::from_id_salt("fcscope").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))).show_ui(ui, |ui| { - for v in ["document", "story", "selection"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, v))).clicked() { - d.fields.insert("scope".into(), json!(v)); - } - } - }); - ui.end_row(); - }); - ui.horizontal(|ui| { - check(ui, &mut d, "caseSensitive", crate::i18n::tr(&app.ui.language, "Case sensitive")); - check(ui, &mut d, "wholeWord", crate::i18n::tr(&app.ui.language, "Whole word")); - }); - ui.horizontal(|ui| { - let params0 = json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")}); - let params = || params0.clone(); - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find Next"))).clicked() { - let r = app.run("find.next", params()); - let msg = match r { - Ok(Value::Null) => "No matches".to_string(), - Ok(_) => "Found".to_string(), - Err(e) => e, - }; - d.fields.insert("status".into(), json!(msg)); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change All"))).clicked() { - let msg = match app.run("find.change", params()) { - Ok(v) => format!("{} replacement(s) made", v["count"]), - Err(e) => e, - }; - d.fields.insert("status".into(), json!(msg)); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Count"))).clicked() { - let msg = match app.run("find.find", params()) { - Ok(v) => format!("{} match(es)", v.as_array().map(|a| a.len()).unwrap_or(0)), - Err(e) => e, - }; - d.fields.insert("status".into(), json!(msg)); - } - }); - if let Some(st) = d.fields.get("status").and_then(Value::as_str) { - ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); - } - } - } - "paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d), - "characterStyleOptions" => character_style_options(app, ui, &mut d), - "footnoteOptions" => footnote_options(app, ui, &mut d), - "paragraphRules" => { - let current = d.fields.get("current").cloned().unwrap_or_default(); - paragraph_rules(app, ui, &mut d, "", ¤t); - } - "insertXref" => insert_xref(app, ui, &mut d), - "findFont" => find_font(app, ui, &mut d), - "colorPicker" => color_picker(ui, &mut d), - "preferences" => preferences(app, ui, &mut d, body_height), - "print" => print_dialog(app, ui, &mut d), - "pdfImport" => { - ui.label(egui::RichText::new(if d.b("async") { d.s("name") } else { d.s("path") }).size(11.0)); - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page (1–{count}):").replace("{count}", &d.n("pages").unwrap_or(1.0).to_string())); - text_field(ui, &mut d, "page", 60.0); - }); - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Crop to:")); - let cur = d.s("crop"); - let label = |v: &str| match v { - "trim" => crate::i18n::tr(&app.ui.language, "Trim"), - "bleed" => crate::i18n::tr(&app.ui.language, "Bleed"), - "art" => crate::i18n::tr(&app.ui.language, "Art"), - "media" => crate::i18n::tr(&app.ui.language, "Media"), - _ => crate::i18n::tr(&app.ui.language, "Crop"), - }; - egui::ComboBox::from_id_salt("pdf_crop").selected_text(crate::rtl::widget(ui, label(&cur))).show_ui(ui, |ui| { - for v in ["crop", "trim", "bleed", "art", "media"] { - if ui.selectable_label(label(&cur) == label(v), label(v)).clicked() { - d.fields.insert("crop".into(), json!(v)); - } - } - }); - }); - } - "layerOptions" => { - let mut layers = d.fields.get("layers").and_then(Value::as_array).cloned().unwrap_or_default(); - if layers.is_empty() { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "This PDF has no layers.")); - } - for l in &mut layers { - let mut on = l["visible"].as_bool().unwrap_or(true); - if ui.checkbox(&mut on, l["name"].as_str().unwrap_or("")).changed() { - l["visible"] = json!(on); - } - } - d.fields.insert("layers".into(), json!(layers)); - } - "colorSettings" => { - let profiles = d.fields.get("profiles").and_then(Value::as_array).cloned().unwrap_or_default(); - egui::Grid::new("color_settings").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (key, label, kind) in [("rgb", "RGB:", "rgb"), ("cmyk", "CMYK:", "cmyk")] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); - let cur = d.s(key); - egui::ComboBox::from_id_salt(("ws", key)).selected_text(&cur).width(260.0).show_ui(ui, |ui| { - for pr in profiles.iter().filter(|p| p["kind"] == kind) { - let name = pr["name"].as_str().unwrap_or(""); - if ui.selectable_label(name == cur, name).clicked() { - d.fields.insert(key.into(), json!(name)); - } - } - }); - ui.end_row(); - } - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Intent:")); - let cur = d.s("intent"); - egui::ComboBox::from_id_salt("ws_intent") - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, designcraft_color::cms::Intent::parse(&cur).map_or("", |i| i.label())))) - .show_ui(ui, |ui| { - for i in designcraft_color::cms::Intent::ALL { - if ui.selectable_label(i.id() == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, i.label()))).clicked() { - d.fields.insert("intent".into(), json!(i.id())); - } - } - }); - ui.end_row(); - }); - check(ui, &mut d, "bpc", crate::i18n::tr(&app.ui.language, "Use Black Point Compensation")); - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load Profile…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("icc")) - { - match app.session.execute("color.loadProfile", &json!({"path": path})) { - Ok(_) => { - let f = app.session.execute("color.settings", &json!({})).unwrap_or_default(); - d.fields.insert("profiles".into(), f["profiles"].clone()); - } - Err(e) => app.status(format!("Color Settings: {e}")), - } - } - } - "keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d), - "userDictionary" => user_dictionary(app, ui, &mut d), - "menus" => { - text_field(ui, &mut d, "query", 260.0); - let q = d.s("query").to_lowercase(); - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for (menu, label) in crate::menus::all_menu_items().into_iter().filter(|(m, l)| q.is_empty() || m.to_lowercase().contains(&q) || l.to_lowercase().contains(&q)) { - let key = crate::menus::menu_key(&menu, &label); - let mut visible = !app.ui.hidden_menu_items.contains(&key); - if ui.checkbox(&mut visible, crate::rtl::widget(ui, format!("{} › {}", crate::i18n::tr(&app.ui.language, &menu), crate::i18n::tr(&app.ui.language, &label)))).changed() { - let _ = app.run("window.hideMenuItem", json!({"item": key, "hidden": !visible})); - } - } - }); - } - "importOptions" => { - ui.label(egui::RichText::new(d.s("path")).size(10.5)); - check(ui, &mut d, "removeStyles", crate::i18n::tr(&app.ui.language, "Remove Styles and Formatting from Text and Tables")); - if !d.b("removeStyles") { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Name Conflicts")).font(semibold(12.0))); - let conflicts: Vec = d.fields.get("conflicts").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); - ui.label(if conflicts.is_empty() { crate::i18n::tr(&app.ui.language, "No conflicts").to_string() } else { format!("{} conflict(s): {}", conflicts.len(), conflicts.join(", ")) }); - let cur = d.s("styleConflicts"); - ui.horizontal(|ui| { - for (v, l) in [("useExisting", "Use Document Style Definition"), ("redefine", "Redefine Document Style"), ("autoRename", "Auto Rename")] { - if ui.radio(cur == v, l).clicked() { - d.fields.insert("styleConflicts".into(), json!(v)); - } - } - }); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Mapping")).font(semibold(12.0))); - let names: Vec = app.session.active().map(|s| s.doc.styles.paragraph.iter().map(|p| p.name.clone()).collect()).unwrap_or_default(); - let imported: Vec = d.fields.get("styles").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); - egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { - egui::Grid::new("imp_map").num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| { - for w in &imported { - ui.label(w); - let mapped = d.fields.get("map").and_then(|m| m.get(w)).and_then(Value::as_str).unwrap_or("").to_string(); - let shown = if mapped.is_empty() { "(import)".to_string() } else { mapped.clone() }; - egui::ComboBox::from_id_salt(("imp_map", w)).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui(ui, |ui| { - if ui.selectable_label(mapped.is_empty(),crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "(import)"))).clicked() - && let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut) + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Intent:")); + let cur = d.s("intent"); + egui::ComboBox::from_id_salt("ws_intent") + .selected_text(crate::rtl::widget( + ui, + crate::i18n::tr(&app.ui.language, designcraft_color::cms::Intent::parse(&cur).map_or("", |i| i.label())), + )) + .show_ui(ui, |ui| { + for i in designcraft_color::cms::Intent::ALL { + if ui + .selectable_label(i.id() == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, i.label()))) + .clicked() { - m.remove(w); + d.fields.insert("intent".into(), json!(i.id())); } - for n in &names { - if ui.selectable_label(*n == mapped, n).clicked() - && let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut) - { - m.insert(w.clone(), json!(n)); - } + } + }); + ui.end_row(); + }); + check(ui, &mut d, "bpc", crate::i18n::tr(&app.ui.language, "Use Black Point Compensation")); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load Profile…"))).clicked() + && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("icc")) + { + match app.session.execute("color.loadProfile", &json!({"path": path})) { + Ok(_) => { + let f = app.session.execute("color.settings", &json!({})).unwrap_or_default(); + d.fields.insert("profiles".into(), f["profiles"].clone()); + } + Err(e) => app.status(format!("Color Settings: {e}")), + } + } + } + "keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d), + "userDictionary" => user_dictionary(app, ui, &mut d), + "menus" => { + text_field(ui, &mut d, "query", 260.0); + let q = d.s("query").to_lowercase(); + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for (menu, label) in crate::menus::all_menu_items() + .into_iter() + .filter(|(m, l)| q.is_empty() || m.to_lowercase().contains(&q) || l.to_lowercase().contains(&q)) + { + let key = crate::menus::menu_key(&menu, &label); + let mut visible = !app.ui.hidden_menu_items.contains(&key); + if ui + .checkbox( + &mut visible, + crate::rtl::widget( + ui, + format!("{} › {}", crate::i18n::tr(&app.ui.language, &menu), crate::i18n::tr(&app.ui.language, &label)), + ), + ) + .changed() + { + let _ = app.run("window.hideMenuItem", json!({"item": key, "hidden": !visible})); + } + } + }); + } + "importOptions" => { + ui.label(egui::RichText::new(d.s("path")).size(10.5)); + check(ui, &mut d, "removeStyles", crate::i18n::tr(&app.ui.language, "Remove Styles and Formatting from Text and Tables")); + if !d.b("removeStyles") { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Name Conflicts")).font(semibold(12.0))); + let conflicts: Vec = d + .fields + .get("conflicts") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) + .unwrap_or_default(); + ui.label(if conflicts.is_empty() { + crate::i18n::tr(&app.ui.language, "No conflicts").to_string() + } else { + format!("{} conflict(s): {}", conflicts.len(), conflicts.join(", ")) + }); + let cur = d.s("styleConflicts"); + ui.horizontal(|ui| { + for (v, l) in [ + ("useExisting", "Use Document Style Definition"), + ("redefine", "Redefine Document Style"), + ("autoRename", "Auto Rename"), + ] { + if ui.radio(cur == v, l).clicked() { + d.fields.insert("styleConflicts".into(), json!(v)); + } + } + }); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Mapping")).font(semibold(12.0))); + let names: Vec = + app.session.active().map(|s| s.doc.styles.paragraph.iter().map(|p| p.name.clone()).collect()).unwrap_or_default(); + let imported: Vec = d + .fields + .get("styles") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) + .unwrap_or_default(); + egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { + egui::Grid::new("imp_map").num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| { + for w in &imported { + ui.label(w); + let mapped = d.fields.get("map").and_then(|m| m.get(w)).and_then(Value::as_str).unwrap_or("").to_string(); + let shown = if mapped.is_empty() { "(import)".to_string() } else { mapped.clone() }; + egui::ComboBox::from_id_salt(("imp_map", w)).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui( + ui, + |ui| { + if ui + .selectable_label( + mapped.is_empty(), + crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "(import)")), + ) + .clicked() + && let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut) + { + m.remove(w); + } + for n in &names { + if ui.selectable_label(*n == mapped, n).clicked() + && let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut) + { + m.insert(w.clone(), json!(n)); + } + } + }, + ); + ui.end_row(); + } + }); + }); + } + } + "closeDocument" => { + let text = crate::i18n::tr(&app.ui.language, "“{}” has changes that are not saved. Save them before closing?") + .replace("{}", &d.s("title")); + crate::rtl::label(ui, text); + } + "newWorkspace" => { + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Name:")); + text_field(ui, &mut d, "name", 220.0); + }); + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Saves which bars show and where the panels are.")).size(10.5), + ); + } + "fittingOptions" => { + check(ui, &mut d, "autoFit", crate::i18n::tr(&app.ui.language, "Auto-Fit")); + ui.add_space(4.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Content Fitting")).font(semibold(12.0))); + let cur = d.s("fitting"); + let label = |v: &str| match v { + "fillProportionally" => crate::i18n::tr(&app.ui.language, "Fill Frame Proportionally"), + "fitProportionally" => crate::i18n::tr(&app.ui.language, "Fit Content Proportionally"), + "fitContentToFrame" => crate::i18n::tr(&app.ui.language, "Fit Content to Frame"), + "centerContent" => crate::i18n::tr(&app.ui.language, "Center Content"), + _ => crate::i18n::tr(&app.ui.language, "None"), + }; + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Fitting:")); + egui::ComboBox::from_id_salt("ff_fit").selected_text(crate::rtl::widget(ui, label(&cur))).width(200.0).show_ui(ui, |ui| { + for v in ["none", "fillProportionally", "fitProportionally", "fitContentToFrame", "centerContent"] { + if ui.selectable_label(cur == v, crate::rtl::widget(ui, label(v))).clicked() { + d.fields.insert("fitting".into(), json!(v)); + } + } + }); + }); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Align From:")); + let a = d.n("align").unwrap_or(4.0) as u8; + egui::Grid::new("ff_align").spacing([2.0, 2.0]).show(ui, |ui| { + for row in 0..3u8 { + for col in 0..3u8 { + let i = row * 3 + col; + let (r, resp) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click()); + let c = ui.visuals().text_color(); + if a == i { + ui.painter().rect_filled(r.shrink(2.0), 1.0, c); + } else { + ui.painter().rect_stroke(r.shrink(3.0), 1.0, egui::Stroke::new(1.0, c), egui::StrokeKind::Inside); } - }); + if resp.clicked() { + d.fields.insert("align".into(), json!(i)); + } + } ui.end_row(); } }); }); - } - } - "closeDocument" => { - let text = crate::i18n::tr(&app.ui.language, "“{}” has changes that are not saved. Save them before closing?").replace("{}", &d.s("title")); - crate::rtl::label(ui, text); - } - "newWorkspace" => { - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Name:")); - text_field(ui, &mut d, "name", 220.0); - }); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Saves which bars show and where the panels are.")).size(10.5)); - } - "fittingOptions" => { - check(ui, &mut d, "autoFit", crate::i18n::tr(&app.ui.language, "Auto-Fit")); - ui.add_space(4.0); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Content Fitting")).font(semibold(12.0))); - let cur = d.s("fitting"); - let label = |v: &str| match v { - "fillProportionally" => crate::i18n::tr(&app.ui.language, "Fill Frame Proportionally"), - "fitProportionally" => crate::i18n::tr(&app.ui.language, "Fit Content Proportionally"), - "fitContentToFrame" => crate::i18n::tr(&app.ui.language, "Fit Content to Frame"), - "centerContent" => crate::i18n::tr(&app.ui.language, "Center Content"), - _ => crate::i18n::tr(&app.ui.language, "None"), - }; - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Fitting:")); - egui::ComboBox::from_id_salt("ff_fit").selected_text(crate::rtl::widget(ui, label(&cur))).width(200.0).show_ui(ui, |ui| { - for v in ["none", "fillProportionally", "fitProportionally", "fitContentToFrame", "centerContent"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, label(v))).clicked() { - d.fields.insert("fitting".into(), json!(v)); - } + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Crop Amount")).font(semibold(12.0))); + egui::Grid::new("ff_crop").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + for (k, l) in [("cropTop", "Top:"), ("cropLeft", "Left:")] { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); + text_field(ui, &mut d, k, 70.0); } + ui.end_row(); + for (k, l) in [("cropBottom", "Bottom:"), ("cropRight", "Right:")] { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); + text_field(ui, &mut d, k, 70.0); + } + ui.end_row(); }); - }); - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Align From:")); - let a = d.n("align").unwrap_or(4.0) as u8; - egui::Grid::new("ff_align").spacing([2.0, 2.0]).show(ui, |ui| { - for row in 0..3u8 { - for col in 0..3u8 { - let i = row * 3 + col; - let (r, resp) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click()); - let c = ui.visuals().text_color(); - if a == i { - ui.painter().rect_filled(r.shrink(2.0), 1.0, c); - } else { - ui.painter().rect_stroke(r.shrink(3.0), 1.0, egui::Stroke::new(1.0, c), egui::StrokeKind::Inside); - } - if resp.clicked() { - d.fields.insert("align".into(), json!(i)); + } + "qrCode" => { + let cur = d.s("type"); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Type:")); + let label = |v: &str| match v { + "text" => crate::i18n::tr(&app.ui.language, "Plain Text"), + "sms" => crate::i18n::tr(&app.ui.language, "Text Message"), + "email" => crate::i18n::tr(&app.ui.language, "Email"), + "vcard" => crate::i18n::tr(&app.ui.language, "Business Card"), + _ => crate::i18n::tr(&app.ui.language, "Web Hyperlink"), + }; + egui::ComboBox::from_id_salt("qr_type").selected_text(crate::rtl::widget(ui, label(&cur))).width(160.0).show_ui(ui, |ui| { + for v in ["url", "text", "sms", "email", "vcard"] { + if ui.selectable_label(cur == v, crate::rtl::widget(ui, label(v))).clicked() { + d.fields.insert("type".into(), json!(v)); } } + }); + }); + let fields: &[(&str, &str)] = match cur.as_str() { + "sms" => &[("number", "Cell Number:"), ("message", "Message:")], + "email" => &[("to", "Email Address:"), ("subject", "Subject:"), ("body", "Message:")], + "vcard" => &[("name", "Name:"), ("org", "Organization:"), ("phone", "Phone:"), ("email", "Email:"), ("url", "URL:")], + "text" => &[("content", "Text:")], + _ => &[("content", "URL:")], + }; + egui::Grid::new("qr_fields").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (k, l) in fields { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); + text_field(ui, &mut d, k, 260.0); ui.end_row(); } - }); - }); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Crop Amount")).font(semibold(12.0))); - egui::Grid::new("ff_crop").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - for (k, l) in [("cropTop", "Top:"), ("cropLeft", "Left:")] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); - text_field(ui, &mut d, k, 70.0); - } - ui.end_row(); - for (k, l) in [("cropBottom", "Bottom:"), ("cropRight", "Right:")] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); - text_field(ui, &mut d, k, 70.0); - } - ui.end_row(); - }); - } - "qrCode" => { - let cur = d.s("type"); - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Type:")); - let label = |v: &str| match v { - "text" => crate::i18n::tr(&app.ui.language, "Plain Text"), - "sms" => crate::i18n::tr(&app.ui.language, "Text Message"), - "email" => crate::i18n::tr(&app.ui.language, "Email"), - "vcard" => crate::i18n::tr(&app.ui.language, "Business Card"), - _ => crate::i18n::tr(&app.ui.language, "Web Hyperlink"), - }; - egui::ComboBox::from_id_salt("qr_type").selected_text(crate::rtl::widget(ui, label(&cur))).width(160.0).show_ui(ui, |ui| { - for v in ["url", "text", "sms", "email", "vcard"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, label(v))).clicked() { - d.fields.insert("type".into(), json!(v)); - } - } - }); - }); - let fields: &[(&str, &str)] = match cur.as_str() { - "sms" => &[("number", "Cell Number:"), ("message", "Message:")], - "email" => &[("to", "Email Address:"), ("subject", "Subject:"), ("body", "Message:")], - "vcard" => &[("name", "Name:"), ("org", "Organization:"), ("phone", "Phone:"), ("email", "Email:"), ("url", "URL:")], - "text" => &[("content", "Text:")], - _ => &[("content", "URL:")], - }; - egui::Grid::new("qr_fields").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (k, l) in fields { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); - text_field(ui, &mut d, k, 260.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Color:")); + text_field(ui, &mut d, "color", 160.0); ui.end_row(); - } - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Color:")); - text_field(ui, &mut d, "color", 160.0); - ui.end_row(); - }); - } - "polygonSettings" => { - egui::Grid::new("poly").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Number of Sides:")); - text_field(ui, &mut d, "sides", 60.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Star Inset:")); - ui.horizontal(|ui| { - text_field(ui, &mut d, "starInset", 60.0); - ui.label("%"); }); - ui.end_row(); - }); - } - STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), - id if id.starts_with("cmd:") => command_form(app, ui, &mut d), - "goToPage" => { - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page")); - text_field(ui, &mut d, "page", 80.0); - }); - } - "ruby" => { - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Ruby:")); - text_field(ui, &mut d, "text", 200.0); - }); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Set over the selected text; empty removes it.")).size(11.0)); - } - "insertTable" => { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Table Dimensions")).font(semibold(12.0))); - egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Body Rows")); - text_field(ui, &mut d, "bodyRows", 60.0); - crate::rtl::label(ui, crate::i18n::tr_context(&app.ui.language, "Columns", "table")); - text_field(ui, &mut d, "columns", 60.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Header Rows")); - text_field(ui, &mut d, "headerRows", 60.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Footer Rows")); - text_field(ui, &mut d, "footerRows", 60.0); - ui.end_row(); - }); - } - "textFrameOptions" => { - seed_text_frame_options(app, &mut d); - text_frame_options_dialog(app, ui, &mut d); - } - "documentSetup" => document_setup(app, ui, &mut d), - "pdfExport" => pdf_export(app, ui, &mut d), - "alert" => { - let file = d.s("file"); - if !file.is_empty() { - crate::rtl::label(ui, egui::RichText::new(crate::rtl::isolate(&file)).font(semibold(13.0))); - ui.add_space(4.0); } - crate::rtl::label(ui, d.s("message")); - } - _ => {} - } - }); + "polygonSettings" => { + egui::Grid::new("poly").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Number of Sides:")); + text_field(ui, &mut d, "sides", 60.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Star Inset:")); + ui.horizontal(|ui| { + text_field(ui, &mut d, "starInset", 60.0); + ui.label("%"); + }); + ui.end_row(); + }); + } + STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), + id if id.starts_with("cmd:") => command_form(app, ui, &mut d), + "goToPage" => { + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page")); + text_field(ui, &mut d, "page", 80.0); + }); + } + "ruby" => { + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Ruby:")); + text_field(ui, &mut d, "text", 200.0); + }); + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Set over the selected text; empty removes it.")).size(11.0), + ); + } + "insertTable" => { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Table Dimensions")).font(semibold(12.0))); + egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Body Rows")); + text_field(ui, &mut d, "bodyRows", 60.0); + crate::rtl::label(ui, crate::i18n::tr_context(&app.ui.language, "Columns", "table")); + text_field(ui, &mut d, "columns", 60.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Header Rows")); + text_field(ui, &mut d, "headerRows", 60.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Footer Rows")); + text_field(ui, &mut d, "footerRows", 60.0); + ui.end_row(); + }); + } + "textFrameOptions" => { + seed_text_frame_options(app, &mut d); + text_frame_options_dialog(app, ui, &mut d); + } + "documentSetup" => document_setup(app, ui, &mut d), + "pdfExport" => pdf_export(app, ui, &mut d), + "alert" => { + let file = d.s("file"); + if !file.is_empty() { + crate::rtl::label(ui, egui::RichText::new(crate::rtl::isolate(&file)).font(semibold(13.0))); + ui.add_space(4.0); + } + crate::rtl::label(ui, d.s("message")); + } + _ => {} + }); ui.add_space(12.0); let ok_label = if d.id == "closeDocument" { " Save " } else { " OK " }; ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add( - egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, ok_label)).color(egui::Color32::WHITE))) - .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), + egui::Button::new(crate::rtl::widget( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, ok_label)).color(egui::Color32::WHITE), + )) + .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), ) .clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) @@ -1742,12 +1856,26 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { /// Close the open dialog without applying it (Cancel, Esc, the control channel): a previewing /// dialog puts the document back as it was. pub fn cancel(app: &mut DesignApp) { + if app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { + app.session.reset_find_scope(); + } if app.ui.dialog.take().is_some_and(|d| d.fields.contains_key(PREVIEWED)) { app.session.cancel_interaction(); } app.cancel_pdf_import(); } +fn find_change_params(d: &Dialog) -> Value { + let mut p = json!({ + "find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), + "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope"), + }); + if let Some(story) = d.fields.get("story") { + p["story"] = story.clone(); + } + p +} + /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; @@ -2172,8 +2300,12 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.run("footnote.options", p) } "alert" => Ok(Value::Null), - "findChange" if d.b("objectMode") => Ok(Value::Null), - "findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})), + "findChange" => { + let result = if d.b("objectMode") { Ok(Value::Null) } else { app.run("find.change", find_change_params(&d)) }; + // Confirmation consumes retained bounds before closing, even on an error. + app.session.reset_find_scope(); + result + } STEP_AND_REPEAT => { // The preview goes; the copies are made once, as one undo step. if d.fields.contains_key(PREVIEWED) { @@ -4022,6 +4154,721 @@ mod tests { } } + fn scope_control(app: &mut DesignApp, ctx: &egui::Context, method: &str, params: Value) -> Value { + let (request, _reply) = crate::control::ControlRequest::new(method, params); + let crate::control::Outcome::Done(value) = crate::control::handle(app, ctx, &request) else { panic!("ordinary control response") }; + assert_eq!(value["ok"], true, "{value}"); + value["result"].clone() + } + + fn scope_click(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, label: &str) { + let labels = dialog_frame(app, ctx, screen, vec![]); + let pos = visible_label(&labels, screen, label).center(); + click_dialog(app, ctx, screen, pos); + } + + fn scope_dialog() -> (DesignApp, egui::Context, egui::Rect, designcraft_engine::doc::StoryId) { + scope_dialog_with_selection("red red | red", 7) + } + + fn scope_dialog_with_selection(text: &str, end: usize) -> (DesignApp, egui::Context, egui::Rect, designcraft_engine::doc::StoryId) { + scope_dialog_with_tool(text, end, None) + } + + fn scope_dialog_with_tool( + text: &str, + end: usize, + tool: Option<&str>, + ) -> (DesignApp, egui::Context, egui::Rect, designcraft_engine::doc::StoryId) { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.language = "en".into(); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect": [36, 36, 100, 100], "content": "text", "text": text})).unwrap(); + let sid = designcraft_engine::doc::StoryId(frame["story"].as_u64().unwrap()); + let ctx = dialog_context("en"); + if let Some(tool) = tool { + scope_control(&mut app, &ctx, "ui.tool.select", json!({"tool": tool})); + } + app.run("text.select", json!({"story": sid.0, "anchor": 0, "focus": end})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + for (field, value) in [("find", "red"), ("change", "unused")] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + } + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + scope_click(&mut app, &ctx, screen, "document"); + scope_click(&mut app, &ctx, screen, "selection"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("scope"), "selection"); + (app, ctx, screen, sid) + } + + fn scope_selected(app: &DesignApp, sid: designcraft_engine::doc::StoryId, a: usize, b: usize) { + let t = app.session.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.story, t.range(), t.cell), (sid, a..b, None)); + } + + fn scope_state_unchanged(app: &DesignApp, before: &designcraft_engine::DocState) { + let after = app.session.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc)); + assert_eq!(after.uid, before.uid); + assert_eq!(after.revision, before.revision); + assert_eq!(after.selection, before.selection); + assert_eq!(after.history.limit, before.history.limit); + for (actual, expected) in [(&after.history.undo, &before.history.undo), (&after.history.redo, &before.history.redo)] { + assert_eq!(actual.len(), expected.len()); + for (a, b) in actual.iter().zip(expected) { + assert_eq!(a.label, b.label); + assert_eq!(a.selection, b.selection); + assert!(std::sync::Arc::ptr_eq(&a.doc, &b.doc)); + } + } + } + + #[test] + fn find_scope_dialog_control_effective_noops_retain_bounds() { + for (field, value) in [("objectMode", json!(false)), ("story", Value::Null)] { + let (mut app, ctx, screen, sid) = scope_dialog(); + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key(field)); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields[field], value); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), before.history.undo.len() + 1); + } + } + + #[test] + fn find_scope_dialog_explicit_story_ignored_scope_preserves_grep_cursor() { + for physical in [false, true] { + for (text, pattern, hits, changed) in [ + ("é", "é|(?:)", vec![(0, 2)], "X"), + ("é🙂", "é|(?:)", vec![(0, 2), (6, 6)], "X🙂X"), + ("é🙂", "(?:)", vec![(0, 0), (2, 2), (6, 6)], "XéX🙂X"), + ] { + let (mut app, ctx, screen, sid) = scope_dialog_with_selection(text, 0); + for (field, value) in [("find", json!(pattern)), ("change", json!("X")), ("story", json!(sid.0))] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + } + scope_click(&mut app, &ctx, screen, "GREP"); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), format!("{} match(es)", hits.len())); + for &(a, b) in &hits { + scope_click(&mut app, &ctx, screen, "Find Next"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "Found"); + scope_selected(&app, sid, a, b); + let before = app.session.doc().unwrap().clone(); + let cur = app.ui.dialog.as_ref().unwrap().s("scope"); + let next = if cur == "selection" { "document" } else { "selection" }; + if physical { + scope_click(&mut app, &ctx, screen, &cur); + scope_click(&mut app, &ctx, screen, next); + } else { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": next})); + } + assert_eq!(app.ui.dialog.as_ref().unwrap().s("scope"), next); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["story"], json!(sid.0)); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), format!("{} match(es)", hits.len())); + } + scope_click(&mut app, &ctx, screen, "Find Next"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "No matches"); + // An ignored scope change must not revive an exhausted endpoint either. + let before = app.session.doc().unwrap().clone(); + let cur = app.ui.dialog.as_ref().unwrap().s("scope"); + let next = if cur == "selection" { "document" } else { "selection" }; + if physical { + scope_click(&mut app, &ctx, screen, &cur); + scope_click(&mut app, &ctx, screen, next); + } else { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": next})); + } + scope_click(&mut app, &ctx, screen, "Find Next"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "No matches"); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), format!("{} replacement(s) made", hits.len())); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, changed); + } + } + } + + #[test] + fn find_scope_dialog_explicit_story_transitions_reset_cursor() { + for remove_override in [false, true] { + let (mut app, ctx, screen, sid) = scope_dialog_with_selection("é🙂", 0); + let other = app.run("frame.create", json!({"rect": [180, 36, 100, 100], "content": "text", "text": "other"})).unwrap(); + app.run("text.select", json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + for (field, value) in [("find", json!("(?:)")), ("story", json!(sid.0))] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + } + scope_click(&mut app, &ctx, screen, "GREP"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 0); + let before = app.session.doc().unwrap().clone(); + let value = if remove_override { Value::Null } else { other["story"].clone() }; + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "story", "value": value})); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "story", "value": sid.0})); + scope_state_unchanged(&app, &before); + // Away and back still releases continuation even if no Find command intervenes. + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 0); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 2, 2); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "é🙂"); + let other_sid = designcraft_engine::doc::StoryId(other["story"].as_u64().unwrap()); + assert_eq!(app.session.doc().unwrap().doc.story(other_sid).unwrap().text, "other"); + } + } + + #[test] + fn find_scope_dialog_control_mode_changes_release_bounds() { + for object_mode in [false, true] { + let (mut app, ctx, _, sid) = scope_dialog(); + if object_mode { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "objectMode", "value": true})); + } + // Direct Session consumers share the retained domain even while Object is shown. + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + let count = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.find", "params": p})); + assert_eq!(count.as_array().unwrap().len(), 2); + scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.next", "params": p})); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "objectMode", "value": !object_mode})); + assert_eq!(app.ui.dialog.as_ref().unwrap().b("objectMode"), !object_mode); + scope_state_unchanged(&app, &before); + let count = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.find", "params": p})); + assert_eq!(count, json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + let changed = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.change", "params": p})); + assert_eq!(changed, json!({"count": 1})); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + } + } + + #[test] + fn find_scope_dialog_tool_replacement_releases_bounds() { + for (tool, replacement) in [("page", "cmd:layout.pageSize"), ("rectangleFrame", "frameSize")] { + let (mut app, ctx, screen, sid) = scope_dialog_with_tool("red red | red", 7, Some(tool)); + assert_eq!(app.session.tool_id(), tool); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + let active = (app.session.active_index(), app.session.documents().len()); + let off = app.session.layout().xf(designcraft_engine::doc::SpreadRef::Doc(0)).translation(); + let x = off.x + before.doc.spreads[0].pages[0].x + 200.0; + let y = off.y + 200.0; + let events = if tool == "page" { + json!([{"kind": "up", "x": x, "y": y}]) + } else { + json!([{"kind": "down", "x": x, "y": y}, {"kind": "up", "x": x, "y": y}]) + }; + // Real tool -> Action::Dialog -> after_engine; no injected UI request. + scope_control(&mut app, &ctx, "ui.pointer", json!({"events": events})); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, replacement); + assert!(app.session.ui_requests.is_empty()); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Cancel"); + assert!(app.ui.dialog.is_none()); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + let count = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.find", "params": p})); + assert_eq!(count, json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + scope_state_unchanged(&app, &before); + let changed = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.change", "params": p})); + assert_eq!(changed, json!({"count": 1})); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), before.history.undo.len() + 1); + } + } + + #[test] + fn find_scope_dialog_tool_without_replacement_retains_bounds() { + for tool in ["page", "rectangleFrame"] { + let (mut app, ctx, screen, sid) = scope_dialog_with_tool("red red | red", 7, Some(tool)); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + let fields = app.ui.dialog.as_ref().unwrap().fields.clone(); + scope_control(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "move", "x": 200, "y": 200}]})); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, "findChange"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields, fields); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn find_scope_dialog_native_pdf_replacement_releases_bounds() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("three.pdf"); + std::fs::write(&path, crate::browser_import_tests::pdf()).unwrap(); + for dismissal in ["Cancel", "Escape", "control"] { + let (mut app, ctx, screen, sid) = scope_dialog(); + let picked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let read = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_pick = picked.clone(); + let seen_read = read.clone(); + let pick_path = path.to_string_lossy().to_string(); + let read_path = pick_path.clone(); + app.services.pick_open = Some(Box::new(move |purpose| { + assert_eq!(purpose, "place"); + seen_pick.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Some(pick_path.clone()) + })); + app.services.read = Some(Box::new(move |path| { + assert_eq!(path, read_path); + seen_read.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + std::fs::read(path).map_err(|e| e.to_string()) + })); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + let active = (app.session.active_index(), app.session.documents().len()); + // Exercise engine.execute -> UiRequest::Pick -> native host PDF replacement. + scope_control(&mut app, &ctx, "engine.execute", json!({"command": "file.place", "params": {}})); + assert_eq!(picked.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(read.load(std::sync::atomic::Ordering::SeqCst), 1); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert_eq!(dialog.id, "pdfImport"); + assert_eq!(dialog.s("path"), path.to_string_lossy()); + assert_eq!(dialog.fields["pages"], 3); + assert_eq!(dialog.s("page"), "1"); + assert!(!dialog.b("async")); + assert_eq!(dialog.fields["target"], json!(before.uid)); + assert!(app.session.ui_requests.is_empty()); + assert!(app.pending_pdf.is_none()); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + match dismissal { + "Cancel" => scope_click(&mut app, &ctx, screen, "Cancel"), + "Escape" => { + dialog_frame( + &mut app, + &ctx, + screen, + vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ); + } + "control" => { + scope_control(&mut app, &ctx, "ui.dialog.cancel", json!({})); + } + _ => panic!("known dismissal"), + } + assert!(app.ui.dialog.is_none()); + assert!(app.pending_pdf.is_none()); + assert_eq!(picked.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(read.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + let count = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.find", "params": p})); + assert_eq!(count, json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + scope_state_unchanged(&app, &before); + let changed = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.change", "params": p})); + assert_eq!(changed, json!({"count": 1})); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), before.history.undo.len() + 1); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn find_scope_dialog_native_picker_cancel_retains_bounds() { + let (mut app, ctx, screen, sid) = scope_dialog(); + let picked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = picked.clone(); + app.services.pick_open = Some(Box::new(move |purpose| { + assert_eq!(purpose, "place"); + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + None + })); + let read = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_read = read.clone(); + app.services.read = Some(Box::new(move |_| { + seen_read.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Err("canceled picker must not read".into()) + })); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + let active = (app.session.active_index(), app.session.documents().len()); + let fields = app.ui.dialog.as_ref().unwrap().fields.clone(); + scope_control(&mut app, &ctx, "engine.execute", json!({"command": "file.place", "params": {}})); + assert_eq!(picked.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(read.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, "findChange"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields, fields); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), before.history.undo.len() + 1); + } + + #[test] + fn find_scope_dialog_exact_baseline_buttons() { + let (mut app, ctx, screen, sid) = scope_dialog_with_selection("red red", 3); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red"); + } + + #[test] + fn find_scope_dialog_grep_tab_alone_restarts_original_selection() { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "GREP"); + assert!(app.ui.dialog.as_ref().unwrap().b("grep")); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("find"), "red"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Text"); + assert!(!app.ui.dialog.as_ref().unwrap().b("grep")); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + } + + #[test] + fn find_scope_dialog_buttons_retain_nonempty_hits_and_change_all() { + let (mut app, ctx, screen, sid) = scope_dialog(); + let history = app.session.doc().unwrap().history.undo.len(); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Find Next"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "No matches"); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history + 1); + scope_click(&mut app, &ctx, screen, "Cancel"); + assert!(app.ui.dialog.is_none()); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + app.run("edit.redo", json!({})).unwrap(); + let native = app.run("file.serialize", json!({})).unwrap(); + app.run("file.openBytes", json!({"name": "scope.designcraft", "base64": native["base64"]})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + } + + fn scope_confirm_case(action: &str) { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Count"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + match action { + "OK" => scope_click(&mut app, &ctx, screen, "OK"), + "Enter" => { + dialog_frame( + &mut app, + &ctx, + screen, + vec![egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ); + } + "control" => { + assert_eq!(scope_control(&mut app, &ctx, "ui.dialog.confirm", json!({}))["count"], 2); + } + _ => panic!("known confirmation route"), + } + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + // The closed dialog cannot leave its old seven-byte range for a later direct query. + let matches = app.run("find.find", json!({"find": "blue", "scope": "selection"})).unwrap(); + assert_eq!(matches.as_array().unwrap().len(), 0); + } + + #[test] + fn find_scope_dialog_ok_consumes_retained_bounds() { + scope_confirm_case("OK"); + } + + #[test] + fn find_scope_dialog_enter_consumes_retained_bounds() { + scope_confirm_case("Enter"); + } + + #[test] + fn find_scope_dialog_control_confirm_consumes_retained_bounds() { + scope_confirm_case("control"); + } + + #[test] + fn find_scope_dialog_query_reselection_cancel_and_reopen() { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "grep", "value": true})); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "find", "value": "r.d"})); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_control(&mut app, &ctx, "ui.dialog.cancel", json!({})); + assert_eq!(app.run("find.find", json!({"find": "red", "scope": "selection"})).unwrap().as_array().unwrap().len(), 1); + app.run("text.select", json!({"story": sid.0, "anchor": 0, "focus": 7})).unwrap(); + app.run("find.next", json!({"find": "red", "scope": "selection"})).unwrap(); + scope_control(&mut app, &ctx, "ui.dialog.open", json!({"id": "findChange", "fields": {"find": "red", "scope": "selection"}})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 match(es)"); + app.run("text.select", json!({"story": sid.0, "anchor": 4, "focus": 7})).unwrap(); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + app.run("app.findChange", json!({})).unwrap(); + for (field, value) in [("find", "red"), ("scope", "selection")] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + } + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 match(es)"); + } + + #[test] + fn find_scope_dialog_word_reselection_limits_count_and_change_buttons() { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let selected = app.session.doc().unwrap().selection.clone(); + let frame = selected.text.unwrap().frame.unwrap(); + let point = { + let st = app.session.doc().unwrap(); + let cs = app.session.cache.get(&st.doc, sid, None); + let (fi, x, baseline, ascent, _) = designcraft_compose::caret(&cs, 1).unwrap(); + let inner = designcraft_geom::Point::new(x, baseline - ascent * 0.5); + assert_eq!(cs.frames[fi].frame, frame); + assert_eq!(designcraft_compose::hit(&cs, fi, inner), Some(1)); + let loc = st.doc.find(frame).unwrap(); + st.doc.parent_xf(&loc) * st.doc.text_xf(st.doc.item(frame).unwrap()) * inner + }; + let before = app.session.doc().unwrap().doc.clone(); + let revision = app.session.doc().unwrap().revision; + app.run("text.selectWord", json!({"frame": frame.0, "point": [point.x, point.y]})).unwrap(); + assert_eq!(app.session.doc().unwrap().selection, selected); + assert_eq!(app.session.doc().unwrap().revision, revision + 1); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.doc().unwrap().doc)); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 match(es)"); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn find_scope_dialog_failed_native_drop_alert_releases_bounds() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("broken.designcraft"); + std::fs::write(&path, b"not a native document").unwrap(); + for dismissal in ["OK", "Escape", "control"] { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().doc.clone(); + let selected = app.session.doc().unwrap().selection.clone(); + let revision = app.session.doc().unwrap().revision; + let history = app.session.doc().unwrap().history.undo.len(); + let uid = app.session.doc().unwrap().uid; + let error = app.open_dropped(&path.to_string_lossy()).unwrap_err(); + let alert = app.ui.dialog.as_ref().unwrap(); + assert_eq!(alert.id, "alert"); + assert_eq!(alert.s("title"), "Can't Open the File"); + assert_eq!(alert.s("message"), error); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.doc().unwrap().doc)); + assert_eq!(app.session.doc().unwrap().selection, selected); + assert_eq!(app.session.doc().unwrap().revision, revision); + assert_eq!(app.session.doc().unwrap().uid, uid); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history); + match dismissal { + "OK" => scope_click(&mut app, &ctx, screen, "OK"), + "Escape" => { + dialog_frame( + &mut app, + &ctx, + screen, + vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ); + } + "control" => { + scope_control(&mut app, &ctx, "ui.dialog.cancel", json!({})); + } + _ => panic!("known dismissal"), + } + assert!(app.ui.dialog.is_none()); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + assert_eq!(app.run("find.find", p.clone()).unwrap(), json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + assert_eq!(app.run("find.change", p).unwrap(), json!({"count": 1})); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history + 1); + } + } + + #[test] + fn find_scope_dialog_child_errors_and_explicit_parent() { + let (mut app, ctx, screen, sid) = scope_dialog(); + app.run("text.select", json!({"story": sid.0, "anchor": 13, "focus": 13})).unwrap(); + app.run("table.insert", json!({"rows": 1, "cols": 1})).unwrap(); + app.run("text.insert", json!({"text": "red child", "raw": true})).unwrap(); + let child = app.session.doc().unwrap().selection.text.unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let selection = app.session.doc().unwrap().selection.clone(); + let history = app.session.doc().unwrap().history.undo.len(); + for label in ["Count", "Find Next", "Change All"] { + scope_click(&mut app, &ctx, screen, label); + assert!(app.ui.dialog.as_ref().unwrap().s("status").contains("not supported")); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.doc().unwrap().doc)); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history); + } + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "story", "value": sid.0})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "3 match(es)"); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "3 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.text_story(sid, child.cell).unwrap().text, "red child"); + assert!(app.session.doc().unwrap().doc.story(sid).unwrap().text.starts_with("blue blue | blue")); + } + + #[test] + fn find_scope_dialog_error_confirmation_and_escape_release_context() { + for escape in [false, true] { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + if escape { + dialog_frame( + &mut app, + &ctx, + screen, + vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ); + } else { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "find", "value": "["})); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "grep", "value": true})); + scope_click(&mut app, &ctx, screen, "OK"); + } + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + assert_eq!(app.run("find.find", json!({"find": "red", "scope": "selection"})).unwrap().as_array().unwrap().len(), 1); + } + } + + #[test] + fn find_scope_dialog_domain_and_object_changes_release_original_range() { + for route in ["scope", "object", "menu", "command"] { + let (mut app, ctx, screen, _) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Find Next"); + match route { + "scope" => { + for scope in ["story", "selection"] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": scope})); + } + } + "object" => { + scope_click(&mut app, &ctx, screen, "Object"); + scope_click(&mut app, &ctx, screen, "Text"); + } + "menu" => crate::menus::activate(&mut app, "app.newDocumentDialog", &Value::Null), + "command" => { + app.run("app.newDocumentDialog", json!({})).unwrap(); + } + _ => panic!("known reset route"), + } + assert_eq!(app.run("find.find", json!({"find": "red", "scope": "selection"})).unwrap().as_array().unwrap().len(), 1); + } + } + #[test] fn dialog_title_and_actions_fit_scaled_viewports() { for language in ["en", "ar"] { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e63..1082dd16 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -622,6 +622,7 @@ impl DesignApp { pub fn after_engine(&mut self) { let reqs = std::mem::take(&mut self.session.ui_requests); for r in reqs { + let was_find = self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); match r { UiRequest::Dialog { id, params } => self.ui.dialog = Some(dialogs::Dialog::new(&id, params)), UiRequest::View { params } => canvas::apply_view_request(self, ¶ms), @@ -630,6 +631,10 @@ impl DesignApp { let _ = self.pick_and_open(&purpose); } } + // Release only when the request actually replaces Find, including a native PDF picker. + if was_find && !self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { + self.session.reset_find_scope(); + } } // New documents get a fitted view. if let Some(st) = self.session.active() { @@ -986,6 +991,9 @@ impl DesignApp { let r = self.run(cmd, params); if let Err(e) = &r { let title = if cmd == "file.place" { "Can't Place the File" } else { "Can't Open the File" }; + if self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { + self.session.reset_find_scope(); + } self.ui.dialog = Some(dialogs::Dialog::new("alert", json!({"title": title, "file": file, "message": e}))); } else if let Ok(result) = &r { let warnings: Vec<&str> = result @@ -2108,7 +2116,7 @@ mod browser_import_tests { result.push_str(&format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1)); result.into_bytes() } - fn pdf() -> Vec { + pub(super) fn pdf() -> Vec { pdf_pages(3) } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81d..13d3a54d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -752,6 +752,15 @@ pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> { /// Run a UI command; `None` if `id` isn't one. pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option> { + let was_find = app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); + let result = run_ui_inner(app, id, p); + if was_find && app.ui.dialog.as_ref().is_none_or(|d| d.id != "findChange") { + app.session.reset_find_scope(); + } + result +} + +fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option> { if let Some(result) = crate::panel_docking::command(app, id, p) { return Some(result); } @@ -957,6 +966,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + app.session.reset_find_scope(); app.ui.dialog = Some(crate::dialogs::Dialog::new("findChange", json!({}))); Ok(Value::Null) } @@ -1880,6 +1890,14 @@ pub fn close_document(app: &mut DesignApp, index: Option) { /// A menu item was chosen. An engine command whose label ends in "…" and that takes parameters /// opens a dialog built from its parameter documentation (see [`crate::dialogs::command_fields`]). pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { + let was_find = app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); + activate_inner(app, id, params); + if was_find && app.ui.dialog.as_ref().is_none_or(|d| d.id != "findChange") { + app.session.reset_find_scope(); + } +} + +fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) { if params.is_null() && id == "file.close" { close_document(app, None); return; From f4f2f0bb08bd3fa5426bc77f2e8af1f0124796a1 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:20:41 +1000 Subject: [PATCH 07/32] FIX-TABLE-SELECTION: resolve selected merged-cell owners Co-Authored-By: Codex --- crates/doc/src/table.rs | 48 +++++ crates/engine/src/cmd/table.rs | 129 ++++++------- crates/engine/src/cmd/text.rs | 11 +- crates/engine/src/tests_table.rs | 315 +++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 76 +++++++- 5 files changed, 503 insertions(+), 76 deletions(-) diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 9449be93..087412bc 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -444,6 +444,25 @@ impl Table { v } + /// Owning cells touched by the range, once each in row-major order. Bounds are clipped + /// to the grid; resolving covered positions does not expand the selection rectangle. + pub fn selected_owners(&self, range: CellRange) -> Vec<(usize, usize)> { + let (nr, nc) = (self.nrows(), self.ncols()); + if range.r0 >= nr || range.c0 >= nc || range.r0 > range.r1 || range.c0 > range.c1 { + return Vec::new(); + } + let owners = self.owners(); + let mut selected = std::collections::BTreeSet::new(); + for r in range.r0..=range.r1.min(nr - 1) { + for c in range.c0..=range.c1.min(nc - 1) { + if let Some(&owner) = owners.get(r * nc + c) { + selected.insert(owner); + } + } + } + selected.into_iter().collect() + } + /// The owning (top-left) cell address of grid position (r, c). pub fn owner(&self, r: usize, c: usize) -> (usize, usize) { self.owners().get(r * self.ncols() + c).copied().unwrap_or((r, c)) @@ -1136,6 +1155,35 @@ mod tests { Story::with_text(StoryId(1), t, ParaFormat::default()) } + #[test] + fn selected_owners_include_covered_cells_without_expanding_range() { + for column in [true, false] { + let mut t = Table::new(1, 2, 2, 0, 0, 120.0); + let range = if column { CellRange::new(0, 1, 1, 1) } else { CellRange::new(1, 0, 1, 1) }; + assert_eq!(t.selected_owners(range), if column { vec![(0, 1), (1, 1)] } else { vec![(1, 0), (1, 1)] }); + t.merge(if column { CellRange::new(0, 0, 0, 1) } else { CellRange::new(0, 0, 1, 0) }).unwrap(); + assert_eq!(t.selected_owners(range), vec![(0, 0), (1, 1)]); + } + } + + #[test] + fn selected_owners_are_bounded_sorted_and_unique() { + let mut t = Table::new(1, 3, 3, 0, 0, 180.0); + t.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + assert_eq!(t.selected_owners(CellRange::new(1, 1, usize::MAX, usize::MAX)), vec![(0, 0), (1, 2), (2, 1), (2, 2)]); + assert_eq!(t.selected_owners(CellRange::new(0, 0, 1, 1)), vec![(0, 0)], "covered positions do not duplicate an owner"); + assert!(t.selected_owners(CellRange::new(3, 0, usize::MAX, usize::MAX)).is_empty()); + assert!(t.selected_owners(CellRange::new(0, 3, usize::MAX, usize::MAX)).is_empty()); + assert!(t.selected_owners(CellRange { r0: 1, c0: 0, r1: 0, c1: 1 }).is_empty()); + t.rows.clear(); + t.cells.clear(); + assert!(t.selected_owners(CellRange::cell(0, 0)).is_empty()); + + let mut right = Table::new(2, 2, 2, 0, 0, 120.0); + right.merge(CellRange::new(0, 1, 1, 1)).unwrap(); + assert_eq!(right.selected_owners(CellRange::new(1, 0, 1, 1)), vec![(0, 1), (1, 0)], "owner order, not encounter order"); + } + #[test] fn insert_table_gets_own_paragraph() { let mut s = story("before after"); diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index ad306be8..c3df2442 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -7,6 +7,7 @@ //! whole table). use designcraft_compose as compose; +use designcraft_doc::story::floor_char_boundary; use designcraft_doc::{ AltFills, CellAddr, CellRange, CellStroke, Document, RowHeightMode, Selection, StoryId, StrokeType, Table, TableSel, TextSel, VerticalJustification, @@ -140,11 +141,9 @@ fn table_specs() -> Vec { |s, p| { let g = target(s, p, "table.textCell")?; edit_table(s, &g, "table.textCell", |t| { - for r in g.range.r0..=g.range.r1 { - for c in g.range.c0..=g.range.c1 { - if let Some(cell) = t.cell_mut(r, c) { - cell.graphic = None; - } + for (r, c) in t.selected_owners(g.range) { + if let Some(cell) = t.cell_mut(r, c) { + cell.graphic = None; } } ok() @@ -351,13 +350,17 @@ fn clamp_selection(d: &Document, sel: &mut Selection) { match story.and_then(|x| x.tables.get(&c.table)) { Some(tb) => { let (r, col) = tb.owner(c.row.min(tb.nrows() - 1), c.col.min(tb.ncols() - 1)); - let len = tb.cell(r, col).map_or(0, |x| x.text.len()); - sel.text = - Some(TextSel { anchor: t.anchor.min(len), focus: t.focus.min(len), cell: Some(CellAddr { table: c.table, row: r, col }), ..t }); + let text = tb.cell(r, col).map_or("", |x| x.text.text.as_str()); + sel.text = Some(TextSel { + anchor: floor_char_boundary(text, t.anchor), + focus: floor_char_boundary(text, t.focus), + cell: Some(CellAddr { table: c.table, row: r, col }), + ..t + }); } None => { - let len = story.map_or(0, |x| x.len()); - sel.text = Some(TextSel { anchor: t.anchor.min(len), focus: t.anchor.min(len), cell: None, ..t }); + let pos = story.map_or(0, |x| floor_char_boundary(&x.text, t.anchor)); + sel.text = Some(TextSel { anchor: pos, focus: pos, cell: None, ..t }); } } } @@ -648,7 +651,6 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").map(|_| super::text_param(p, "text")); let stroke = p.get("stroke").cloned(); edit_table(s, &g, "table.setCell", |t| { - let owners = t.owners(); let nc = t.ncols(); let nr = t.nrows(); let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; @@ -656,50 +658,45 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { // Local edits outrank previously imported edges, including explicit zero/None. let priority = if stroke.is_some() { next_cell_stroke_priority(t) } else { 0 }; let rg = g.range; - for r in rg.r0..=rg.r1 { - for c in rg.c0..=rg.c1 { - if owners[r * nc + c] != (r, c) { - continue; - } - let Some(cell) = t.cell_mut(r, c) else { continue }; - if let Some(f) = &fill { - cell.fill = f.clone(); - } - if let Some(ti) = tint { - cell.fill_tint = ti; - } - if let Some(i) = insets { - cell.insets = i; - } - if let Some(v) = vj { - cell.vj = v; - } - if let Some(tx) = &text { - let len = cell.text.len(); - cell.text.replace(0..len, tx); - } - if let Some(sv) = &stroke { - let edges = str_param(sv, "edges").unwrap_or("all"); - let at_bottom = r.saturating_add(cell.row_span.max(1) as usize); - let at_right = c.saturating_add(cell.col_span.max(1) as usize); - let perimeter = [r == 0, if rtl { at_right == nc } else { c == 0 }, at_bottom == nr, if rtl { c == 0 } else { at_right == nc }]; - for (i, on) in [ - ("top", r == rg.r0), - ("left", if rtl { at_right.saturating_sub(1) == rg.c1 } else { c == rg.c0 }), - ("bottom", at_bottom.saturating_sub(1) == rg.r1), - ("right", if rtl { c == rg.c0 } else { at_right.saturating_sub(1) == rg.c1 }), - ] - .iter() - .enumerate() - .map(|(i, (name, outer))| (i, edges == "all" || edges == *name || (edges == "outer" && *outer) || (edges == "inner" && !*outer))) - { - if on { - let base = if perimeter[i] && !cell.border_overrides[i] { &borders[i] } else { &cell.strokes[i] }; - cell.strokes[i] = parse_stroke(sv, base.clone()); - cell.stroke_defined[i] = true; - cell.stroke_priorities[i] = priority; - cell.border_overrides[i] = true; - } + for (r, c) in t.selected_owners(rg) { + let Some(cell) = t.cell_mut(r, c) else { continue }; + if let Some(f) = &fill { + cell.fill = f.clone(); + } + if let Some(ti) = tint { + cell.fill_tint = ti; + } + if let Some(i) = insets { + cell.insets = i; + } + if let Some(v) = vj { + cell.vj = v; + } + if let Some(tx) = &text { + let len = cell.text.len(); + cell.text.replace(0..len, tx); + } + if let Some(sv) = &stroke { + let edges = str_param(sv, "edges").unwrap_or("all"); + let at_bottom = r.saturating_add(cell.row_span.max(1) as usize); + let at_right = c.saturating_add(cell.col_span.max(1) as usize); + let perimeter = [r == 0, if rtl { at_right == nc } else { c == 0 }, at_bottom == nr, if rtl { c == 0 } else { at_right == nc }]; + for (i, on) in [ + ("top", r == rg.r0), + ("left", if rtl { at_right.saturating_sub(1) == rg.c1 } else { c == rg.c0 }), + ("bottom", at_bottom.saturating_sub(1) == rg.r1), + ("right", if rtl { c == rg.c0 } else { at_right.saturating_sub(1) == rg.c1 }), + ] + .iter() + .enumerate() + .map(|(i, (name, outer))| (i, edges == "all" || edges == *name || (edges == "outer" && *outer) || (edges == "inner" && !*outer))) + { + if on { + let base = if perimeter[i] && !cell.border_overrides[i] { &borders[i] } else { &cell.strokes[i] }; + cell.strokes[i] = parse_stroke(sv, base.clone()); + cell.stroke_defined[i] = true; + cell.stroke_priorities[i] = priority; + cell.border_overrides[i] = true; } } } @@ -707,6 +704,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { // Keep both copies of unmerged shared boundaries consistent. Left/right name // physical sides, also in RTL tables. if let Some(sv) = &stroke { + let owners = t.owners(); let edges = str_param(sv, "edges").unwrap_or("all"); let selected = |side: &str| edges == "all" || edges == "outer" || edges == side; if selected("top") @@ -1095,7 +1093,10 @@ fn cell_style_create(s: &mut Session, p: &Value) -> Result { let cell = d .story(g.story) .and_then(|st| st.tables.get(&g.table)) - .and_then(|t| t.cell(g.range.r0, g.range.c0)) + .and_then(|t| { + let (r, c) = t.owner(g.range.r0, g.range.c0); + t.cell(r, c) + }) .ok_or_else(|| bad("style.cell.create", "no cell"))?; cs.fill = Some(cell.fill.clone()); cs.fill_tint = Some(cell.fill_tint); @@ -1122,17 +1123,11 @@ fn cell_style_apply(s: &mut Session, p: &Value) -> Result { let cs = designcraft_doc::resolve_cell_style(styles, &name); let g = target(s, p, "style.cell.apply")?; edit_table(s, &g, "style.cell.apply", |t| { - let owners = t.owners(); - let nc = t.ncols(); let mut n = 0; - for r in g.range.r0..=g.range.r1 { - for c in g.range.c0..=g.range.c1 { - if owners[r * nc + c] == (r, c) - && let Some(cell) = t.cell_mut(r, c) - { - cs.apply_to(cell); - n += 1; - } + for (r, c) in t.selected_owners(g.range) { + if let Some(cell) = t.cell_mut(r, c) { + cs.apply_to(cell); + n += 1; } } Ok(json!({"cells": n})) @@ -1353,7 +1348,7 @@ fn drop_cells(s: &mut Session, p: &Value) -> Result { && ts.story == sid && ts.table == tid && tid2 == tid - && ts.range.contains(fr, fc) + && s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).is_some_and(|t| t.selected_owners(ts.range).binary_search(&(fr, fc)).is_ok()) { let (nr, nc) = { let t = s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).ok_or_else(|| bad(ID, "no table"))?; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b57e9e88..a53e6e8d 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -823,14 +823,9 @@ pub(crate) fn format_targets(s: &Session) -> Vec { && let Some(t) = st.doc.story(ts.story).and_then(|x| x.tables.get(&ts.table)) { let mut v = Vec::new(); - let owners = t.owners(); - for r in ts.range.r0..=ts.range.r1.min(t.nrows().saturating_sub(1)) { - for c in ts.range.c0..=ts.range.c1.min(t.ncols().saturating_sub(1)) { - if owners[r * t.ncols() + c] == (r, c) { - let len = t.cell(r, c).map_or(0, |x| x.text.len()); - v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len }); - } - } + for (r, c) in t.selected_owners(ts.range) { + let len = t.cell(r, c).map_or(0, |x| x.text.len()); + v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len }); } return v; } diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 3934aabe..c3a575d0 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -16,6 +16,321 @@ fn check(s: &Session) { s.doc().unwrap().doc.check().unwrap(); } +#[test] +fn table_text_replacement_floors_utf8_selection_and_allows_quote_typing() { + for (original, (a, b), replacement, (after_a, after_b), quoted) in [ + ("a", (1, 1), "é", (0, 0), Some("“é")), // Exact caret/quote baseline. + ("abc", (3, 3), "🙂", (0, 0), Some("“🙂")), + ("ab", (1, 2), "éX", (0, 2), None), + ("ab", (2, 1), "éX", (2, 0), None), + ("abcdef", (3, 5), "🙂éX", (0, 4), None), + ("abcdef", (5, 3), "🙂éX", (4, 0), None), + ("abc", (1, 2), "🙂", (0, 0), None), // Both endpoints floor to the same boundary. + ("abcdef", (2, 6), "é🙂", (2, 6), None), // Valid Unicode endpoints stay fixed. + ("abcdef", (1, 4), "ABCDEF", (1, 4), None), + ("abcdef", (4, 1), "ABCDEF", (4, 1), None), + ("abcdef", (6, 6), "é", (2, 2), None), // Shortening clamps to EOF. + ("abc", (1, 3), "", (0, 0), None), + ] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": ""})).unwrap(); + let sid = frame["story"].as_u64().unwrap(); + let fid = frame["id"].as_u64().unwrap(); + let tid = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": original, "raw": true})).unwrap(); + // Real commands position the child selection; text.select addresses parent stories. + for _ in a..original.len() { + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + } + let (dir, steps) = if b >= a { ("right", b - a) } else { ("left", a - b) }; + for _ in 0..steps { + s.execute("text.move", &json!({"dir": dir, "extend": true})).unwrap(); + } + let mut expected = s.doc().unwrap().selection.clone(); + let selected = expected.text.as_mut().unwrap(); + assert_eq!((selected.anchor, selected.focus), (a, b)); + assert_eq!(selected.frame, Some(doc::ItemId(fid))); + assert_eq!(selected.cell, Some(doc::CellAddr { table: tid, row: 0, col: 0 })); + (selected.anchor, selected.focus) = (after_a, after_b); + let parent = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text.clone(); + s.execute("table.setCell", &json!({"text": replacement})).unwrap(); + assert_eq!(table_for(&s, sid, tid).cell(0, 0).unwrap().text.text, replacement); + assert_eq!(s.doc().unwrap().selection, expected, "{original}: {a}->{b}, replacement {replacement}"); + assert!(replacement.is_char_boundary(after_a) && replacement.is_char_boundary(after_b)); + assert_eq!(s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text, parent); + if let Some(quoted) = quoted { + assert!(s.prefs.typographers_quotes); + s.execute("text.insert", &json!({"text": "\""})).unwrap(); + assert_eq!(table_for(&s, sid, tid).cell(0, 0).unwrap().text.text, quoted); + let selected = expected.text.as_mut().unwrap(); + (selected.anchor, selected.focus) = (3, 3); // Opening curly quote is three bytes. + assert_eq!(s.doc().unwrap().selection, expected); + } + check(&s); + } +} + +#[test] +fn table_edit_after_anchor_removal_floors_parent_caret() { + for (original, (a, b), replacement, pos, quoted, typed_pos) in [("ab", (1, 2), "é", 0, "“é\n", 3), ("abcde", (5, 1), "é🙂", 2, "é”🙂\n", 5)] + { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let other = s.execute("frame.create", &json!({"rect": [320, 36, 580, 200], "content": "text"})).unwrap(); + let other_sid = other["story"].as_u64().unwrap(); + let other_tid = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": "keep", "raw": true})).unwrap(); + let mut expected_other = table_for(&s, other_sid, other_tid).clone(); + expected_other.cell_mut(0, 0).unwrap().insets = [7.0; 4]; + + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap(); + let sid = frame["story"].as_u64().unwrap(); + let tid = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": original, "raw": true})).unwrap(); + for _ in a..original.len() { + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + } + let (dir, steps) = if b >= a { ("right", b - a) } else { ("left", a - b) }; + for _ in 0..steps { + s.execute("text.move", &json!({"dir": dir, "extend": true})).unwrap(); + } + let mut expected = s.doc().unwrap().selection.clone(); + let selected = expected.text.unwrap(); + assert_eq!((selected.anchor, selected.focus), (a, b)); + assert_eq!(selected.frame, Some(doc::ItemId(frame["id"].as_u64().unwrap()))); + assert_eq!(selected.cell, Some(doc::CellAddr { table: tid, row: 0, col: 0 })); + // Explicit parent replacement removes its anchor, leaving the child address stale. + assert_eq!( + s.execute("find.change", &json!({"story": sid, "find": doc::TABLE_ANCHOR.to_string(), "change": replacement})).unwrap()["count"], + 1 + ); + let parent = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap(); + assert_eq!(parent.text, format!("{replacement}\n")); + assert!(!parent.tables.contains_key(&tid)); + assert_eq!(s.doc().unwrap().selection, expected); + assert!(!parent.text.is_char_boundary(selected.anchor)); + // An explicit edit to the surviving table reaches the disappeared-table fallback. + s.execute("table.setCell", &json!({"story": other_sid, "table": other_tid, "insets": 7})).unwrap(); + let selected = expected.text.as_mut().unwrap(); + (selected.anchor, selected.focus, selected.cell) = (pos, pos, None); // Both floor the OLD anchor. + assert_eq!(s.doc().unwrap().selection, expected); + assert_eq!(s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text, format!("{replacement}\n")); + assert_eq!(table_for(&s, other_sid, other_tid), &expected_other); + s.execute("text.insert", &json!({"text": "\""})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text, quoted); + let selected = expected.text.as_mut().unwrap(); + (selected.anchor, selected.focus) = (typed_pos, typed_pos); + assert_eq!(s.doc().unwrap().selection, expected); + assert_eq!(table_for(&s, other_sid, other_tid), &expected_other); + check(&s); + } +} + +fn selected_merged_table(column: bool) -> (Session, u64, u64, u64) { + let (mut s, sid, fid) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + for (i, text) in ["A", "B", "C", "D"].iter().enumerate() { + s.execute("text.insert", &json!({"text": text})).unwrap(); + if i < 3 { + s.execute("table.nextCell", &json!({})).unwrap(); + } + } + let merge = if column { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) }; + s.execute("table.select", &merge).unwrap(); + s.execute("table.merge", &json!({})).unwrap(); + s.execute("table.select", &json!({"rows": [1, 1], "cols": [1, 1]})).unwrap(); + s.execute(if column { "table.selectColumn" } else { "table.selectRow" }, &json!({})).unwrap(); + (s, sid, tid, fid) +} + +#[test] +fn selected_merged_owners_receive_text_formatting_and_character_styles() { + for column in [true, false] { + let (mut s, sid, tid, _) = selected_merged_table(column); + let selected = s.doc().unwrap().selection.clone(); + assert_eq!(selected.cells.unwrap().range, if column { doc::CellRange::new(0, 1, 1, 1) } else { doc::CellRange::new(1, 0, 1, 1) }); + let before = table_for(&s, sid, tid).clone(); + let neighbor = if column { (1, 0) } else { (0, 1) }; + s.execute("type.char", &json!({"attrs": {"size": 23}})).unwrap(); + for (r, c) in [(0, 0), (1, 1)] { + let cell = table_for(&s, sid, tid).cell(r, c).unwrap(); + assert!(cell.text.runs().all(|(_, f)| f.over.size == Some(23.0)), "all runs of owner {r},{c}"); + assert_eq!(cell.text.text, before.cell(r, c).unwrap().text.text); + } + assert_eq!(table_for(&s, sid, tid).cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1)); + assert_eq!(s.doc().unwrap().selection, selected); + s.execute("style.character.create", &json!({"name": "Selected owners", "chars": {"tracking": 37}})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Selected owners"})).unwrap(); + for (r, c) in [(0, 0), (1, 1)] { + assert!(table_for(&s, sid, tid).cell(r, c).unwrap().text.runs().all(|(_, f)| f.style == "Selected owners")); + } + let after = table_for(&s, sid, tid); + assert_eq!(after.cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1)); + assert_eq!(after.rows, before.rows); + assert_eq!(after.columns, before.columns); + assert_eq!(s.doc().unwrap().selection, selected, "formatting preserves the original grid rectangle"); + check(&s); + } +} + +#[test] +fn selected_merged_owners_receive_cell_options_and_unique_style_counts() { + for column in [true, false] { + let (mut s, sid, tid, _) = selected_merged_table(column); + let range = s.doc().unwrap().selection.cells.unwrap().range; + let before = table_for(&s, sid, tid).clone(); + let neighbor = if column { (1, 0) } else { (0, 1) }; + let covered = if column { (0, 1) } else { (1, 0) }; + s.execute("table.setCell", &json!({"fill": "[Black]", "insets": 7, "text": "REPLACED"})).unwrap(); + for (r, c) in [(0, 0), (1, 1)] { + let cell = table_for(&s, sid, tid).cell(r, c).unwrap(); + assert_eq!(serde_json::to_value(&cell.fill).unwrap(), json!("[Black]")); + assert_eq!(cell.insets, [7.0; 4]); + assert_eq!(cell.text.text, "REPLACED"); + } + s.execute("style.cell.create", &json!({"name": "Selected cell owners", "insets": 9})).unwrap(); + assert_eq!(s.execute("style.cell.apply", &json!({"name": "Selected cell owners"})).unwrap()["cells"], 2); + let after = table_for(&s, sid, tid); + for (r, c) in [(0, 0), (1, 1)] { + assert_eq!(after.cell(r, c).unwrap().style, "Selected cell owners"); + assert_eq!(after.cell(r, c).unwrap().insets, [9.0; 4]); + } + assert_eq!(after.cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1)); + assert_eq!(after.cell(covered.0, covered.1), before.cell(covered.0, covered.1)); + assert_eq!(after.rows, before.rows); + assert_eq!(after.columns, before.columns); + assert_eq!(s.doc().unwrap().selection.cells.unwrap().range, range); + let merged = if column { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) }; + s.execute("table.select", &merged).unwrap(); + assert_eq!(s.execute("style.cell.apply", &json!({"name": "Selected cell owners"})).unwrap()["cells"], 1); + check(&s); + } +} + +#[test] +fn covered_owner_strokes_keep_literal_edge_masks_and_neighbors() { + for rtl in [false, true] { + for outer in [false, true] { + let (mut s, sid, tid, _) = selected_merged_table(true); + if rtl { + s.execute("table.options", &json!({"direction": "rightToLeft"})).unwrap(); + } + s.execute("table.select", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap(); + let before = table_for(&s, sid, tid).clone(); + s.execute("table.setCell", &json!({"stroke": {"weight": 3, "edges": if outer { "outer" } else { "inner" }}})).unwrap(); + let after = table_for(&s, sid, tid); + let mask = if rtl { [true, true, true, false] } else { [true, false, true, true] }; + let expected = mask.map(|edge| if outer { edge } else { !edge }); + let owner = after.cell(0, 0).unwrap(); + assert_eq!(owner.border_overrides, expected); + assert_eq!(owner.stroke_defined, expected); + for (i, on) in expected.into_iter().enumerate() { + if on { + assert_eq!(owner.strokes[i].weight, 3.0); + assert_eq!(owner.stroke_priorities[i], 1); + } else { + assert_eq!(owner.strokes[i], before.cell(0, 0).unwrap().strokes[i]); + } + } + for (r, c) in [(0, 1), (1, 0), (1, 1)] { + assert_eq!(after.cell(r, c), before.cell(r, c), "strict merged/covered mirror guard preserves {r},{c}"); + } + assert_eq!(s.doc().unwrap().selection.cells.unwrap().range, doc::CellRange::cell(0, 1)); + check(&s); + } + } +} + +#[test] +fn cell_style_sampling_resolves_covered_point_but_retains_upper_left_policy() { + let (mut s, sid, tid, _) = selected_merged_table(true); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "insets": 7})).unwrap(); + s.execute("table.select", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap(); + let selection = s.doc().unwrap().selection.clone(); + let before = table_for(&s, sid, tid).clone(); + s.execute("style.cell.create", &json!({"name": "Covered", "fromSelection": true})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.cell.iter().find(|c| c.name == "Covered").unwrap().insets, Some([7.0; 4])); + assert_eq!(table_for(&s, sid, tid), &before); + assert_eq!(s.doc().unwrap().selection, selection); + + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.select", &json!({"rows": [0, 1], "cols": [1, 1]})).unwrap(); + s.execute("table.merge", &json!({})).unwrap(); + for (row, col, insets, size) in [(0, 1, 7, 17), (1, 0, 11, 29)] { + s.execute("table.setCell", &json!({"row": row, "col": col, "text": "Text", "insets": insets})).unwrap(); + s.execute("table.select", &json!({"rows": [row, row], "cols": [col, col]})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": size}})).unwrap(); + } + s.execute("table.selectRow", &json!({})).unwrap(); + let before = table_for(&s, sid, tid).clone(); + let selection = s.doc().unwrap().selection.clone(); + // Row-major owners are [(0,1),(1,0)], while the rectangle starts at (1,0). + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 17.0); + s.execute("style.cell.create", &json!({"name": "Upper left", "fromSelection": true})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.cell.iter().find(|c| c.name == "Upper left").unwrap().insets, Some([11.0; 4])); + assert_eq!(table_for(&s, sid, tid), &before); + assert_eq!(s.doc().unwrap().selection, selection); +} + +#[test] +fn selected_merged_owners_convert_graphics_back_to_text() { + let png = designcraft_render::Rendered { width: 1, height: 1, pixels: vec![255, 0, 0, 255] }.to_png(); + let image = crate::cmd::base64_encode(&png); + for column in [true, false] { + let (mut s, sid, tid, _) = selected_merged_table(column); + let neighbor = if column { (1, 0) } else { (0, 1) }; + let covered = if column { (0, 1) } else { (1, 0) }; + for (r, c) in [covered, (1, 1), neighbor] { + let placed = s.execute("table.placeGraphic", &json!({"row": r, "col": c, "base64": image, "name": "cell.png"})).unwrap(); + if (r, c) == covered { + assert_eq!((placed["row"].as_u64(), placed["col"].as_u64()), (Some(0), Some(0))); + } + } + let before = table_for(&s, sid, tid).clone(); + let selected = s.doc().unwrap().selection.cells.unwrap(); + s.execute("table.textCell", &json!({})).unwrap(); + let after = table_for(&s, sid, tid); + for (r, c) in [(0, 0), (1, 1)] { + assert!(before.cell(r, c).unwrap().graphic.is_some()); + assert!(after.cell(r, c).unwrap().graphic.is_none()); + assert_eq!(after.cell(r, c).unwrap().text, before.cell(r, c).unwrap().text); + } + assert_eq!(after.cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1)); + assert_eq!(after.cell(covered.0, covered.1), before.cell(covered.0, covered.1)); + assert_eq!(s.doc().unwrap().selection.cells, Some(selected)); + check(&s); + } +} + +#[test] +fn covered_selected_owner_drag_keeps_merged_move_restrictions_atomic() { + for column in [true, false] { + let (mut s, sid, _, fid) = selected_merged_table(column); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let revision = s.doc().unwrap().revision; + let undo = s.doc().unwrap().history.undo.len(); + let redo = s.doc().unwrap().history.redo.len(); + let cs = s.cache.get(&before, doc::StoryId(sid), None); + let cells = &cs.frames[0].tables[0].cells; + let point = |row, col| { + let cell = cells.iter().find(|c| c.row == row && c.col == col).unwrap(); + before.text_xf(before.item(doc::ItemId(fid)).unwrap()) * cell.rect.center() + }; + let from = point(0, 0); + let to = if column { point(1, 0) } else { point(0, 1) }; + let err = s.execute("table.dropCells", &json!({"frame": fid, "from": [from.x, from.y], "to": [to.x, to.y]})).unwrap_err(); + assert!(err.to_string().contains(if column { "unmerge cells that span columns" } else { "unmerge cells that span rows" }), "{err}"); + let st = s.doc().unwrap(); + assert_eq!(st.doc, before); + assert_eq!(st.selection, selection); + assert_eq!(st.revision, revision); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, redo)); + } +} + #[test] fn insert_type_in_cells_and_tab() { let (mut s, sid, _) = session_with_frame(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae294..cc7cee3d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -946,11 +946,12 @@ fn draw_text_selection( let range = ts.range(); // Selected table cells: tint every selected cell. if let Some(sel) = cells.filter(|c| c.story == ts.story) { + let selected = doc.story(sel.story).and_then(|s| s.tables.get(&sel.table)).map(|t| t.selected_owners(sel.range)).unwrap_or_default(); for ft in &cs.frames { let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; let m = a * doc.text_xf(it); for t in ft.tables.iter().filter(|t| t.table == sel.table) { - for c in t.cells.iter().filter(|c| sel.range.contains(c.row, c.col)) { + for c in t.cells.iter().filter(|c| selected.binary_search(&(c.row, c.col)).is_ok()) { let q = [ xf.to_screen(m * Point::new(c.rect.x0, c.rect.y0)), xf.to_screen(m * Point::new(c.rect.x1, c.rect.y0)), @@ -1805,6 +1806,79 @@ mod tests { (s.horizontal_units, s.vertical_units) } + #[test] + fn selected_merged_owners_paint_the_same_row_and_column_targets() { + for column in [true, false] { + for merged in [false, true] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 1})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 300, 300], "content": "text"})).unwrap(); + app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap(); + if merged { + let range = if column { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) }; + app.run("table.select", range).unwrap(); + app.run("table.merge", json!({})).unwrap(); + } + app.run("table.select", json!({"rows": [1, 1], "cols": [1, 1]})).unwrap(); + app.run(if column { "table.selectColumn" } else { "table.selectRow" }, json!({})).unwrap(); + let st = app.session.doc().unwrap(); + let selection = st.selection.clone(); + let ts = selection.text.unwrap(); + let selected = selection.cells.unwrap(); + assert_eq!( + selected.range, + if column { designcraft_doc::CellRange::new(0, 1, 1, 1) } else { designcraft_doc::CellRange::new(1, 0, 1, 1) } + ); + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let cs = app.session.cache.get(&st.doc, ts.story, None); + let ft = &cs.frames[0]; + let table = ft.tables.iter().find(|t| t.table == selected.table).unwrap(); + let (a, _) = item_canvas_xf(&st.doc, &layout, ft.frame).unwrap(); + let m = a * st.doc.text_xf(st.doc.item(ft.frame).unwrap()); + let xf = Xf { min: pos2(0.0, 0.0), origin: Point::new(0.0, 0.0), zoom: 1.0, rot: 0, pivot: pos2(0.0, 0.0) }; + let owners = if merged { + [(0, 0), (1, 1)] + } else if column { + [(0, 1), (1, 1)] + } else { + [(1, 0), (1, 1)] + }; + let expected: Vec<_> = owners + .into_iter() + .map(|(r, c)| { + let rect = table.cells.iter().find(|cell| cell.row == r && cell.col == c).unwrap().rect; + vec![ + xf.to_screen(m * Point::new(rect.x0, rect.y0)), + xf.to_screen(m * Point::new(rect.x1, rect.y0)), + xf.to_screen(m * Point::new(rect.x1, rect.y1)), + xf.to_screen(m * Point::new(rect.x0, rect.y1)), + ] + }) + .collect(); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + draw_text_selection(&app, ui.painter(), &xf, &st.doc, &layout, ts, Some(selected)); + }); + output.textures_delta.clear(); + let tint = Color32::from_rgba_unmultiplied(80, 140, 255, 90); + let painted: Vec<_> = output + .shapes + .into_iter() + .filter_map(|s| match s.shape { + egui::Shape::Path(path) if path.fill == tint => { + assert!(path.closed); + assert_eq!(path.stroke, egui::epaint::PathStroke::NONE); + Some(path.points) + } + _ => None, + }) + .collect(); + assert_eq!(painted, expected, "only the two touched owners are tinted; column={column}, merged={merged}"); + assert_eq!(app.session.doc().unwrap().selection, selection); + } + } + } + #[test] fn right_clicking_a_ruler_sets_that_rulers_units() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 3539a72b2f30e7f42031a65452d4ad69473d6463 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:23:48 +0200 Subject: [PATCH 08/32] Canvas: baseline grid draws only the lines on the page (#844) The grid was walked from its start, one line per step, with no bound. A start far above the page (from document.preferences, a saved file or an IDML package) made the next frame draw lines until the app ran out of memory. The lines are now the ones inside the page, found without walking those above it, and never more than one per point of the tallest page. --- crates/ui-egui/src/canvas.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae294..45cfbbe6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -584,6 +584,17 @@ fn dashed(painter: &egui::Painter, a: Pos2, b: Pos2, stroke: Stroke, dash: f32, painter.extend(egui::Shape::dashed_line(&[a, b], stroke, dash, gap)); } +/// The most baseline grid lines drawn on a page: one per point of the tallest page (216 in). +const MAX_BASELINES: usize = 216 * 72; + +/// Baseline grid lines inside a page, measured from its top: `start + n * increment` for n >= 0 (the bottom +/// edge is not a line). Lines above the page are skipped without walking them, however far up the start is. +fn baseline_grid_lines(start: f64, increment: f64, height: f64) -> impl Iterator { + let step = increment.max(1.0); + let first = if start < 0.0 { start.rem_euclid(step) } else { start }; + (0..MAX_BASELINES).map(move |n| first + n as f64 * step).take_while(move |y| *y < height) +} + fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) { let s = &doc.settings; for slot in &layout.slots { @@ -612,13 +623,11 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document // Baseline grid. if app.ui.baseline_grid && xf.zoom >= s.baseline_grid.view_threshold { let g = &s.baseline_grid; - let mut y = g.start; - while y < p.height { + for y in baseline_grid_lines(g.start, g.increment, p.height) { painter.line_segment( [pt(Point::new(pr.x0, pr.y0 + y)), pt(Point::new(pr.x1, pr.y0 + y))], Stroke::new(hair(painter), c32(g.color).gamma_multiply(0.8)), ); - y += g.increment.max(1.0); } } // Margins (magenta) and columns (violet). @@ -1918,4 +1927,18 @@ mod tests { right_click(&mut h, pos2(rect.center().x, rect.min.y - RULER / 2.0)); assert!(h.query_by_label(" Points").is_none()); } + + #[test] + fn baseline_grid_lines_are_the_ones_on_the_page() { + // A start far above the page used to be walked line by line, without end. + for start in [-1e9, -1e17] { + let mut far = baseline_grid_lines(start, 12.0, 792.0); + assert_eq!((far.next(), far.count()), (Some(8.0), 65)); + } + let lines = |start, increment, height| baseline_grid_lines(start, increment, height).collect::>(); + assert_eq!(lines(36.0, 12.0, 72.0), [36.0, 48.0, 60.0], "the bottom edge is not a line"); + assert_eq!(lines(-30.0, 12.0, 30.0), [6.0, 18.0], "a start above the page keeps its rhythm"); + assert_eq!(lines(f64::NAN, 12.0, 792.0), [0.0; 0]); + assert_eq!(baseline_grid_lines(0.0, 12.0, f64::INFINITY).count(), MAX_BASELINES); + } } From 129adfa42921d166b962e7b554e32aa2ba8bb90d Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 20:41:56 +0000 Subject: [PATCH 09/32] Layout: Table of Contents dialog with entry styles, page number position and IDML TOC styles (#755) --- ROADMAP.md | 1 + crates/doc/src/lib.rs | 27 ++++ crates/engine/src/cmd/toc.rs | 220 +++++++++++++++++++++++--- crates/engine/src/tests_idml.rs | 30 ++++ crates/idml/src/export.rs | 46 +++++- crates/idml/src/import.rs | 43 ++++- crates/idml/src/names.rs | 26 ++++ crates/ui-egui/src/dialogs.rs | 267 +++++++++++++++++++++++++++++++- crates/ui-egui/src/menus.rs | 4 + docs/target-app-parity.md | 2 +- 10 files changed, 641 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f8207..b61dc953 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** · 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-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/doc/src/lib.rs b/crates/doc/src/lib.rs index a643cc08..337b024f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -584,6 +584,30 @@ pub struct TocEntry { /// 1-based nesting level. #[serde(default = "one")] pub level: u8, + /// Paragraph style of this style's entries; `None` is the generated "TOC Level n" style. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entry_style: Option, + #[serde(default)] + pub page_number: TocPageNumber, + /// Text between the entry and its page number; `None` is a tab. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub separator: Option, +} + +impl TocEntry { + pub fn new(style: impl Into, level: u8) -> Self { + Self { style: style.into(), level, entry_style: None, page_number: TocPageNumber::After, separator: None } + } +} + +/// Where an entry's page number goes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TocPageNumber { + #[default] + After, + Before, + None, } fn one() -> u8 { @@ -598,6 +622,9 @@ pub struct Toc { pub entries: Vec, #[serde(default)] pub page_numbers: bool, + /// Paragraph style of the title; `None` is the generated "TOC Title" style. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title_style: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/cmd/toc.rs b/crates/engine/src/cmd/toc.rs index d6ed53d6..30ef0e87 100644 --- a/crates/engine/src/cmd/toc.rs +++ b/crates/engine/src/cmd/toc.rs @@ -1,9 +1,9 @@ //! Layout → Table of Contents: list the paragraphs of chosen styles with their page names, as a -//! story with "TOC Title" / "TOC Level n" styles (right tab with dot leader). Update Table of -//! Contents regenerates the same story after edits. +//! story with "TOC Title" / "TOC Level n" styles (right tab with dot leader) or the entry styles +//! chosen per style. Update Table of Contents regenerates the same story after edits. use designcraft_compose::{ComposeOptions, caret, compose_story}; -use designcraft_doc::{Align, Document, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, StoryId, TabAlign, TabStop, Toc, TocEntry}; +use designcraft_doc::{Align, Document, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, StoryId, TabAlign, TabStop, Toc, TocEntry, TocPageNumber}; use designcraft_geom::Rect; use serde_json::{Value, json}; @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "Table of Contents…", ["Layout"], None, - "{entries: [{style, level?: 1}] | styles: [names], title?: \"Contents\", pageNumbers?: true, page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", + "{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", has_doc, generate ), @@ -44,20 +44,66 @@ fn has_toc(s: &Session) -> std::result::Result<(), String> { } } +const MAX_ENTRIES: usize = 64; +const MAX_NAME: usize = 256; +const MAX_TITLE: usize = 1024; +const MAX_SEPARATOR: usize = 64; + fn entries_param(p: &Value) -> Result> { - if let Some(v) = p.get("entries") { - return serde_json::from_value(v.clone()).map_err(|e| bad("toc.generate", e.to_string())); + let mut entries: Vec = if let Some(v) = p.get("entries") { + if v.as_array().is_some_and(|a| a.len() > MAX_ENTRIES) { + return Err(bad("toc.generate", format!("at most {MAX_ENTRIES} entries"))); + } + serde_json::from_value(v.clone()).map_err(|e| bad("toc.generate", e.to_string()))? + } else if let Some(a) = p.get("styles").and_then(Value::as_array) { + if a.len() > MAX_ENTRIES { + return Err(bad("toc.generate", format!("at most {MAX_ENTRIES} styles"))); + } + a.iter().enumerate().filter_map(|(i, v)| v.as_str().map(|s| TocEntry::new(s, (i + 1).min(9) as u8))).collect() + } else { + return Err(bad("toc.generate", "give `entries` or `styles`")); + }; + for e in &mut entries { + if !(1..=9).contains(&e.level) { + return Err(bad("toc.generate", format!("level {} of `{}` is not 1–9", e.level, e.style))); + } + if e.style.chars().count() > MAX_NAME || e.entry_style.as_ref().is_some_and(|n| n.chars().count() > MAX_NAME) { + return Err(bad("toc.generate", format!("style names are at most {MAX_NAME} characters"))); + } + if let Some(sep) = &mut e.separator { + if sep.chars().count() > MAX_SEPARATOR { + return Err(bad("toc.generate", format!("the separator is at most {MAX_SEPARATOR} characters"))); + } + *sep = one_line(sep); + } + // The defaults are stored as `None`, so a definition reads the same however it was made. + if e.entry_style.as_deref() == Some("") { + e.entry_style = None; + } + if e.separator.as_deref() == Some("\t") { + e.separator = None; + } } - if let Some(a) = p.get("styles").and_then(Value::as_array) { - return Ok(a.iter().enumerate().filter_map(|(i, v)| v.as_str().map(|s| TocEntry { style: s.into(), level: (i + 1).min(9) as u8 })).collect()); + Ok(entries) +} + +/// Paragraph breaks in a title or separator would shift the paragraph styles after them. +fn one_line(s: &str) -> String { + s.chars().map(|c| if matches!(c, '\n' | '\r' | '\u{2029}') { ' ' } else { c }).collect() +} + +fn para_style_exists(d: &Document, name: &str) -> Result<()> { + match d.styles.para(name) { + Some(_) => Ok(()), + None => Err(bad("toc.generate", format!("no paragraph style `{name}`"))), } - Err(bad("toc.generate", "give `entries` or `styles`")) } pub struct Found { pub level: u8, pub text: String, pub page: String, + entry: usize, key: (usize, i64, i64), } @@ -70,7 +116,7 @@ pub fn collect(d: &Document, entries: &[TocEntry], skip: Option) -> Vec } let cs = compose_story(d, *sid, &ComposeOptions::default()); for (pi, r) in st.para_ranges().into_iter().enumerate() { - let Some(e) = entries.iter().find(|e| e.style == st.paras[pi].style) else { continue }; + let Some((ei, e)) = entries.iter().enumerate().find(|(_, e)| e.style == st.paras[pi].style) else { continue }; let text: String = st.text[r.clone()] .chars() .filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)) @@ -95,6 +141,7 @@ pub fn collect(d: &Document, entries: &[TocEntry], skip: Option) -> Vec out.push(Found { level: e.level, text, + entry: ei, page: d.page_name(abs), key: (abs, ((b.y0 + baseline) * 10.0) as i64, ((b.x0 + x) * 10.0) as i64), }); @@ -146,22 +193,35 @@ fn fill(d: &mut Document, toc: &Toc, width: Option) -> Result { d.story(toc.story).and_then(|st| st.frames.first()).and_then(|f| d.item(*f)).map(|i| i.text_area().width()).unwrap_or(300.0) }); ensure_styles(d, levels, width); + for name in toc.entries.iter().filter_map(|e| e.entry_style.as_deref()).chain(toc.title_style.as_deref()) { + para_style_exists(d, name)?; + } let mut text = String::new(); let mut styles = Vec::new(); if !toc.title.is_empty() { text.push_str(&toc.title); - styles.push("TOC Title".to_string()); + styles.push(toc.title_style.clone().unwrap_or_else(|| "TOC Title".into())); } for f in &found { + let Some(e) = toc.entries.get(f.entry) else { continue }; if !text.is_empty() { text.push('\n'); } - text.push_str(&f.text); - if toc.page_numbers { - text.push('\t'); - text.push_str(&f.page); + let sep = e.separator.as_deref().unwrap_or("\t"); + match e.page_number { + TocPageNumber::After if toc.page_numbers => { + text.push_str(&f.text); + text.push_str(sep); + text.push_str(&f.page); + } + TocPageNumber::Before if toc.page_numbers => { + text.push_str(&f.page); + text.push_str(sep); + text.push_str(&f.text); + } + _ => text.push_str(&f.text), } - styles.push(format!("TOC Level {}", f.level.max(1))); + styles.push(e.entry_style.clone().unwrap_or_else(|| format!("TOC Level {}", f.level.max(1)))); } let st = d.story_mut(toc.story).ok_or(designcraft_doc::DocError::NoStory(toc.story))?; let len = st.len(); @@ -179,13 +239,29 @@ fn generate(s: &mut Session, p: &Value) -> Result { if entries.is_empty() { return Err(bad("toc.generate", "no entries")); } - let title = str_param(p, "title").unwrap_or("Contents").to_string(); + let title = str_param(p, "title").unwrap_or("Contents"); + if title.chars().count() > MAX_TITLE { + return Err(bad("toc.generate", format!("the title is at most {MAX_TITLE} characters"))); + } + let title = one_line(title); + let title_style = str_param(p, "titleStyle").filter(|n| !n.is_empty()).map(str::to_string); + if title_style.as_ref().is_some_and(|n| n.chars().count() > MAX_NAME) { + return Err(bad("toc.generate", format!("style names are at most {MAX_NAME} characters"))); + } let page_numbers = p.get("pageNumbers").and_then(Value::as_bool).unwrap_or(true); + let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(true); let rect = super::rect_param(p, "rect"); - let page = p.get("page").and_then(Value::as_u64).unwrap_or(1).max(1) as usize - 1; + if rect.is_some_and(|r| ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0) { + return Err(bad("toc.generate", "`rect` must be finite with a positive size")); + } + let page = p.get("page").and_then(Value::as_u64).map_or(Some(0), |n| usize::try_from(n.max(1) - 1).ok()); + let page = page.ok_or_else(|| bad("toc.generate", "no such page"))?; s.edit(|d, sel| { - // Reuse the existing TOC story when there is one; otherwise make a frame. - let existing = d.toc.as_ref().map(|t| t.story).filter(|sid| d.story(*sid).is_some()); + for e in &entries { + para_style_exists(d, &e.style)?; + } + // Replace: regenerate the existing TOC story in place; otherwise make a frame. + let existing = d.toc.as_ref().map(|t| t.story).filter(|sid| replace && d.story(*sid).is_some()); let (story, width) = match existing { Some(sid) => (sid, None), None => { @@ -198,7 +274,7 @@ fn generate(s: &mut Session, p: &Value) -> Result { (sid, w) } }; - let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers }; + let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers, title_style: title_style.clone() }; let n = fill(d, &toc, width)?; d.toc = Some(toc); Ok(json!({"story": story.0, "entries": n})) @@ -237,4 +313,106 @@ mod tests { assert!(text.contains("Thoughts on the Grid"), "{text}"); s.doc().unwrap().doc.check().unwrap(); } + + fn sample_with_toc_page() -> (Session, usize) { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let pages = s.doc().unwrap().doc.page_count(); + s.execute("layout.pages.insert", &json!({"count": 1})).unwrap(); + (s, pages + 1) + } + + fn toc_lines(s: &Session) -> Vec<(String, String)> { + let d = &s.doc().unwrap().doc; + let st = d.story(d.toc.as_ref().unwrap().story).unwrap(); + st.para_ranges().into_iter().zip(&st.paras).map(|(r, f)| (st.text[r].trim_end_matches('\n').to_string(), f.style.clone())).collect() + } + + #[test] + fn toc_entry_style_page_number_position_and_separator_survive_update() { + let (mut s, page) = sample_with_toc_page(); + let basic = designcraft_doc::story::BASIC_PARAGRAPH; + s.execute( + "toc.generate", + &json!({"page": page, "title": "In This Issue", "titleStyle": basic, "entries": [ + {"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " — "}, + {"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"}, + ]}), + ) + .unwrap(); + let check = |s: &Session, headline: &str| { + let lines = toc_lines(s); + assert_eq!(lines[0], ("In This Issue".to_string(), basic.to_string())); + let cover = lines.iter().find(|(t, _)| t.contains("The Quiet")).unwrap(); + assert!(cover.0.starts_with("1 — The Quiet"), "{cover:?}"); + assert_eq!(cover.1, "TOC Level 1"); + let head = lines.iter().find(|(t, _)| t.contains("on the Grid")).unwrap(); + assert!(head.0.starts_with(headline) && !head.0.contains('\t'), "{head:?}"); + assert_eq!(head.1, basic); + }; + check(&s, "Notes on the Grid"); + let d = &s.doc().unwrap().doc; + let hid = d.stories.iter().find(|(_, st)| st.text.starts_with("Notes on the Grid")).map(|(id, _)| *id).unwrap(); + s.execute("story.replaceRange", &json!({"story": hid.0, "start": 0, "end": 5, "text": "Thoughts"})).unwrap(); + s.execute("toc.update", &json!({})).unwrap(); + check(&s, "Thoughts on the Grid"); + } + + #[test] + fn replace_regenerates_the_toc_in_place_and_off_makes_a_new_frame() { + let (mut s, page) = sample_with_toc_page(); + let stories = |s: &Session| s.doc().unwrap().doc.stories.len(); + let first = s.execute("toc.generate", &json!({"styles": ["Headline"], "page": page})).unwrap()["story"].clone(); + let n = stories(&s); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("toc.generate", &json!({"styles": ["Headline"], "title": "Inside", "page": page, "replace": true})).unwrap(); + assert_eq!(r["story"], first); + assert_eq!(stories(&s), n, "one table of contents"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(toc_lines(&s)[0].0, "Inside"); + let r = s.execute("toc.generate", &json!({"styles": ["Headline"], "page": page, "replace": false})).unwrap(); + assert_ne!(r["story"], first); + assert_eq!(stories(&s), n + 1); + assert_eq!(s.doc().unwrap().doc.toc.as_ref().unwrap().story.0, r["story"].as_u64().unwrap()); + } + + #[test] + fn hostile_toc_params_are_errors_that_leave_the_document_alone() { + let (mut s, page) = sample_with_toc_page(); + let long = "x".repeat(5000); + let many: Vec = (0..65).map(|_| json!({"style": "Headline"})).collect(); + for p in [ + json!({"entries": [{"style": "Headline", "level": 0}]}), + json!({"entries": [{"style": "Headline", "level": 10}]}), + json!({"entries": [{"style": "Headline", "level": 300}]}), + json!({"entries": [{"style": "Headline", "level": -1}]}), + json!({"entries": [{"style": "Headline", "pageNumber": "sideways"}]}), + json!({"entries": [{"style": "No Such Style"}]}), + json!({"entries": [{"style": "Headline", "entryStyle": "No Such Style"}]}), + json!({"entries": [{"style": "Headline", "separator": long}]}), + json!({"entries": [{"style": long}]}), + json!({"entries": many}), + json!({"entries": "Headline"}), + json!({"entries": []}), + json!({"styles": ["Headline"], "titleStyle": "No Such Style"}), + json!({"styles": ["Headline"], "title": long}), + json!({"styles": ["Headline"], "rect": [100, 100, 50, 50]}), + json!({"styles": ["Headline"], "page": u64::MAX}), + json!([1, 2]), + ] { + let rev = s.doc().unwrap().revision; + assert!(s.execute("toc.generate", &merge(&p, page)).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().revision, rev, "{p}"); + assert!(s.doc().unwrap().doc.toc.is_none()); + } + } + + /// `p` with the TOC page added when it is an object and has none. + fn merge(p: &Value, page: usize) -> Value { + let mut p = p.clone(); + if let Some(o) = p.as_object_mut() { + o.entry("page").or_insert(json!(page)); + } + p + } } diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index cc40768e..143e4669 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -251,3 +251,33 @@ fn named_lists_round_trip_through_idml() { assert_eq!(st.paras[0].para.list_name.as_deref(), Some("Steps")); assert_eq!(st.paras[0].para.start_at, Some(Some(5))); } + +#[test] +fn table_of_contents_definition_round_trips_through_idml_and_updates() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let page = s.doc().unwrap().doc.page_count() + 1; + s.execute("layout.pages.insert", &json!({"count": 1})).unwrap(); + let basic = designcraft_doc::story::BASIC_PARAGRAPH; + s.execute( + "toc.generate", + &json!({"page": page, "title": "Inside", "titleStyle": basic, "entries": [ + {"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ "}, + {"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"}, + ]}), + ) + .unwrap(); + let toc_text = |s: &Session| { + let d = &s.doc().unwrap().doc; + d.story(d.toc.as_ref().unwrap().story).unwrap().text.clone() + }; + let before = s.doc().unwrap().doc.toc.clone().unwrap(); + let text = toc_text(&s); + let b64 = s.execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.openIdml", &json!({"base64": b64, "name": "toc.idml"})).unwrap(); + let after = s.doc().unwrap().doc.toc.clone().expect("the TOC definition is imported"); + assert_eq!((&after.title, &after.title_style, &after.entries), (&before.title, &before.title_style, &before.entries)); + assert_eq!(toc_text(&s), text); + s.execute("toc.update", &json!({})).unwrap(); + assert_eq!(toc_text(&s), text); +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index d01abe9e..2d1bf32d 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -16,6 +16,8 @@ use crate::{DOM_VERSION, MIMETYPE, base64_encode}; const PKG_NS: &str = "http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"; const NO_PARA_ID: &str = "ParagraphStyle/$ID/[No paragraph style]"; +const TOC_STYLE_ID: &str = "TOCStyle/Table of Contents"; +const TOC_STYLE_NAME: &str = "Table of Contents"; const NO_CHAR_ID: &str = "CharacterStyle/$ID/[No character style]"; /// Export options. @@ -1049,9 +1051,46 @@ impl<'a> Ex<'a> { let mut ro = El::new("RootObjectStyleGroup").attr("Self", self.fresh()); og.emit(&mut ro, "ObjectStyleGroup", ""); root.push(ro); + if let Some(toc) = d.toc.as_ref().filter(|t| d.story(t.story).is_some()) { + root.push(self.toc_style_el(toc)); + } document(&root) } + /// The document's table of contents as a TOC style; its story names it in `AppliedTOCStyle`. + fn toc_style_el(&mut self, toc: &designcraft_doc::Toc) -> El { + let para = |n: &str| names::style_self("ParagraphStyle", PARA_BUILTINS, n); + let mut el = El::new("TOCStyle") + .attr("Self", TOC_STYLE_ID) + .attr("Name", TOC_STYLE_NAME) + .attr("Title", &toc.title) + .attr("TitleStyle", para(toc.title_style.as_deref().unwrap_or("TOC Title"))) + .attr("RunIn", "false") + .attr("IncludeHidden", "false") + .attr("IncludeBookDocuments", "false") + .attr("CreatePdfBookmarks", "false"); + for e in &toc.entries { + let format = e.entry_style.clone().unwrap_or_else(|| format!("TOC Level {}", e.level)); + let position = match e.page_number { + _ if !toc.page_numbers => "NoPageNumber", + designcraft_doc::TocPageNumber::After => "AfterEntry", + designcraft_doc::TocPageNumber::Before => "BeforeEntry", + designcraft_doc::TocPageNumber::None => "NoPageNumber", + }; + el.push( + El::new("TOCStyleEntry") + .attr("Self", self.fresh()) + .attr("Name", names::style_name_out(PARA_BUILTINS, &e.style)) + .attr("FormatStyle", para(&format)) + .attr("Level", e.level) + .attr("PageNumberPosition", position) + .attr("Separator", names::toc_separator_out(e.separator.as_deref().unwrap_or("\t"))) + .attr("Sort", "false"), + ); + } + el + } + fn char_attrs(&mut self, el: &mut El, props: &mut Vec, a: &CharAttrs) { if let Some(v) = a.leading_aki { el.set("LeadingAki", num(v.unwrap_or(-1.0))); @@ -1923,7 +1962,12 @@ impl<'a> Ex<'a> { // ---------- stories ---------- fn story_el(&mut self, s: &Story) -> El { - let mut el = El::new("Story").attr("Self", uid(s.id.0)).attr("AppliedTOCStyle", "n").attr("TrackChanges", "false").attr("StoryTitle", "$ID/"); + let toc = self.d.toc.as_ref().is_some_and(|t| t.story == s.id); + let mut el = El::new("Story") + .attr("Self", uid(s.id.0)) + .attr("AppliedTOCStyle", if toc { TOC_STYLE_ID } else { "n" }) + .attr("TrackChanges", "false") + .attr("StoryTitle", "$ID/"); el.push( El::new("StoryPreference") .attr("OpticalMarginAlignment", "false") diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 59ca44be..c7fe2aec 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -209,6 +209,7 @@ struct Importer<'r> { /// Index topic Self → topic path and per-level sort keys; topic cross-references. index_topics: HashMap, index_xrefs: Vec, + toc: Option, } /// IDML stores a sort key on each topic, rather than on its page references. @@ -312,6 +313,7 @@ impl<'r> Importer<'r> { xref_formats: designcraft_doc::xref::default_formats(), index_topics: HashMap::new(), index_xrefs: Vec::new(), + toc: None, } } @@ -485,6 +487,7 @@ impl<'r> Importer<'r> { let story = self.story(id, e); self.stories.insert(id, story); } + self.toc = self.toc_of(top); // Topic cross-references (See / See also) become markers at the start of the first story // with text. if !self.index_xrefs.is_empty() @@ -1140,6 +1143,44 @@ impl<'r> Importer<'r> { self.footnote_options = o; } + /// The table of contents: the TOC style a story is generated from (`AppliedTOCStyle`), with + /// its entries. Styles the document doesn't have fall back to the generated TOC styles. + fn toc_of(&self, top: &[El]) -> Option { + use designcraft_doc::{TocEntry, TocPageNumber}; + let (ts, story) = top.iter().filter(|e| e.local() == "TOCStyle").find_map(|ts| { + let id = ts.get("Self")?; + let story = top.iter().find(|e| e.local() == "Story" && e.get("AppliedTOCStyle") == Some(id))?; + Some((ts, *self.story_ids.get(story.get("Self")?)?)) + })?; + let known = |r: Option<&str>| r.map(|r| self.para_style_ref(r)).filter(|n| self.styles.para(n).is_some()); + let entries: Vec = ts + .elements() + .filter(|e| e.local() == "TOCStyleEntry") + .filter_map(|e| { + let style = names::style_name_in(PARA_BUILTINS, e.get("Name")?); + if style.is_empty() || style.chars().count() > 256 { + return None; + } + let level = e.get("Level").and_then(|l| l.trim().parse::().ok()).unwrap_or(1).clamp(1, 9); + let page_number = match e.get("PageNumberPosition") { + Some("BeforeEntry") => TocPageNumber::Before, + Some("NoPageNumber") => TocPageNumber::None, + _ => TocPageNumber::After, + }; + let separator = e.get("Separator").map(crate::names::toc_separator_in).filter(|s| s != "\t" && s.chars().count() <= 64); + let entry_style = known(e.get("FormatStyle")).filter(|n| *n != format!("TOC Level {level}")); + Some(TocEntry { style, level, entry_style, page_number, separator }) + }) + .take(64) + .collect(); + if entries.is_empty() { + return None; + } + let title: String = ts.get("Title").unwrap_or("").chars().filter(|c| !matches!(c, '\n' | '\r' | '\u{2029}')).take(1024).collect(); + let title_style = known(ts.get("TitleStyle")).filter(|n| n != "TOC Title"); + Some(designcraft_doc::Toc { story, title, entries, page_numbers: true, title_style }) + } + fn para_style_ref(&self, r: &str) -> String { self.para_names.get(r).cloned().unwrap_or_else(|| names::style_name_in(PARA_BUILTINS, &unescape_id(r.trim_start_matches("ParagraphStyle/")))) } @@ -2434,7 +2475,7 @@ impl<'r> Importer<'r> { bookmarks: vec![], user_words: vec![], hyphenation_exceptions: vec![], - toc: None, + toc: self.toc.take(), text_variables: designcraft_doc::vars::defaults(), footnote_options: std::mem::take(&mut self.footnote_options), xref_formats: std::mem::take(&mut self.xref_formats), diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 1df0f56d..3d863ef3 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -689,3 +689,29 @@ pub fn digits_in(s: &str) -> Option { use designcraft_doc::Digits; [Digits::Default, Digits::Arabic, Digits::Hindi, Digits::Farsi, Digits::Native].into_iter().find(|d| digits_out(*d) == s) } + +/// A TOC separator in IDML's special-character notation: `^t` is a tab, `^^` a caret. +pub fn toc_separator_out(s: &str) -> String { + s.replace('^', "^^").replace('\t', "^t") +} +/// IDML TOC separator → text (`^t` and `^y`, the right indent tab, are tabs; other codes stay). +pub fn toc_separator_in(s: &str) -> String { + let mut out = String::new(); + let mut it = s.chars(); + while let Some(c) = it.next() { + if c != '^' { + out.push(c); + continue; + } + match it.next() { + Some('t' | 'y') => out.push('\t'), + Some('^') => out.push('^'), + Some(o) => { + out.push('^'); + out.push(o); + } + None => out.push('^'), + } + } + out +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 695a66af..d8178f36 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2,7 +2,7 @@ //! (`ui.dialog.set {field, value}`) and confirm them (`ui.dialog.confirm`) like a user would. use designcraft_geom::{Unit, format_measure, units::parse_measure}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use crate::DesignApp; @@ -1657,6 +1657,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); } STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), + TOC => toc_dialog(app, ui, &mut d), id if id.starts_with("cmd:") => command_form(app, ui, &mut d), "goToPage" => { ui.horizontal(|ui| { @@ -2188,6 +2189,15 @@ pub fn confirm(app: &mut DesignApp) -> Result { } r } + TOC => { + let r = app.run("toc.generate", toc_params(app, &d)); + if let Err(e) = &r { + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + } + r + } id if id.starts_with("cmd:") => { let cid = &id[4..]; let doc = designcraft_engine::find_command(cid).map_or("", |c| c.params); @@ -3580,6 +3590,223 @@ fn command_form(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } const STEP_AND_REPEAT: &str = "cmd:edit.stepAndRepeat"; +const TOC: &str = "cmd:toc.generate"; + +/// One included style of the Table of Contents dialog, as `toc.generate` takes it, with the +/// separator in the dialog's notation (`^t` a tab). +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", default)] +struct TocRow { + style: String, + level: u8, + entry_style: String, + page_number: String, + separator: String, +} + +fn toc_separator_shown(s: &str) -> String { + s.replace('\t', "^t").replace('\u{2003}', "^m").replace('\u{2002}', "^>") +} + +fn toc_separator_typed(s: &str) -> String { + s.replace("^t", "\t").replace("^m", "\u{2003}").replace("^>", "\u{2002}") +} + +fn toc_rows(d: &Dialog) -> Vec { + d.fields.get("included").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default() +} + +/// Layout › Table of Contents…: prefilled from the document's table of contents when it has one. +pub fn open_toc(app: &mut DesignApp) { + let Some(st) = app.session.active() else { return }; + let d = &st.doc; + let styles: Vec<&str> = d.styles.paragraph.iter().map(|p| p.name.as_str()).filter(|n| *n != designcraft_doc::NO_PARA_STYLE).collect(); + let toc = d.toc.as_ref().filter(|t| d.story(t.story).is_some()); + let rows: Vec = toc + .map(|t| { + t.entries + .iter() + .map(|e| TocRow { + style: e.style.clone(), + level: e.level, + entry_style: e.entry_style.clone().unwrap_or_default(), + page_number: match (t.page_numbers, e.page_number) { + (false, _) | (_, designcraft_doc::TocPageNumber::None) => "none", + (_, designcraft_doc::TocPageNumber::Before) => "before", + _ => "after", + } + .into(), + separator: toc_separator_shown(e.separator.as_deref().unwrap_or("\t")), + }) + .collect() + }) + .unwrap_or_default(); + let fields = json!({ + "title": toc.map_or("Contents", |t| t.title.as_str()), + "titleStyle": toc.and_then(|t| t.title_style.clone()).unwrap_or_default(), + "included": rows, + "selected": 0, + "styles": styles, + "hasToc": toc.is_some(), + "replace": toc.is_some(), + }); + app.ui.dialog = Some(Dialog::new(TOC, fields)); +} + +/// The dialog's fields as `toc.generate` params. A new frame goes on the page in view. +fn toc_params(app: &DesignApp, d: &Dialog) -> Value { + let entries: Vec = toc_rows(d) + .iter() + .map(|r| { + let mut e = json!({"style": r.style, "level": r.level, "separator": toc_separator_typed(&r.separator)}); + if !r.entry_style.is_empty() { + e["entryStyle"] = json!(r.entry_style); + } + if !r.page_number.is_empty() { + e["pageNumber"] = json!(r.page_number); + } + e + }) + .collect(); + let mut p = json!({"title": d.s("title"), "entries": entries, "replace": d.b("replace")}); + let title_style = d.s("titleStyle"); + if !title_style.is_empty() { + p["titleStyle"] = json!(title_style); + } + if let Some(page) = crate::canvas::current_page(app) { + p["page"] = json!(page + 1); + } + p +} + +/// A paragraph style menu whose first choice, stored as "", is the generated style `auto`. +fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str, cur: &mut String) -> bool { + let shown = if cur.is_empty() { auto.to_string() } else { cur.clone() }; + let mut changed = false; + egui::ComboBox::from_id_salt(("toc_style", salt)).selected_text(shown).width(200.0).show_ui(ui, |ui| { + if ui.selectable_label(cur.is_empty(), auto).clicked() { + cur.clear(); + changed = true; + } + for n in styles.iter().filter(|n| *n != auto) { + if ui.selectable_label(cur == n, n.as_str()).clicked() { + *cur = n.clone(); + changed = true; + } + } + }); + changed +} + +/// Layout › Table of Contents: the title and its style, the paragraph styles to include (Add / +/// Remove between the two lists) and, for the selected one, its entry style, page number +/// position, separator and level. +fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + let styles: Vec = d.fields.get("styles").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let mut rows = toc_rows(d); + let mut sel = d.n("selected").map_or(0, |n| n.max(0.0) as usize); + let mut other = d.s("other"); + let mut title_style = d.s("titleStyle"); + egui::Grid::new("toc_title").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, tr("Title:")); + text_field(ui, d, "title", 200.0); + crate::rtl::label(ui, tr("Style:")); + toc_style_combo(ui, "title", &styles, "TOC Title", &mut title_style); + ui.end_row(); + }); + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(tr("Styles in Table of Contents")).font(semibold(12.0))); + let others: Vec<&String> = styles.iter().filter(|n| !rows.iter().any(|r| &r.style == *n)).collect(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + crate::rtl::label(ui, tr("Include Paragraph Styles:")); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_width(200.0); + egui::ScrollArea::vertical().id_salt("toc_included").max_height(150.0).min_scrolled_height(150.0).show(ui, |ui| { + for (i, r) in rows.iter().enumerate() { + let indent = " ".repeat(usize::from(r.level.saturating_sub(1))); + if ui.selectable_label(i == sel, format!("{indent}{}", r.style)).clicked() { + sel = i; + } + } + }); + }); + }); + ui.vertical(|ui| { + ui.add_space(40.0); + if ui.button(tr("« Add")).clicked() && others.iter().any(|n| **n == other) { + let level = u8::try_from(rows.len() + 1).unwrap_or(9).min(9); + rows.push(TocRow { style: other.clone(), level, page_number: "after".into(), separator: "^t".into(), ..Default::default() }); + sel = rows.len() - 1; + other.clear(); + } + if ui.button(tr("Remove »")).clicked() && sel < rows.len() { + other = rows.remove(sel).style; + sel = sel.min(rows.len().saturating_sub(1)); + } + }); + ui.vertical(|ui| { + crate::rtl::label(ui, tr("Other Styles:")); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_width(200.0); + egui::ScrollArea::vertical().id_salt("toc_other").max_height(150.0).min_scrolled_height(150.0).show(ui, |ui| { + for n in styles.iter().filter(|n| !rows.iter().any(|r| &r.style == *n)) { + if ui.selectable_label(*n == other, n.as_str()).clicked() { + other = n.clone(); + } + } + }); + }); + }); + }); + if let Some(r) = rows.get_mut(sel) { + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(format!("{} {}", tr("Style:"), r.style)).font(semibold(12.0))); + egui::Grid::new("toc_entry").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, tr("Entry Style:")); + let auto = format!("TOC Level {}", r.level.clamp(1, 9)); + toc_style_combo(ui, "entry", &styles, &auto, &mut r.entry_style); + ui.end_row(); + crate::rtl::label(ui, tr("Page Number:")); + let choices = [("after", "After Entry"), ("before", "Before Entry"), ("none", "No Page Number")]; + let cur = choices.iter().find(|(v, _)| *v == r.page_number).map_or("After Entry", |(_, l)| l); + egui::ComboBox::from_id_salt("toc_page_number").selected_text(tr(cur)).width(200.0).show_ui(ui, |ui| { + for (v, l) in choices { + if ui.selectable_label(r.page_number == v, tr(l)).clicked() { + r.page_number = v.into(); + } + } + }); + ui.end_row(); + crate::rtl::label(ui, tr("Between Entry and Number:")); + ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).horizontal_align(egui::Align::Min)); + ui.end_row(); + crate::rtl::label(ui, tr("Level:")); + egui::ComboBox::from_id_salt("toc_level").selected_text(r.level.to_string()).width(60.0).show_ui(ui, |ui| { + for l in 1..=9u8 { + if ui.selectable_label(r.level == l, l.to_string()).clicked() { + r.level = l; + } + } + }); + ui.end_row(); + }); + } + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(tr("Options")).font(semibold(12.0))); + let has_toc = d.b("hasToc"); + ui.add_enabled_ui(has_toc, |ui| check(ui, d, "replace", &tr("Replace Existing Table of Contents"))); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } + d.fields.insert("included".into(), json!(rows)); + d.fields.insert("selected".into(), json!(sel)); + d.fields.insert("other".into(), json!(other)); + d.fields.insert("titleStyle".into(), json!(title_style)); +} /// The params a previewing dialog shows on the page (its interaction is open while it is set). const PREVIEWED: &str = "_previewed"; @@ -3954,6 +4181,44 @@ mod tests { assert_eq!(d.s("status"), "Invalid value: up"); } + #[test] + fn toc_dialog_adds_a_style_and_ok_makes_the_table_of_contents() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + crate::menus::activate(&mut app, "toc.generate", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(TOC)); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in + ["Table of Contents", "Title:", "Include Paragraph Styles:", "Other Styles:", "« Add", "Remove »", "Replace Existing Table of Contents"] + { + visible_label(&labels, screen, expected); + } + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Headline").center()); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "« Add").center()); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Entry Style:", "TOC Level 1", "After Entry", "Between Entry and Number:", "Level:"] { + visible_label(&labels, screen, expected); + } + let undo = app.session.doc().unwrap().history.undo.len(); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "OK").center()); + assert!(app.ui.dialog.is_none()); + let d = &app.session.doc().unwrap().doc; + let toc = d.toc.clone().unwrap(); + assert_eq!(toc.entries, vec![designcraft_doc::TocEntry::new("Headline", 1)]); + let text = &d.story(toc.story).unwrap().text; + assert!(text.starts_with("Contents\n") && text.contains("\nNotes on the Grid\t"), "{text}"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + // Reopened, the dialog shows the table of contents and replaces it. + crate::menus::activate(&mut app, "toc.generate", &Value::Null); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(d.b("replace")); + assert_eq!(toc_rows(d).iter().map(|r| r.style.as_str()).collect::>(), ["Headline"]); + } + fn dialog_context(language: &str) -> egui::Context { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx, language); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81d..4f33f9bb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1888,6 +1888,10 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { crate::dialogs::open_document_setup(app); return; } + if params.is_null() && id == "toc.generate" { + crate::dialogs::open_toc(app); + return; + } if params.is_null() && id == "file.print" { crate::dialogs::open_print(app); return; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c..625cfb4a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -428,7 +428,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Parent item overrides: Cmd+Shift-click a parent item on a page (or Override All Parent Page Items, ⌥⇧⌘L) makes an editable local copy that hides the parent's; Remove All Local Overrides and Detach All Objects from Parent in the Pages panel menu. - Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `` in IDML. - Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation. -- Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page. +- Table of contents (Layout ▸ Table of Contents dialog: included styles, per-style entry style, page number position, separator and level, title style, Replace Existing; generate/update, dot leaders; the definition round-trips through IDML as a TOC style) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page. - Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather. - ~200 commands, all reachable through the JSON control channel; headless CLI rendering to PNG. - MCP server (`designcraft-cli mcp [--connect PORT]`, docs/mcp.md): headless engine or the running app; commands, batch, document/story inspection, page renders as images, window screenshots, pointer/keyboard/dialog input. From 1bddba0e7578b79a8eba2db7dbaba2175dfdc450 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:07:21 +0200 Subject: [PATCH 10/32] Compose: cross-reference index no longer rebuilds itself (#851) The cross-reference index composed its destination stories through the cache. One with a running header asked for the running-header index, which composed the story holding the cross-reference, which asked for the cross-reference index again: the stack overflowed and the app aborted. Destination stories with a running header are now composed without it, like those with cross-references already were. --- crates/compose/src/xref.rs | 8 +++++--- crates/engine/src/cmd/xref.rs | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/xref.rs b/crates/compose/src/xref.rs index 4bce48d2..b3371420 100644 --- a/crates/compose/src/xref.rs +++ b/crates/compose/src/xref.rs @@ -19,15 +19,17 @@ pub fn has_xrefs(st: &Story) -> bool { } impl XrefIndex { - /// `get` composes a story (normally through the cache). Stories that themselves hold - /// cross-references are composed without resolving them (their anchors' pages hardly move). + /// `get` composes a story (normally through the cache). Stories that hold cross-references or + /// running headers are composed without resolving them (their anchors' pages hardly move): + /// through the cache they would build this index again, directly or by way of the running-header index. pub fn build(doc: &Document, get: &dyn Fn(StoryId) -> Arc) -> XrefIndex { let mut pages = HashMap::new(); for (sid, st) in &doc.stories { if st.anchors.is_empty() { continue; } - let cs = if has_xrefs(st) { Arc::new(crate::compose_story(doc, *sid, &Default::default())) } else { get(*sid) }; + let indexed = has_xrefs(st) || crate::vars::has_running(doc, st); + let cs = if indexed { Arc::new(crate::compose_story(doc, *sid, &Default::default())) } else { get(*sid) }; for a in &st.anchors { let Some(pos) = st.anchor_pos(a.id) else { continue }; if let Some((page, ..)) = crate::vars::place(doc, &cs, pos) { diff --git a/crates/engine/src/cmd/xref.rs b/crates/engine/src/cmd/xref.rs index b0b97ebd..54e5dd6b 100644 --- a/crates/engine/src/cmd/xref.rs +++ b/crates/engine/src/cmd/xref.rs @@ -214,4 +214,23 @@ mod tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "(p. 3)"); } + + #[test] + fn cross_reference_into_a_story_with_a_running_header_composes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let head = s.execute("frame.create", &json!({"rect": [72, 72, 400, 120], "content": "text", "text": "Chapter"})).unwrap()["story"].clone(); + let body = s.execute("frame.create", &json!({"rect": [72, 200, 400, 300], "content": "text", "text": "See"})).unwrap()["story"].clone(); + s.execute("text.select", &json!({"story": head, "anchor": 0, "focus": 0})).unwrap(); + s.execute("variables.insert", &json!({"name": "Running Header"})).unwrap(); + s.execute("text.select", &json!({"story": body, "anchor": 3, "focus": 3})).unwrap(); + s.execute("xref.insert", &json!({"story": head, "para": 0})).unwrap(); + // The cross-reference index and the running-header index used to build each other without end. + assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "\u{201C}Chapter\u{201D} on page 1"); + // The header shows the body's paragraph in front of its own text. + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, StoryId(head.as_u64().unwrap()), None); + let glyphs: usize = cs.frames[0].lines.iter().map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum(); + assert_eq!(glyphs, "SeeChapter".len()); + } } From 0fd627aa44b268d935f04e1b094d46ba84815865 Mon Sep 17 00:00:00 2001 From: LabDennis <341377186+LabDennis@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:12:09 +0200 Subject: [PATCH 11/32] perf(assets): index duplicate candidates by content (#727) --- crates/doc/src/assets.rs | 64 ++++++++++++++++++++++++++++++++++++-- crates/format/src/lib.rs | 33 ++++++++++++++++++-- docs/file-format-parity.md | 5 +-- 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 24ba5c24..cff196fc 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -5,6 +5,7 @@ //! shown); each placement's frame data (transform, crop, fitting) lives on its [`Graphic`]. use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::hash::BuildHasher; use std::sync::Arc; use crate::story::Story; @@ -246,10 +247,19 @@ impl Document { if !self.asset_refs(&mut |_| {}) { return 0; } - let mut reps: HashMap<(usize, &str, u32), Vec<&Arc>> = HashMap::new(); + let fingerprint = std::collections::hash_map::RandomState::new(); + let mut hashes = HashMap::new(); + let mut reps: HashMap<_, Vec<&Arc>> = HashMap::new(); let mut map = HashMap::new(); for a in self.assets.values() { - let bucket = reps.entry((a.data.len(), a.mime.as_str(), a.page)).or_default(); + // Empty, unlinked assets never compare equal. Don't build a quadratic bucket + // of them, and hash a shared immutable buffer only once per compaction. + if a.data.is_empty() && a.link.is_none() { + continue; + } + let hash = *hashes.entry(Arc::as_ptr(&a.data)).or_insert_with(|| fingerprint.hash_one(a.data.as_slice())); + let bucket = reps.entry((hash, a.mime.as_str(), a.page, a.link.as_deref())).or_default(); + // A fingerprint only selects candidates; equality still checks the actual file. match bucket.iter().find(|r| r.same_file(a)) { Some(r) => { map.insert(a.id, r.id); @@ -280,6 +290,56 @@ mod tests { Asset { id: AssetId(id), name: "p.png".into(), mime: "image/png".into(), link: None, data: Arc::new(data.to_vec()), pixels: None, page: 0 } } + #[test] + fn equal_size_assets_keep_distinct_contents_and_merge_exact_copies() { + let mut d = Document::new(&Default::default()); + for id in 1..=256u64 { + let mut bytes = vec![42; 1024]; + bytes[1016..].copy_from_slice(&id.to_le_bytes()); + d.assets.insert(AssetId(id), Arc::new(asset(id, &bytes))); + d.assets.insert(AssetId(id + 256), Arc::new(asset(id + 256, &bytes))); + } + assert_eq!(d.merge_duplicate_assets(), 256); + assert_eq!(d.assets.len(), 256); + for id in 1..=256u64 { + assert_eq!(d.assets[&AssetId(id)].data[1016..], id.to_le_bytes()); + } + } + + #[test] + fn compaction_preserves_asset_identity_and_missing_data_rules() { + let mut d = Document::new(&Default::default()); + let mut cases = vec![asset(1, b"same"), asset(2, b"same"), asset(3, b"same"), asset(4, b"same")]; + cases[1].mime = "application/pdf".into(); + cases[2].page = 1; + cases[3].link = Some("/linked.png".into()); + cases.extend([asset(5, b""), asset(6, b""), asset(7, b""), asset(8, b"")]); + cases[6].link = Some("/missing.png".into()); + cases[7].link = Some("/missing.png".into()); + for a in cases { + d.assets.insert(a.id, Arc::new(a)); + } + assert_eq!(d.merge_duplicate_assets(), 1); + assert_eq!(d.assets.keys().copied().collect::>(), (1..=7).map(AssetId).collect::>()); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + #[ignore = "manual bounded timing comparison; no timing assertion"] + fn equal_size_assets_timing() { + for count in [500, 1000, 2000] { + let mut d = Document::new(&Default::default()); + for id in 1..=count as u64 { + let mut bytes = vec![42; 8192]; + bytes[8184..].copy_from_slice(&id.to_le_bytes()); + d.assets.insert(AssetId(id), Arc::new(asset(id, &bytes))); + } + let start = std::time::Instant::now(); + assert_eq!(d.merge_duplicate_assets(), 0); + eprintln!("{count} distinct 8 KiB assets: {:?}", start.elapsed()); + } + } + fn graphic_item(id: u64, asset: u64, crop: f64) -> Item { let mut it = Item::new(ItemId(id), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); it.content = Content::Graphic(Graphic { diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 408bb3cc..c1b6d805 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -11,6 +11,7 @@ #![forbid(unsafe_code)] use std::collections::{HashMap, HashSet}; +use std::hash::BuildHasher; use std::io::{Cursor, Read, Write}; use std::sync::Arc; @@ -107,12 +108,13 @@ fn load_zip(bytes: &[u8]) -> Result { let names: Vec = z.file_names().map(str::to_string).collect(); // Files saved with one copy per placement: identical copies share one buffer as they are // read, so memory holds each distinct file once. - let mut loaded: HashMap>>> = HashMap::new(); + let fingerprint = std::collections::hash_map::RandomState::new(); + let mut loaded: HashMap>>> = HashMap::new(); for name in names { let Some(stem) = name.strip_prefix("assets/") else { continue }; let Some(id) = stem.split('.').next().and_then(|s| s.parse::().ok()) else { continue }; if let (Some(data), Some(a)) = (read(&mut z, &name), doc.assets.get_mut(&AssetId(id))) { - let same = loaded.entry(data.len()).or_default(); + let same = loaded.entry(fingerprint.hash_one(data.as_slice())).or_default(); let data = match same.iter().find(|d| ***d == data) { Some(d) => d.clone(), None => { @@ -338,6 +340,33 @@ mod tests { } } + #[test] + fn equal_size_archive_assets_preserve_content_and_share_only_identical_buffers() { + let mut d = Document::new(&NewDocument::default()); + let mut pairs = Vec::new(); + for n in 0..128u64 { + let mut bytes = vec![42; 1024]; + bytes[1016..].copy_from_slice(&n.to_le_bytes()); + let a = png_asset(&mut d, &bytes); + let b = png_asset(&mut d, &bytes); + // Distinct links must remain independently relinkable even when their bytes match. + Arc::make_mut(d.assets.get_mut(&a).unwrap()).link = Some(format!("/a/{n}.png")); + Arc::make_mut(d.assets.get_mut(&b).unwrap()).link = Some(format!("/b/{n}.png")); + place(&mut d, a, 1.0); + place(&mut d, b, 2.0); + pairs.push((a, b)); + } + let back = load(&save_uncompacted(&d)).unwrap(); + assert_eq!(back.assets.len(), 256); + for (n, (a, b)) in pairs.into_iter().enumerate() { + assert_eq!(back.assets[&a].data[1016..], (n as u64).to_le_bytes()); + assert!(Arc::ptr_eq(&back.assets[&a].data, &back.assets[&b].data)); + assert_eq!(back.assets[&a].link, d.assets[&a].link); + assert_eq!(back.assets[&b].link, d.assets[&b].link); + } + assert_eq!(back.spreads, d.spreads); + } + #[test] fn older_files_with_one_asset_per_placement_open_with_one() { let mut d = Document::new(&NewDocument::default()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fc8fbc3a..06f59de9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6) Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`, @@ -22,7 +22,7 @@ score below). | InDesign document | `.indd` | read/write (native) | ✗ (#21; converters proposed in #98, #114) | ✗ | — | 30% | | InDesign template | `.indt` | read/write | ✗ | ✗ | — | 2% | | InDesign Markup | `.idml` | read/write | ✓ | ✓ | Synthetic fixtures in `crates/idml/src/tests.rs` (49 tests); exported files verified by opening in InDesign 2026 by hand; indd-utils' 98-document run imports 811 spreads / 2,866 stories / 5,812 items. Open: #193, #338, #339, #340, #211. No real-file corpus test | 20% | -| DesignCraft native | `.designcraft` | — | ✓ | ✓ | zip + JSON, versioned; round-trip tests | — | +| DesignCraft native | `.designcraft` | — | ✓ | ✓ | zip + JSON, versioned; round-trip tests. Asset loading and compaction index by content hash, then confirm byte equality; equal-sized distinct files no longer all share one search bucket (#727). Separate links, MIME types and PDF pages retain their identity | — | | InDesign book | `.indb` | read/write | ✗ (own `.dcbook`) | ✗ | Books are shallow (see gaps.md) | 2% | | InDesign library | `.indl`, `.inlx` | read/write | ✗ (own `.dclib`) | ✗ | | 1% | | Snippet | `.idms` | read/write | ✗ (own snippets) | ✗ | | 1% | @@ -107,4 +107,5 @@ for real work** (estimated). INDD alone moves this dimension by up to 30 points. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Asset loading and compaction use content fingerprints with exact equality checks; tests cover equal-sized distinct buffers, shared copies, independent links and missing data (#727) | | 2026-10-10 | major | Created from InDesign 2026's declared document types and filters, `plan/indesign/08-file-formats.md` and the code | From 2d07bc5295ef71ec3dffbb6e52f67e0833291cc4 Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:28:38 -0400 Subject: [PATCH 12/32] fix: refresh canvas when switching parent editing --- ROADMAP.md | 4 +- crates/ui-egui/examples/ui_shot.rs | 1 + crates/ui-egui/src/canvas.rs | 18 +++- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/parent_canvas_tests.rs | 104 ++++++++++++++++++++++ 5 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/parent_canvas_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f8207..2499c222 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (parent canvas refresh) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -127,6 +127,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change. + Newest first. - **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. diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index c0bf3894..c241df13 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -31,6 +31,7 @@ fn main() { } let mut harness = egui_kittest::Harness::builder() .with_size(egui::vec2(1440.0, 900.0)) + .with_step_dt(1.0 / 60.0) .with_pixels_per_point(2.0) .with_max_steps(1_000_000) .wgpu() diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae294..0289ad9f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -372,12 +372,14 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, let Some(st) = app.session.active() else { return }; let ppp = ctx.pixels_per_point() as f64; let doc_key = (st.uid, std::sync::Arc::as_ptr(&st.doc) as u64, st.revision, preview); + let editing_parents = st.editing_parents; // Overscan: 25% of the viewport on every side, within the GPU's maximum texture size. let max_side = ctx.input(|i| i.max_texture_side) as f32 / ppp as f32; let (ow, oh) = ((rect.width() * 0.25).min((max_side - rect.width()) / 2.0).max(0.0), (rect.height() * 0.25).min((max_side - rect.height()) / 2.0).max(0.0)); let want = |origin: Point| crate::Shown { doc: doc_key, + editing_parents, origin: Point::new(origin.x - ow as f64 / xf.zoom, origin.y - oh as f64 / xf.zoom), zoom: xf.zoom, size: (rect.width() + 2.0 * ow, rect.height() + 2.0 * oh), @@ -385,7 +387,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, let target = want(xf.origin); // Does the current texture cover the viewport at this zoom and document state? let covers = |s: &crate::Shown| { - s.doc == doc_key && (s.zoom - xf.zoom).abs() < 1e-9 && { + s.doc == doc_key && s.editing_parents == editing_parents && (s.zoom - xf.zoom).abs() < 1e-9 && { let x0 = (xf.origin.x - s.origin.x) * s.zoom; let y0 = (xf.origin.y - s.origin.y) * s.zoom; x0 >= -0.5 && y0 >= -0.5 && x0 + rect.width() as f64 <= s.size.0 as f64 + 0.5 && y0 + rect.height() as f64 <= s.size.1 as f64 + 0.5 @@ -407,6 +409,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, return; } let doc_changed = app.canvas.shown.is_none_or(|s| s.doc != doc_key); + let mode_changed = app.canvas.shown.is_some_and(|s| s.editing_parents != editing_parents); let zoom_same = app.canvas.shown.is_some_and(|s| (s.zoom - xf.zoom).abs() < 1e-9); let job = |app: &DesignApp| { let st = app.session.active()?; @@ -449,7 +452,9 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, if doc_changed && zoom_same && patch_texture(app, layout, ppp, doc_key, preview) { return; } - if !has_worker || app.canvas.shown.is_none() || (doc_changed && zoom_same) { + // A mode switch changes every spread's meaning, even if the document has no damage. + // Show the new canvas immediately, including when fitting it changes the zoom. + if !has_worker || app.canvas.shown.is_none() || mode_changed || (doc_changed && zoom_same) { let t0 = crate::now_ms(); let Some((doc, placed, w, h, view, opts)) = job(app) else { return }; let img = app.canvas.renderer.render(&doc, &app.session.cache, &placed, w, h, view, &opts); @@ -457,6 +462,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, app.canvas.shown = Some(target); app.canvas.shown_doc = Some(doc); app.canvas.pending = None; + app.canvas.pending_doc = None; app.perf.render_ms = crate::now_ms() - t0; return; } @@ -464,7 +470,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, { // Already rendering this view? if app.canvas.pending.is_some_and(|(_, p)| { - p.doc == doc_key && (p.zoom - xf.zoom).abs() < 1e-9 && { + p.doc == doc_key && p.editing_parents == editing_parents && (p.zoom - xf.zoom).abs() < 1e-9 && { let x0 = (xf.origin.x - p.origin.x) * p.zoom; let y0 = (xf.origin.y - p.origin.y) * p.zoom; x0 >= 0.0 && y0 >= 0.0 && x0 + rect.width() as f64 <= p.size.0 as f64 && y0 + rect.height() as f64 <= p.size.1 as f64 @@ -487,7 +493,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, /// Returns false when a full render is needed (view moved, too much changed, or damage unknown). fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key: (u64, u64, u64, bool), preview: bool) -> bool { let (Some(sh), Some(old), Some(st)) = (app.canvas.shown, app.canvas.shown_doc.clone(), app.session.active()) else { return false }; - if sh.doc.0 != doc_key.0 || sh.doc.3 != doc_key.3 || app.canvas.texture.is_none() { + if sh.doc.0 != doc_key.0 || sh.doc.3 != doc_key.3 || sh.editing_parents != st.editing_parents || app.canvas.texture.is_none() { return false; } let new = st.doc.clone(); @@ -1771,6 +1777,10 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec true } +#[cfg(test)] +#[path = "parent_canvas_tests.rs"] +mod parent_canvas_tests; + #[cfg(test)] mod tests { use designcraft_geom::Unit; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e63..6e7650b9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -410,6 +410,8 @@ impl Default for View { pub struct Shown { /// (doc uid, doc pointer, revision, preview). pub doc: (u64, u64, u64, bool), + /// Parent and document spreads have different canvas placement and content. + pub editing_parents: bool, /// Canvas point at the texture's top-left, zoom, and texture size in screen points. pub origin: Point, pub zoom: f64, diff --git a/crates/ui-egui/src/parent_canvas_tests.rs b/crates/ui-egui/src/parent_canvas_tests.rs new file mode 100644 index 00000000..b3197a7e --- /dev/null +++ b/crates/ui-egui/src/parent_canvas_tests.rs @@ -0,0 +1,104 @@ +use super::*; + +fn app_with_document_object() -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64, "pages": 1, "facingPages": true, "margins": 4})).unwrap(); + app.run("frame.create", json!({"rect": [24, 24, 40, 40], "content": "unassigned"})).unwrap(); + app.run("object.fill", json!({"swatch": "[Black]"})).unwrap(); + // Exercise the production texture cache without starting a background thread. + app.canvas.worker_started = true; + app +} + +fn render(app: &mut DesignApp, ctx: &egui::Context) -> egui::epaint::ImageDelta { + render_at_zoom(app, ctx, 1.0) +} + +fn render_at_zoom(app: &mut DesignApp, ctx: &egui::Context, zoom: f64) -> egui::epaint::ImageDelta { + let rect = Rect::from_min_size(Pos2::ZERO, vec2(128.0, 64.0)); + let view = View { zoom, origin: Point::new(-64.0, 0.0), fitted: true, rotation: 0 }; + let mut output = ctx.run_ui(Default::default(), |ui| { + let st = app.session.active().unwrap(); + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + render_texture(app, ui.ctx(), rect, &Xf::new(rect, &view), &layout, false); + }); + let id = app.canvas.texture.as_ref().unwrap().id(); + let deltas = output.textures_delta.set.remove(&id); + output.textures_delta.clear(); + let mut deltas = deltas.expect("canvas pixels must update"); + assert_eq!(deltas.len(), 1, "one canvas render should produce one texture update"); + deltas.pop().unwrap() +} + +fn pixel(delta: &egui::epaint::ImageDelta, shown: crate::Shown, point: Point) -> Color32 { + let egui::ImageData::Color(image) = &delta.image; + let offset = delta.pos.unwrap_or([0, 0]); + let x = ((point.x - shown.origin.x) * shown.zoom).round() as usize - offset[0]; + let y = ((point.y - shown.origin.y) * shown.zoom).round() as usize - offset[1]; + image[(x, y)] +} + +#[test] +fn switching_parent_editing_repaints_pages_and_document_objects() { + let mut app = app_with_document_object(); + let ctx = egui::Context::default(); + let left_page = Point::new(-32.0, 32.0); + let document_object = Point::new(32.0, 32.0); + let before = render(&mut app, &ctx); + let shown = app.canvas.shown.unwrap(); + assert_eq!(pixel(&before, shown, left_page), Color32::TRANSPARENT); + let document_color = pixel(&before, shown, document_object); + assert_ne!(document_color, Color32::WHITE); + assert_eq!(document_color.a(), 255); + + let doc = app.session.active().unwrap().doc.clone(); + app.run("layout.parents.edit", json!({"on": true})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &app.session.active().unwrap().doc), "switching canvas mode does not edit the document"); + let parent = render(&mut app, &ctx); + assert!(parent.pos.is_none(), "the parent canvas needs a complete repaint"); + let shown = app.canvas.shown.unwrap(); + assert_eq!(pixel(&parent, shown, left_page), Color32::WHITE, "the parent's left page must become visible"); + assert_eq!(pixel(&parent, shown, document_object), Color32::WHITE, "document-only objects must disappear in parent editing"); + + app.run("layout.parents.edit", json!({"on": false})).unwrap(); + let restored = render(&mut app, &ctx); + assert!(restored.pos.is_none()); + let shown = app.canvas.shown.unwrap(); + assert_eq!(pixel(&restored, shown, left_page), Color32::TRANSPARENT); + assert_eq!(pixel(&restored, shown, document_object), document_color); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn switching_parent_editing_with_a_new_zoom_repaints_before_the_worker() { + let mut app = app_with_document_object(); + let ctx = egui::Context::default(); + app.canvas.worker = Some(crate::render_worker::Worker::spawn(ctx.clone()).expect("render worker must start")); + let before = render(&mut app, &ctx); + assert_ne!(pixel(&before, app.canvas.shown.unwrap(), Point::new(32.0, 32.0)), Color32::WHITE); + + // Fitting the parent spread can change the zoom as well as the canvas mode. + app.run("layout.parents.edit", json!({"on": true})).unwrap(); + let parent = render_at_zoom(&mut app, &ctx, 0.75); + assert!(parent.pos.is_none(), "a mode switch must upload the new canvas in this frame"); + assert!(app.canvas.pending.is_none(), "parent pages must not wait behind an asynchronous zoom render"); + let shown = app.canvas.shown.unwrap(); + assert_eq!(shown.zoom, 0.75); + assert_eq!(pixel(&parent, shown, Point::new(-32.0, 32.0)), Color32::WHITE); + assert_eq!(pixel(&parent, shown, Point::new(32.0, 32.0)), Color32::WHITE); +} + +#[test] +fn small_edits_in_the_same_canvas_mode_still_repaint_partially() { + let mut app = app_with_document_object(); + let ctx = egui::Context::default(); + let before = render(&mut app, &ctx); + let point = Point::new(32.0, 32.0); + assert_ne!(pixel(&before, app.canvas.shown.unwrap(), point), Color32::WHITE); + + app.run("object.fill", json!({"swatch": "[Paper]"})).unwrap(); + let patch = render(&mut app, &ctx); + assert!(patch.pos.is_some(), "an ordinary small edit should keep the partial repaint optimization"); + assert_eq!(app.canvas.patches, 1); + assert_eq!(pixel(&patch, app.canvas.shown.unwrap(), point), Color32::WHITE); +} From 1e153c5c4533993cf89799f23ae47b19093c9617 Mon Sep 17 00:00:00 2001 From: Daniel van Lith Date: Wed, 14 Oct 2026 10:36:52 +1300 Subject: [PATCH 13/32] macOS DMG: hdiutil create -srcfolder, so the app inside passes codesign --strict makehybrid -hfs gave every file in the image a com.apple.FinderInfo, so the app in the DMG, and a copy installed from it, failed codesign --verify --strict --deep ("resource fork, Finder information, or similar detritus not allowed"), though Gatekeeper accepts it. Build the image as VectorCraft (#1085) and PdfCraft (#823) do now: hdiutil create -srcfolder with a few tries for CI's "Resource busy", and verify the mounted app with --strict --deep so the regression can't ship again. Co-Authored-By: Claude Opus 5.5 --- packaging/macos/package.sh | 39 +++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 38fa2afb..c818ae6d 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -135,15 +135,40 @@ ln -s /Applications "$STAGE/Applications" mkdir -p "$STAGE/.background" cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff" cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store" -rm -f "$DMG" "$WORK/raw.dmg" -# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`, -# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions. -# The volume name has no version: .DS_Store finds the background through an alias that includes it. -hdiutil makehybrid -hfs -hfs-volume-name "DesignCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" -hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" -rm -f "$WORK/raw.dmg" +rm -f "$DMG" +# `hdiutil create -srcfolder` writes the files as they are. `makehybrid -hfs` gave every file a +# com.apple.FinderInfo, which `codesign --verify --strict` rejects as "Finder information, or +# similar detritus" on the app in the image and on a copy installed from it (as VectorCraft's +# storytold/vectorcraft#1085 and PdfCraft's storytold/pdfcraft#823). `create` attaches a device +# while it copies, which fails now and then on CI runners ("Resource busy"), so it gets a few +# tries. The volume name has no version: .DS_Store finds the background through an alias that +# includes it. +# Usage: hdiutil_retry ; tries five times with growing pauses. +hdiutil_retry() { + local what="$1" attempt + shift + for attempt in 1 2 3 4 5; do + if hdiutil "$@"; then + return 0 + fi + [ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; } + echo "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s" >&2 + sleep $((attempt * 10)) + done +} +hdiutil_retry create create -srcfolder "$STAGE" -volname "DesignCraft" -fs HFS+ -format UDZO -imagekey zlib-level=9 -ov "$DMG" sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" +# The app inside the image must still pass --strict, so a makehybrid-style regression can't ship. +MOUNT="$WORK/dmg-check" +rm -rf "$MOUNT" +mkdir -p "$MOUNT" +hdiutil_retry attach attach -nobrowse -readonly -mountpoint "$MOUNT" "$DMG" >/dev/null +if ! codesign --verify --strict --deep --verbose=2 "$MOUNT/DesignCraft.app"; then + hdiutil_retry detach detach "$MOUNT" >/dev/null || true + exit 1 +fi +hdiutil_retry detach detach "$MOUNT" >/dev/null if [ "$NOTARIZE" = 1 ]; then notarize "$DMG" xcrun stapler staple "$DMG" From bfa536352dea4d0673d05fd8285caf9f82bf2d6e Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Wed, 14 Oct 2026 07:54:46 +1000 Subject: [PATCH 14/32] FIX-CHILD-NOTE-PASTE: filter unsupported markers in child stories Co-Authored-By: Codex --- crates/engine/src/cmd/edit.rs | 128 +++++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e7190acb..79d136a1 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -74,7 +74,7 @@ fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result< } } let use_clip = formatted && clip.as_ref().is_some_and(|(_, p)| text.as_ref().is_none_or(|x| x.replace("\r\n", "\n") == *p)); - // Footnotes, cross-references and tables only paste into story text, not cells or footnotes. + // Footnotes, endnotes, editorial notes, cross-references and tables cannot be pasted into cells or footnotes. if use_clip && let Some((slice, _)) = clip { let slice = if t.cell.is_some() { strip_objects(&slice) } else { (*slice).clone() }; return s.edit(|d, sel| { @@ -93,7 +93,13 @@ fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result< fn strip_objects(st: &designcraft_doc::Story) -> designcraft_doc::Story { let mut out = st.clone(); - let marks = [designcraft_doc::FOOTNOTE_REF, designcraft_doc::XREF_MARK, designcraft_doc::TABLE_ANCHOR]; + let marks = [ + designcraft_doc::FOOTNOTE_REF, + designcraft_doc::ENDNOTE_REF, + designcraft_doc::NOTE_MARK, + designcraft_doc::XREF_MARK, + designcraft_doc::TABLE_ANCHOR, + ]; while let Some(i) = out.text.find(marks) { let n = out.text[i..].chars().next().map_or(1, char::len_utf8); out.delete(i..i + n); @@ -355,6 +361,124 @@ mod text_clipboard_tests { s.doc().unwrap().doc.check().unwrap(); } + fn note_clipboard_session() -> (Session, designcraft_doc::StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let source = frame(&mut s, "aéz"); + s.execute("text.select", &json!({"story": source.0, "anchor": 1, "focus": 1})).unwrap(); + s.execute("endnote.insert", &json!({"text": "Citation A"})).unwrap(); + s.execute("note.new", &json!({"text": "Memo A", "author": "Ed"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(source).unwrap().text, "a\u{E00F}\u{E010}éz"); + s.execute("text.select", &json!({"story": source.0, "anchor": 0, "focus": 10})).unwrap(); + s.execute("type.char", &json!({"attrs": {"fontStyle": "Bold"}})).unwrap(); + assert_eq!(s.execute("edit.copy", &json!({})).unwrap(), json!({"text": "aéz"})); + (s, source) + } + + fn assert_child_note_paste( + s: &mut Session, + source: designcraft_doc::StoryId, + parent: designcraft_doc::StoryId, + cell: designcraft_doc::CellAddr, + endpoints: (usize, usize), + ) { + let before = s.doc().unwrap().doc.clone(); + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.story, selected.cell, selected.anchor, selected.focus), (parent, Some(cell), endpoints.0, endpoints.1)); + assert_eq!(selected.frame, None); + let before_child = before.text_story(parent, Some(cell)).unwrap(); + assert_eq!(before_child.text, "L----R"); + let clipboard = s.text_clipboard.clone(); + let history = s.execute("document.history", &json!({})).unwrap(); + assert_eq!(history["redo"], json!([])); + + let result = s.execute("edit.paste", &json!({})).unwrap(); + let after = s.doc().unwrap().doc.clone(); + let child = after.text_story(parent, Some(cell)).unwrap(); + assert_eq!(child.text, "LaézR", "root-only note markers must not enter child text"); + assert_eq!(child.len(), 6); + assert_eq!(result, json!({"pos": 5, "formatted": true})); + assert_eq!(s.doc().unwrap().selection, Selection::text(designcraft_doc::TextSel { anchor: 5, focus: 5, ..selected })); + assert!(child.endnotes.is_empty() && child.editorial.is_empty()); + for pos in [1, 2, 4] { + let (_, format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + assert_eq!(format.over.font_style.as_deref(), Some("Bold")); + } + // Inspect actual run coverage: char_format_at at a run boundary means the preceding run. + for pos in [0, 5] { + let (_, old_format) = before_child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + let (_, new_format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + assert_eq!(new_format, old_format); + } + assert_eq!(after.story(parent).unwrap().text, before.story(parent).unwrap().text); + assert_eq!(after.story(parent).unwrap().chars, before.story(parent).unwrap().chars); + assert_eq!(after.story(source), before.story(source)); + assert_eq!(s.text_clipboard, clipboard); + + let mut pasted_history = history.clone(); + pasted_history["undo"].as_array_mut().unwrap().push(json!("Paste")); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": "Paste"})); + assert_eq!(s.doc().unwrap().doc, before); + let mut undone_history = history; + undone_history["redo"] = json!(["Paste"]); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), undone_history); + // This filter does not change which selection Undo restores. + assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": "Paste"})); + assert_eq!(s.doc().unwrap().doc, after); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); + } + + #[test] + fn formatted_child_paste_filters_endnotes_and_editorial_in_cells() { + let (mut s, source) = note_clipboard_session(); + let parent = frame(&mut s, "PZ"); + s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); + let table_id = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": "L----R"})).unwrap(); + s.execute("table.nextCell", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "NEIGHBOR"})).unwrap(); + s.execute("table.prevCell", &json!({})).unwrap(); + s.execute("text.move", &json!({"dir": "storyStart"})).unwrap(); + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + for _ in 0..4 { + s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); + } + let before_table = s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id].clone(); + let cell = designcraft_doc::CellAddr { table: table_id, row: 0, col: 0 }; + assert_child_note_paste(&mut s, source, parent, cell, (1, 5)); + let table = &s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id]; + assert_eq!(table.cell(0, 1).unwrap().text.text, "NEIGHBOR"); + let mut expected_table = (*before_table).clone(); + expected_table.cells[0].text = table.cells[0].text.clone(); + assert_eq!(table.as_ref(), &expected_table); + + // The same clipboard must still retain both markers when pasted into an ordinary root story. + let root = frame(&mut s, "L----R"); + s.execute("text.select", &json!({"story": root.0, "anchor": 1, "focus": 5})).unwrap(); + assert_eq!(s.execute("edit.paste", &json!({})).unwrap(), json!({"pos": 11, "formatted": true})); + assert_eq!(s.doc().unwrap().doc.story(root).unwrap().text, "La\u{E00F}\u{E010}ézR"); + } + + #[test] + fn formatted_child_paste_filters_endnotes_and_editorial_in_footnotes() { + let (mut s, source) = note_clipboard_session(); + let parent = frame(&mut s, "PZ"); + let neighbor = s.execute("footnote.insert", &json!({"text": "NEIGHBOR"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); + let target = s.execute("footnote.insert", &json!({"text": "L----R"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + for _ in 0..4 { + s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap(); + } + let before = s.doc().unwrap().doc.story(parent).unwrap().clone(); + assert_child_note_paste(&mut s, source, parent, designcraft_doc::CellAddr::footnote(target), (5, 1)); + let after = s.doc().unwrap().doc.story(parent).unwrap(); + assert_eq!(after.note(neighbor).unwrap().text.text, "NEIGHBOR"); + assert_eq!(after.note(neighbor), before.note(neighbor)); + assert_eq!(after.notes.iter().map(|note| note.id).collect::>(), before.notes.iter().map(|note| note.id).collect::>()); + } + #[test] fn change_case_keeps_runs() { let mut s = Session::new(); From 5024ed8870a4c4cba5f1b6ca20a5914e55237944 Mon Sep 17 00:00:00 2001 From: Andreas Haraminac Date: Tue, 13 Oct 2026 21:55:44 +0000 Subject: [PATCH 15/32] #732: bound control-channel click and drag event queues --- ROADMAP.md | 3 +- crates/ui-egui/src/control.rs | 149 +++++++++++++++++++++++++++++++++- docs/control-protocol.md | 13 ++- docs/gaps.md | 4 +- 4 files changed, 163 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f8207..6c6c09c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (bounded control click/drag input) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **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-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/control.rs b/crates/ui-egui/src/control.rs index 5a4de005..232f2ca2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -100,6 +100,27 @@ fn key_from(name: &str) -> Option { }) } +// Retain room for the existing maximum drag (10,000 moves plus move/down/up), but +// bound the whole gesture and already queued input before generating any events. +const MAX_PENDING_POINTER_EVENTS: usize = 10_003; + +fn pointer_counts(p: &Value, drag: bool, pending: usize) -> Result<(u64, u64), String> { + let positive = |name: &str, default: u64| match p.get(name) { + None => Ok(default), + Some(v) => v.as_u64().filter(|n| *n > 0).ok_or_else(|| format!("`{name}` must be a positive integer")), + }; + let count = positive("count", 1)?; + let steps = if drag { positive("steps", 8)? } else { 0 }; + // One initial move, two button events per click, and each drag step. Check the + // arithmetic too: hostile counts must never overflow into a small budget. + let events = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)); + let available = MAX_PENDING_POINTER_EVENTS.saturating_sub(pending) as u64; + if events.is_none_or(|n| n > available) { + return Err(format!("pointer input would exceed the {MAX_PENDING_POINTER_EVENTS} pending-event limit")); + } + Ok((count, steps)) +} + pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); @@ -181,6 +202,10 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> } "ui.click" | "ui.drag" => { // Screen-space (egui points) pointer input through egui itself: reaches every widget. + let (count, steps) = match pointer_counts(p, req.method == "ui.drag", app.synthetic.len()) { + Ok(counts) => counts, + Err(e) => return err(e), + }; let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; let button = match s("button") { Some("right") | Some("secondary") => egui::PointerButton::Secondary, @@ -200,15 +225,12 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); if req.method == "ui.drag" { - // From the caller: a huge count would queue events until memory runs out. - let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 10_000); for i in 1..=steps { let t = i as f32 / steps as f32; app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t)); } } app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); - let count = p.get("count").and_then(Value::as_u64).unwrap_or(1); for _ in 1..count { app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }); app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); @@ -420,6 +442,127 @@ mod automation_contract_tests { output.textures_delta.clear(); } #[test] + fn oversized_pointer_requests_leave_pending_events_unchanged() { + let mut app = app(); + let ctx = egui::Context::default(); + let pending = vec![egui::Event::PointerMoved(egui::pos2(12.0, 34.0))]; + for (method, params) in [ + ("ui.click", json!({"count": 5002})), + ("ui.drag", json!({"steps": 10000, "count": 2})), + ("ui.drag", json!({"steps": 10001})), + ("ui.click", json!({"count": u64::MAX})), + ("ui.drag", json!({"steps": u64::MAX})), + ("ui.drag", json!({"count": u64::MAX, "steps": u64::MAX})), + ] { + app.synthetic = pending.clone(); + let reply = call(&mut app, &ctx, method, params); + assert_eq!(reply["ok"], false, "{method}: {reply}"); + assert_eq!(app.synthetic, pending, "a rejected gesture must not leave a press queued"); + } + } + #[test] + fn pointer_counts_require_positive_json_integers() { + let mut app = app(); + let ctx = egui::Context::default(); + let pending = vec![egui::Event::PointerMoved(egui::pos2(12.0, 34.0))]; + app.synthetic = pending.clone(); + for value in [json!(0), json!(-1), json!(1.5), Value::Null, json!("2"), json!(true), json!(18446744073709551616_f64)] { + for (method, field) in [("ui.click", "count"), ("ui.drag", "count"), ("ui.drag", "steps")] { + let mut params = json!({}); + params[field] = value.clone(); + let reply = call(&mut app, &ctx, method, params); + assert_eq!(reply["ok"], false, "{method}, {field}={value}: {reply}"); + assert!(reply["error"].as_str().unwrap().contains(field)); + assert_eq!(app.synthetic, pending); + } + } + } + #[test] + fn pointer_gestures_accept_the_exact_event_budget() { + let mut app = app(); + let ctx = egui::Context::default(); + for (method, params) in + [("ui.click", json!({"count": 5001})), ("ui.drag", json!({"steps": 10000})), ("ui.drag", json!({"steps": 9998, "count": 2}))] + { + app.synthetic.clear(); + assert_eq!(call(&mut app, &ctx, method, params)["ok"], true, "{method}"); + assert_eq!(app.synthetic.len(), 10003); + assert!(matches!(app.synthetic.last(), Some(egui::Event::PointerButton { pressed: false, .. }))); + let queued = app.synthetic.clone(); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], false); + assert_eq!(app.synthetic, queued, "a full queue retains its release events"); + } + } + #[test] + fn pointer_budget_counts_pending_input_and_recovers_when_it_drains() { + let mut app = app(); + let ctx = egui::Context::default(); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({"count": 5000}))["ok"], true); + assert_eq!(app.synthetic.len(), 10001); + let queued = app.synthetic.clone(); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], false); + assert_eq!(app.synthetic, queued); + // The ordinary input hook consumes one pointer event per frame, freeing + // enough room for a complete click without dropping earlier input. + app.raw_input_hook(&mut egui::RawInput::default()); + assert_eq!(app.synthetic.len(), 10000); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], true); + assert_eq!(app.synthetic.len(), 10003); + + app.synthetic.clear(); + assert_eq!(call(&mut app, &ctx, "ui.move", json!({"x": 12, "y": 34}))["ok"], true); + let queued = app.synthetic.clone(); + assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"steps": 10000}))["ok"], false); + assert_eq!(app.synthetic, queued); + assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"steps": 9999}))["ok"], true); + assert_eq!(app.synthetic.len(), 10003); + } + #[test] + fn ordinary_pointer_gestures_keep_their_defaults_and_event_order() { + let mut app = app(); + let ctx = egui::Context::default(); + let a = egui::pos2(10.0, 20.0); + let end = egui::pos2(30.0, 40.0); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({"x": 10, "y": 20}))["ok"], true); + assert_eq!( + app.synthetic, + vec![ + egui::Event::PointerMoved(a), + egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }, + egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }, + ] + ); + app.synthetic.clear(); + assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"x": 10, "y": 20, "toX": 30, "toY": 40}))["ok"], true); + assert_eq!(app.synthetic.len(), 11, "default drag has eight steps"); + assert_eq!(app.synthetic.get(9), Some(&egui::Event::PointerMoved(end))); + + app.synthetic.clear(); + assert_eq!( + call( + &mut app, + &ctx, + "ui.drag", + json!({"x": 10, "y": 20, "toX": 30, "toY": 40, "steps": 2, "count": 2, "button": "right", "shift": true}) + )["ok"], + true + ); + let modifiers = egui::Modifiers { shift: true, ..Default::default() }; + let button = egui::PointerButton::Secondary; + assert_eq!( + app.synthetic, + vec![ + egui::Event::PointerMoved(a), + egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }, + egui::Event::PointerMoved(egui::pos2(20.0, 30.0)), + egui::Event::PointerMoved(end), + egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }, + egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }, + egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }, + ] + ); + } + #[test] fn control_typing_uses_explicit_caret_but_retains_widget_events() { let mut app = app(); let ctx = egui::Context::default(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9e514561..6615eb10 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -39,7 +39,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) | | `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas"}], mods?}` | Drive the active tool through the same code path as the mouse | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) | -| `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle` | Real egui pointer input — reaches every widget, menu and panel | +| `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle`, `count?: positive integer` (click/drag, default 1), `steps?: positive integer` (drag, default 8) | Real egui pointer input — reaches every widget, menu and panel | | `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state | | `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` or `characterStyleOptions` with `{name, section}`, or `{new: true}` for a new style) | | `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog | @@ -57,6 +57,17 @@ Headless window screenshots (locked screen, hidden window): `cargo run -p design The MCP server (`designcraft-cli mcp`) wraps the same methods for Claude and other agents. +### Click and drag limits + +`ui.click` and `ui.drag` validate the whole gesture before queuing any input. They permit at +most 10,003 pending synthetic events, including input already waiting for native frames. A +click uses `1 + 2 * count` events; a drag uses `1 + steps + 2 * count`. The count includes the +initial click or drag; further clicks occur at its endpoint. An empty queue therefore accepts +up to 5,001 clicks, or a single drag with up to 10,000 steps. `count` and `steps` must be positive +JSON integers when supplied; invalid or over-budget requests return `ok: false` and leave pending +input unchanged. Oversized gestures are rejected rather than truncated. Wait for queued input +to finish before retrying a request that would exceed the pending-event limit. + ## Shared panel docking Panel arrangements support tab grouping and reordering, horizontal and vertical splits, diff --git a/docs/gaps.md b/docs/gaps.md index ebd2d7da..a578036d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (book chapter update safety) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (bounded control click/drag input) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -38,6 +38,8 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · open (#238, #187, #160, #312); threading (gap 0); double-click word selection in fields (#189); character formatting set at a caret is discarded (#289). - **Evidence:** 16 issues from 2026-10-05 to 2026-10-10, mostly against the 0.4/0.5 release builds. +- **Safety:** control-channel clicks and drags reject invalid or over-budget counts before + queuing input (#732), including combined drag steps, repeated clicks and already pending events. - **Impact:** a layout artist can't finish the simplest job. This dominates the ready-for-real-work number. - **Estimate:** 30–50 h (reproduce per platform through the control channel, regression tests). From 0aaaa9600d607cef7e480ad3373fa4f53e3c2d4e Mon Sep 17 00:00:00 2001 From: Seon-Jin Yoon Date: Wed, 14 Oct 2026 07:12:08 +0900 Subject: [PATCH 16/32] fix(compose): odd/even page starts keep their parity at the top of a frame Start options were skipped for a paragraph at the top of a frame. That is right for Next Page, Next Frame and Next Column (there is nothing to break away from), but a Next Odd Page paragraph that lands at the top of an even page stayed there, as did Next Even Page at the top of an odd page, and the same went for odd and even page break characters. A part or chapter opener whose previous section ended exactly at the bottom of an odd page therefore opened on a left-hand page. Cursor::start_at_top now moves such a paragraph to the next page of the asked parity, for paragraphs, tables and break characters alike. Other start options at the top of a frame are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/lib.rs | 34 ++++++++++++++++++++++++++++------ crates/compose/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index cd0e7cbd..6d4c4bb9 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -576,8 +576,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C .and_then(|t| t.chars().next_back()) .and_then(break_start); if let Some(t) = story.para_table(pi) { + let start = after_break.unwrap_or(pp.start_paragraph); if cur.last_baseline.is_some() { - cur.start(after_break.unwrap_or(pp.start_paragraph), &cols, frames, doc); + cur.start(start, &cols, frames, doc); + } else { + cur.start_at_top(start, frames, doc); } let at_top = cur.last_baseline.is_none(); let start_at = (cur.fi, cur.col); @@ -796,17 +799,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C StartParagraph::NextColumn => next_column(cur), other => cur.start(other, &cols, frames, doc), }; - // Paragraph start options. - if let Some(start) = after_break - && cur.last_baseline.is_some() - { - begin_at(&mut cur, start); + // Paragraph start options. At the top of a frame there is nothing to break away from, but + // an odd or even page start still has to land on a page of that parity. + if let Some(start) = after_break { + if cur.last_baseline.is_some() { + begin_at(&mut cur, start); + } else { + cur.start_at_top(start, frames, doc); + } } if force_col[pi] { next_column(&mut cur); } if cur.last_baseline.is_some() { begin_at(&mut cur, pp.start_paragraph); + } else { + cur.start_at_top(pp.start_paragraph, frames, doc); } if spanning && cur.fi < frames.len() { // Close the band: the paragraph goes below the deepest of its columns, which are @@ -1983,6 +1991,20 @@ impl Cursor { StartParagraph::NextEvenPage => self.next_page(frames, doc, Some(false)), } } + /// A start option for a paragraph at the top of a frame: there is nothing to break away from, + /// but an odd or even page start still has to land on a page of that parity, so a paragraph + /// at the top of an even page that asks for the next odd page moves on (and vice versa). + fn start_at_top(&mut self, start: StartParagraph, frames: &[FrameSpec], doc: &Document) { + let want_odd = match start { + StartParagraph::NextOddPage => true, + StartParagraph::NextEvenPage => false, + _ => return, + }; + let Some(page) = frames.get(self.fi).and_then(|f| f.page) else { return }; + if (doc.page_number(page) % 2 == 1) != want_odd { + self.next_page(frames, doc, Some(want_odd)); + } + } /// Move to the next frame on a later page, one with an odd page number (`odd` true) or an /// even one (false) when asked. Frames without a page (parent pages) qualify; with no frame /// left, the rest of the story is overset. diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 7a0e790d..f6f5c9ad 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -595,6 +595,29 @@ fn break_characters_end_their_paragraph_and_keep_page_parity() { assert_eq!(cs.overset_at, Some(text.find("four").unwrap())); } +/// A paragraph that starts at the top of a frame isn't pushed on by Next Page, but Next Odd Page +/// (or Next Even Page) still moves it when that frame is on a page of the other parity. +#[test] +fn odd_and_even_page_starts_keep_parity_at_the_top_of_a_frame() { + use designcraft_doc::StartParagraph; + use designcraft_doc::story::PAGE_BREAK; + let pages_of = |start: StartParagraph| { + let mut d = Document::new(&NewDocument { pages: 4, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + // "two" begins at the top of page 2, after the page break that ends "one". + d.story_mut(sid).unwrap().insert(0, &format!("one{PAGE_BREAK}\ntwo\nthree")); + d.story_mut(sid).unwrap().paras[1].para.start_paragraph = Some(start); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + // Frame i is on page i + 1. + cs.frames.iter().enumerate().flat_map(|(fi, f)| f.lines.iter().map(move |l| (l.para, fi + 1))).collect::>() + }; + // Already at the top of an even page: Next Page and Next Even Page leave "two" where it is. + assert_eq!(pages_of(StartParagraph::NextPage), vec![(0, 1), (1, 2), (2, 2)]); + assert_eq!(pages_of(StartParagraph::NextEvenPage), vec![(0, 1), (1, 2), (2, 2)]); + // Next Odd Page takes it to page 3, leaving page 2 empty. + assert_eq!(pages_of(StartParagraph::NextOddPage), vec![(0, 1), (1, 3), (2, 3)]); +} + #[test] fn line_before_a_break_character_is_a_last_line() { // As in InDesign: column, frame and page breaks end the paragraph's last line (set with the From cc13e80c9d3ebfe3453b8fcb3aad0165aa6b027e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 00:29:55 +0200 Subject: [PATCH 17/32] feat(tools): Page tool selects pages; handles preview the liquid rule, Alt-drag resizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (0:42– 2:06) selects a page with the Page tool, gets its bounding box with handles, drags a handle (the page springs back: a liquid-rule preview), holds Option to really resize it, Shift for proportions, and reads the size in a tooltip by the cursor. DesignCraft's Page tool only opened a generic Page Size dialog on click. - tools: the Page tool selects the page under a click (none on the pasteboard); the selected page shows its outline, eight handles and its liquid page rule ("Scale", "Object-Based"…) above it. Dragging a handle previews layout.pageSize (the liquid rule re-flows the objects) and springs back on release; Alt resizes, Shift keeps the proportions; a W/H readout follows the pointer; resize cursors on the handles. Double-click a page for the Page Size dialog. - doc: Selection.page (the Page tool's page); engine: layout.selectPage (no undo step). - layout.pageSize `anchor` (0-8): the page grows away from the reference point and its objects and guides keep their place relative to it (a handle drag anchors the opposite edge); liquid rules work from that. - canvas: PageHandles and Tag overlays; translations for the tags and the Resize Page undo label. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/selection.rs | 3 + crates/engine/src/cmd/layout.rs | 61 +++++++++++- crates/engine/src/tests.rs | 73 ++++++++++++++ crates/tools/src/lib.rs | 10 +- crates/tools/src/nav.rs | 160 ++++++++++++++++++++++++++++--- crates/tools/src/tests.rs | 12 ++- crates/ui-egui/src/canvas.rs | 21 ++++ crates/ui-egui/src/i18n.rs | 3 + crates/ui-egui/src/i18n/ar.rs | 3 + crates/ui-egui/src/i18n/it.rs | 3 + crates/ui-egui/src/i18n/ja.rs | 3 + crates/ui-egui/src/i18n/pt_br.rs | 3 + crates/ui-egui/src/i18n/uk.rs | 3 + docs/target-app-parity.md | 2 +- 15 files changed, 340 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f8207..050c07d9 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-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. 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/doc/src/selection.rs b/crates/doc/src/selection.rs index d9e9b7a3..2d28bf2c 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -80,6 +80,9 @@ pub struct Selection { /// Key object for Align. #[serde(default, skip_serializing_if = "Option::is_none")] pub key: Option, + /// The page selected with the Page tool (absolute index). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page: Option, } impl Selection { diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bd532079..2c23640f 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -376,10 +376,24 @@ pub fn specs() -> Vec { "Page Size", [], None, - "{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…} — pages of their own size (Page tool); objects on the pages keep their place on them", + "{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…, anchor?: 0–8 (reference point that stays put, row-major from the top-left; default 0)} — pages of their own size (Page tool); objects keep their place relative to the anchor, or follow the page's liquid rule", has_doc, page_size ), + cmd!(noundo "layout.selectPage", "Select Page", [], None, "{page: 1-based | null} — the page the Page tool selects (its handles, the Control panel)", has_doc, |s, p| { + let n = s.doc()?.doc.page_count(); + let page = match p.get("page") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().filter(|v| *v >= 1 && *v as usize <= n).ok_or_else(|| bad("layout.selectPage", format!("`page`: 1–{n}")))? as usize - 1), + }; + let st = s.doc_mut()?; + if page.is_some() { + st.selection = Default::default(); + } + st.selection.page = page; + st.revision += 1; + Ok(json!({"page": page.map(|p| p + 1)})) + }), cmd!( "layout.section", "Numbering & Section Options…", @@ -640,8 +654,12 @@ fn page_size(s: &mut crate::Session, p: &Value) -> Result { w = Some(pr.width); h = Some(pr.height); } + let anchor = match p.get("anchor") { + None | Some(Value::Null) => 0, + Some(v) => v.as_u64().filter(|a| *a <= 8).ok_or_else(|| bad("layout.pageSize", "`anchor`: 0–8"))? as usize, + }; s.edit(|d, _| { - resize_pages(d, &pages, w, h); + resize_pages_from(d, &pages, w, h, anchor); Ok(json!({"pages": pages.len()})) }) } @@ -649,14 +667,35 @@ fn page_size(s: &mut crate::Session, p: &Value) -> Result { /// Give `pages` (1-based) their own size. Objects stay on their page; pages with a liquid rule /// re-flow their objects by it. pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option) { + resize_pages_from(d, pages, w, h, 0); +} + +/// [`resize_pages`] keeping reference point `anchor` (0–8, row-major from the top-left) in place: +/// the page grows away from it, so objects keep their place relative to it. +pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option, anchor: usize) { + let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3).min(2) as f64 / 2.0); { for page in pages { let Some((si, pi)) = d.page_loc(page - 1) else { continue }; let sp = std::sync::Arc::make_mut(&mut d.spreads[si]); let old: Vec = sp.pages.iter().map(|x| x.x).collect(); let old_bounds = sp.pages[pi].bounds(); + // The page's own objects (outer pages take the pasteboard beside them). + let last = sp.pages.len() - 1; + let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == last { f64::INFINITY } else { old_bounds.x1 }); + let own: Vec = sp + .items + .iter() + .enumerate() + .filter(|(_, it)| { + let c = it.bounds().center().x; + c >= lo && c < hi + }) + .map(|(i, _)| i) + .collect(); let pg = &mut sp.pages[pi]; let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width); + let dh = h.map_or(0.0, |h| h.max(1.0) - pg.height); if let Some(w) = w { pg.width = w.max(1.0); } @@ -693,8 +732,22 @@ pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w } } } - // Liquid rules: objects were carried along with the page's left edge. - let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0); + // Growing away from the anchor: the page's objects and guides keep their place + // relative to it. + let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh); + if shift != designcraft_geom::Vec2::ZERO { + for i in &own { + if let Some(it) = sp.items.get_mut(*i) { + let it = std::sync::Arc::make_mut(it); + it.xf = designcraft_geom::Affine::translate(shift) * it.xf; + } + } + for g in &mut sp.pages[pi].guides { + g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y }; + } + } + // Liquid rules: objects were carried along with the page's left edge (and the anchor). + let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift; super::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, moved); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 17d1ed09..deec4b9b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -569,3 +569,76 @@ fn commands_take_non_object_params_without_panicking() { assert!(r.is_ok(), "{p}: {r:?}"); } } + +#[test] +fn page_tool_selects_a_page_and_its_handles_preview_or_resize_it() { + use designcraft_tools::{CanvasLayout, Mods, PointerEvent, PointerKind}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [400, 100, 500, 200]})).unwrap()["id"].as_u64().unwrap(); + let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + let width = |s: &Session| s.doc().unwrap().doc.spreads[0].pages[0].width; + // The page on the canvas (a single page is centred on the spine). + let page = |s: &Session| { + let d = &s.doc().unwrap().doc; + CanvasLayout::new(d, false).page_rect(d, 0).unwrap() + }; + s.set_tool("page"); + let v = ViewInfo::at_zoom(1.0); + let ev = |kind, x: f64, y: f64, alt: bool| PointerEvent { mods: Mods { alt, ..Default::default() }, ..PointerEvent::new(kind, x, y) }; + let drag = |s: &mut Session, from: (f64, f64), by: f64, alt: bool, check: &dyn Fn(&Session)| { + s.pointer(&ev(PointerKind::Down, from.0, from.1, alt), v).unwrap(); + s.pointer(&ev(PointerKind::Drag, from.0 + by, from.1, alt), v).unwrap(); + check(s); + s.pointer(&ev(PointerKind::Up, from.0 + by, from.1, alt), v).unwrap(); + }; + // A click selects the page. + let c = page(&s).center(); + s.pointer(&ev(PointerKind::Down, c.x, c.y, false), v).unwrap(); + s.pointer(&ev(PointerKind::Up, c.x, c.y, false), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.page, Some(0)); + // A plain drag of the right handle previews the page's liquid rule and springs back. + s.execute("liquid.pageRule", &json!({"rule": "scale"})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = page(&s); + drag(&mut s, (r.x1, r.center().y), 612.0, false, &|s| { + assert_eq!(width(s), 1224.0, "previewed"); + // Scale keeps proportions: only wider, so the frame is centred on the wider page. + assert_eq!(frame(s).x0, 706.0, "the Scale rule in the preview: {:?}", frame(s)); + }); + assert_eq!((width(&s), frame(&s)), (612.0, designcraft_geom::Rect::new(400.0, 100.0, 500.0, 200.0)), "sprung back"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "nothing to undo"); + // Alt-drag resizes; with the rule off the frame stays where it is. + s.execute("liquid.pageRule", &json!({"rule": "off"})).unwrap(); + let r = page(&s); + drag(&mut s, (r.x1, r.center().y), 100.0, true, &|_| {}); + assert_eq!(width(&s), 712.0); + assert_eq!(frame(&s).x0, 400.0); + // The left handle grows the page to the left: the frame keeps its place by the right edge. + let r = page(&s); + drag(&mut s, (r.x0, r.center().y), -100.0, true, &|_| {}); + let pg = s.doc().unwrap().doc.spreads[0].pages[0].clone(); + assert_eq!(pg.width, 812.0); + assert_eq!(frame(&s).x0 - pg.x, 500.0, "100 pt further from the page's left edge"); + // A click on the pasteboard deselects the page. + let r = page(&s); + s.pointer(&ev(PointerKind::Down, r.x0 - 200.0, r.center().y, false), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.page, None); +} + +#[test] +fn page_size_keeps_objects_by_its_anchor() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + // Bottom-right stays put: 100 wider and 50 taller moves the frame 100 right and 50 down. + s.execute("layout.pageSize", &json!({"pages": [1], "width": 500, "height": 450, "anchor": 8})).unwrap(); + assert_eq!(frame(&s), designcraft_geom::Rect::new(200.0, 150.0, 300.0, 250.0)); + // The centre: half of it. + s.execute("layout.pageSize", &json!({"pages": [1], "width": 700, "height": 450, "anchor": 4})).unwrap(); + assert_eq!(frame(&s).x0, 300.0); + assert!(s.execute("layout.pageSize", &json!({"pages": [1], "width": 700, "anchor": 9})).is_err()); + assert!(s.execute("layout.selectPage", &json!({"page": 2})).is_err()); + assert_eq!(s.execute("layout.selectPage", &json!({"page": 1})).unwrap()["page"], 1); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f3de390a..dd09e9c2 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -314,6 +314,14 @@ pub enum Overlay { b: Point, label: String, }, + /// A page selected with the Page tool (canvas rect): its outline and eight handles. + PageHandles(Rect), + /// A small tag whose bottom-left is at `p` (the Page tool's liquid page rule; English, the UI + /// translates it). + Tag { + p: Point, + text: String, + }, } /// Correction from a snap, in spread coordinates for `delta`. @@ -400,7 +408,7 @@ pub fn create(id: &str) -> Box { "hand" => Box::new(nav::HandTool::default()), "placeGun" => Box::new(nav::PlaceGun::default()), "zoom" => Box::new(nav::ZoomTool::default()), - "page" => Box::new(nav::PageTool), + "page" => Box::new(nav::PageTool::default()), "typeOnPath" => Box::new(nav::PathTypeTool), "gap" => Box::new(nav::GapTool::default()), "contentCollector" => Box::new(nav::ConveyorTool::new(false)), diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index d892b090..a94a9388 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -3,7 +3,7 @@ use designcraft_geom::Point; use serde_json::json; -use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; /// Hand tool: drag to pan. Alt-press starts Power Zoom: the view zooms out to the spread, the /// pointer aims a rectangle of the previous view, and releasing zooms back in there. @@ -162,27 +162,161 @@ impl Tool for PlaceGun { } } -/// Page tool (Shift+P): click a page to set its own size. +/// Page tool (Shift+P): click a page to select it; drag one of its handles to preview its liquid +/// page rule (the page springs back on release), Alt-drag to resize it, Shift to keep its +/// proportions; double-click a page for exact sizes. #[derive(Default)] -pub struct PageTool; +pub struct PageTool { + drag: Option, +} + +/// A page handle being dragged. +#[derive(Clone, Copy, Debug)] +struct PageDrag { + /// Absolute page index. + page: usize, + /// Reference-point index of the handle (0–8, row-major; 4 is the centre and has none). + handle: usize, + start: Point, + cur: Point, + /// The page's size when the drag began. + size: (f64, f64), + alt: bool, +} + +/// The eight handles of a page rect: (reference-point index, canvas point). +fn page_handles(r: designcraft_geom::Rect) -> impl Iterator { + (0..9).filter(|i| *i != 4).map(move |i| (i, Point::new(r.x0 + r.width() * (i % 3) as f64 / 2.0, r.y0 + r.height() * (i / 3) as f64 / 2.0))) +} + +/// The liquid page rule as the Page tool tags a page. +pub fn rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { + use designcraft_doc::LiquidRule as R; + match rule { + R::Off => "Off", + R::Scale => "Scale", + R::ReCenter => "Re-center", + R::ObjectBased => "Object-Based", + R::GuideBased => "Guide-Based", + } +} + +impl PageTool { + /// The selected page's handle under `p`. + fn handle_at(&self, cx: &ToolContext, p: Point) -> Option<(usize, usize)> { + let page = cx.selection.page?; + let r = cx.layout.page_rect(cx.doc, page)?; + page_handles(r).find(|(_, h)| (h.x - p.x).abs() <= cx.tol(5.0) && (h.y - p.y).abs() <= cx.tol(5.0)).map(|(i, _)| (page, i)) + } + + /// The new page size for the drag at `p`: the handle's edges follow the pointer (Shift: in + /// proportion). + fn size_at(cx: &ToolContext, d: &PageDrag, p: Point, shift: bool) -> (f64, f64) { + let si = cx.doc.page_loc(d.page).map_or(0, |l| l.0); + let v = cx.layout.delta_to_spread(designcraft_doc::SpreadRef::Doc(si), p - d.start); + let (col, row) = (d.handle % 3, d.handle / 3); + let dx = match col { + 0 => -v.x, + 2 => v.x, + _ => 0.0, + }; + let dy = match row { + 0 => -v.y, + 2 => v.y, + _ => 0.0, + }; + let (w0, h0) = d.size; + let (mut w, mut h) = ((w0 + dx).max(1.0), (h0 + dy).max(1.0)); + if shift { + // The axis being dragged (the larger change at a corner) sets the scale. + let k = match (col == 1, row == 1) { + (true, _) => h / h0, + (_, true) => w / w0, + _ if (w / w0 - 1.0).abs() >= (h / h0 - 1.0).abs() => w / w0, + _ => h / h0, + }; + (w, h) = ((w0 * k).max(1.0), (h0 * k).max(1.0)); + } + (w, h) + } +} impl Tool for PageTool { fn id(&self) -> &'static str { "page" } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - if ev.kind != PointerKind::Up { - return vec![]; + match ev.kind { + PointerKind::Down => { + if let Some((page, handle)) = self.handle_at(cx, ev.pos) { + let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; + let pg = &cx.doc.spreads[si].pages[pi]; + self.drag = Some(PageDrag { page, handle, start: ev.pos, cur: ev.pos, size: (pg.width, pg.height), alt: ev.mods.alt }); + return vec![Action::Begin("Resize Page".into())]; + } + // A click selects the page under the pointer (none on the pasteboard). + let page = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))); + if page == cx.selection.page { + return vec![]; + } + vec![Action::Exec("layout.selectPage".into(), json!({"page": page.map(|p| p + 1)}))] + } + PointerKind::Drag => { + let Some(d) = self.drag.as_mut() else { return vec![] }; + d.cur = ev.pos; + d.alt |= ev.mods.alt; + let d = *d; + let (w, h) = Self::size_at(cx, &d, ev.pos, ev.mods.shift); + vec![Action::Preview("layout.pageSize".into(), json!({"pages": [d.page + 1], "width": w, "height": h, "anchor": 8 - d.handle}))] + } + PointerKind::Up => match self.drag.take() { + // Alt-drag resizes; a plain drag previews the liquid rule and springs back. + Some(d) if d.alt || ev.mods.alt => vec![Action::Commit], + Some(_) => vec![Action::Cancel], + None => vec![], + }, + PointerKind::DoubleClick => { + // Exact sizes: the Page Size dialog for the page. + let Some(page) = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))) else { + return vec![]; + }; + let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; + let pg = &cx.doc.spreads[si].pages[pi]; + vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [page + 1], "width": pg.width, "height": pg.height}))] + } + PointerKind::Move => vec![], } - let Some((designcraft_doc::SpreadRef::Doc(si), sp)) = cx.layout.spread_at(ev.pos) else { return vec![] }; - let Some(spread) = cx.doc.spreads.get(si) else { return vec![] }; - let Some(pi) = spread.pages.iter().position(|p| p.bounds().contains(sp)) else { return vec![] }; - let pg = &spread.pages[pi]; - let n = cx.doc.first_page_of_spread(si) + pi + 1; - vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [n], "width": pg.width, "height": pg.height}))] } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Arrow + fn overlays(&self, cx: &ToolContext) -> Vec { + let Some(page) = cx.selection.page else { return vec![] }; + let Some(r) = cx.layout.page_rect(cx.doc, page) else { return vec![] }; + let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; + let pg = &cx.doc.spreads[si].pages[pi]; + let mut v = vec![Overlay::PageHandles(r), Overlay::Tag { p: Point::new(r.x0, r.y0 - cx.tol(6.0)), text: rule_label(pg.liquid).into() }]; + if let Some(d) = self.drag { + v.push(Overlay::Measure { + p: d.cur, + text: format!( + "W: {}\nH: {}", + designcraft_geom::format_measure(pg.width, cx.unit), + designcraft_geom::format_measure(pg.height, cx.unit) + ), + }); + } + v + } + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + let handle = self.drag.map(|d| d.handle).or_else(|| self.handle_at(cx, p).map(|(_, h)| h)); + match handle { + Some(1 | 7) => Cursor::ResizeV, + Some(3 | 5) => Cursor::ResizeH, + Some(0 | 8) => Cursor::ResizeNwSe, + Some(_) => Cursor::ResizeNeSw, + None => Cursor::Arrow, + } + } + fn busy(&self) -> bool { + self.drag.is_some() } } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6776902a..b2e59724 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -227,14 +227,22 @@ fn hand_tool_alt_press_power_zooms() { } #[test] -fn page_tool_opens_page_size() { +fn page_tool_selects_a_page_and_opens_page_size_on_double_click() { let d = Document::new(&NewDocument::default()); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let off = l.xf(SpreadRef::Doc(0)).translation(); + let (x, y) = (200.0 + off.x + d.spreads[0].pages[0].x, 200.0 + off.y); let mut t = create("page"); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0 + off.x + d.spreads[0].pages[0].x, 200.0 + off.y)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)); + assert_eq!(a, vec![Action::Exec("layout.selectPage".into(), serde_json::json!({"page": 1}))]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, x, y)); assert_eq!(a, vec![Action::Dialog("cmd:layout.pageSize".into(), serde_json::json!({"pages": [1], "width": 612.0, "height": 792.0}))]); + // The selected page shows its handles and its liquid page rule. + let s = Selection { page: Some(0), ..Default::default() }; + let cx = ctx(&d, &s, &c, &l); + let o = t.overlays(&cx); + assert!(matches!(o.as_slice(), [Overlay::PageHandles(_), Overlay::Tag { text, .. }] if text == "Off"), "{o:?}"); } #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae294..b3fa9233 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1237,6 +1237,27 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { painter.rect_filled(r, 3.0, tok.measure_bg); painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); } + Overlay::PageHandles(r) => { + // The Page tool's selected page: outline and eight handles, as InDesign draws them. + let r = xf.rect(r); + let col = Color32::from_rgb(79, 153, 255); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Middle); + for i in (0..9).filter(|i| *i != 4) { + let c = pos2(r.min.x + r.width() * (i % 3) as f32 / 2.0, r.min.y + r.height() * (i / 3) as f32 / 2.0); + let h = Rect::from_center_size(c, vec2(7.0, 7.0)); + painter.rect_filled(h, 0.0, Color32::WHITE); + painter.rect_stroke(h, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); + } + } + Overlay::Tag { p, text } => { + let text = crate::i18n::tr(&app.ui.language, &text).to_owned(); + let s = xf.to_screen(p); + let g = painter.layout_no_wrap(text, egui::FontId::proportional(10.0), tok.measure_text); + let r = Rect::from_min_size(s - vec2(0.0, g.size().y + 4.0), g.size() + vec2(8.0, 4.0)); + painter.rect_filled(r, 2.0, tok.measure_bg); + painter.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_rgb(79, 153, 255)), StrokeKind::Inside); + painter.galley(r.min + vec2(4.0, 2.0), g, tok.measure_text); + } Overlay::Path { .. } => {} } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5252c388..63c70d4a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2498,6 +2498,9 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Added", ["Added", "Added", "Added", "Added", "已添加"]), ("Deleted", ["Deleted", "Deleted", "Deleted", "Deleted", "已删除"]), ("Guide-based", ["Guide-based", "Guide-based", "Guide-based", "Guide-based", "基于参考线"]), + ("Object-Based", ["Object-Based", "Object-Based", "Object-Based", "Object-Based", "基于对象"]), + ("Guide-Based", ["Guide-Based", "Guide-Based", "Guide-Based", "Guide-Based", "基于参考线"]), + ("Resize Page", ["Seitengröße ändern", "Redimensionner la page", "Cambiar tamaño de página", "ページのサイズを変更", "调整页面大小"]), ("Loop", ["Loop", "Loop", "Loop", "Loop", "循环播放"]), ("Object-based", ["Object-based", "Object-based", "Object-based", "Object-based", "基于对象"]), ("Off", ["Off", "Off", "Off", "Off", "关闭"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index a182d09e..e0c17c88 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -709,6 +709,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "مضاف"), ("Deleted", "محذوف"), ("Guide-based", "استنادًا إلى الأدلة"), + ("Object-Based", "استنادًا إلى الكائنات"), + ("Guide-Based", "استنادًا إلى الأدلة"), + ("Resize Page", "تغيير حجم الصفحة"), ("Loop", "تكرار"), ("Object-based", "استنادًا إلى الكائنات"), ("Play on Page Load", "تشغيل عند تحميل الصفحة"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 75ece03f..3f0915aa 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1295,6 +1295,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Aggiunto"), ("Deleted", "Eliminato"), ("Guide-based", "Basato su guide"), + ("Object-Based", "Basato su oggetti"), + ("Guide-Based", "Basato su guide"), + ("Resize Page", "Ridimensiona pagina"), ("Loop", "Ciclo continuo"), ("Object-based", "Basato su oggetti"), ("Off", "Disattivato"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 642b4a4c..9e0f7a95 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1291,6 +1291,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "追加済み"), ("Deleted", "削除済み"), ("Guide-based", "ガイド基準"), + ("Object-Based", "オブジェクト基準"), + ("Guide-Based", "ガイド基準"), + ("Resize Page", "ページのサイズを変更"), ("Loop", "繰り返し再生"), ("Object-based", "オブジェクト基準"), ("Off", "オフ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 982dfb20..e1fbf308 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -708,6 +708,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Adicionado"), ("Deleted", "Excluído"), ("Guide-based", "Baseado em guias"), + ("Object-Based", "Baseado em objetos"), + ("Guide-Based", "Baseado em guias"), + ("Resize Page", "Redimensionar página"), ("Loop", "Repetir"), ("Object-based", "Baseado em objetos"), ("Play on Page Load", "Reproduzir ao carregar a página"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f3bc8fb3..5994dc13 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1292,6 +1292,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Додано"), ("Deleted", "Видалено"), ("Guide-based", "На основі напрямних"), + ("Object-Based", "На основі об’єктів"), + ("Guide-Based", "На основі напрямних"), + ("Resize Page", "Змінити розмір сторінки"), ("Loop", "Зациклити"), ("Object-based", "На основі об’єктів"), ("Off", "Вимкнено"), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c..84b602d5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -396,7 +396,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. - Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. -- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. +- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. - File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document). - Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. - Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. From 554019cdd582207835a2f895344483389a016cb0 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:32:16 +0200 Subject: [PATCH 18/32] Tables: table.options limits header and footer rows (#861) table.options added as many rows as the requested header and footer counts needed, without a limit. A large number typed into the Table panel's Header Rows field froze the app and exhausted memory. Both counts are now capped at 100, the limit table.insert already has. --- crates/engine/src/cmd/table.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index ad306be8..7851ab85 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -931,8 +931,9 @@ fn options(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("altCols") { o.alt_cols = parse_alt(v); } - let h = p.get("headerRows").and_then(Value::as_u64).map(|v| v as usize); - let f = p.get("footerRows").and_then(Value::as_u64).map(|v| v as usize); + // At most 100 of each, as in `table.insert`: the rows the table lacks are added below. + let h = p.get("headerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize); + let f = p.get("footerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize); if h.is_some() || f.is_some() { let (h, f) = (h.unwrap_or(t.header_rows()), f.unwrap_or(t.footer_rows())); // Add rows when there aren't enough for the requested header/footer plus one body row. @@ -1680,6 +1681,19 @@ mod sort_tests { s.execute("table.moveColumn", &json!({"from": 1, "to": 0})).unwrap(); assert_eq!((cell(&s, 2, 0), cell(&s, 2, 1)), ("x".to_string(), "a".to_string())); } + + #[test] + fn table_options_limits_header_and_footer_rows() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + // The limit `table.insert` has: the table used to grow by as many rows as were asked for. + s.execute("table.options", &json!({"headerRows": 5000, "footerRows": 5000})).unwrap(); + let t = table(&s); + assert_eq!((t.header_rows(), t.footer_rows(), t.nrows()), (100, 100, 201)); + } } #[cfg(test)] From 59b6125af1c5402d6950ab8a92ff28125373a2f0 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 00:51:47 +0200 Subject: [PATCH 19/32] feat(ui): Control panel for the Page tool; Controlled by Parent; Show Parent Page Overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (1:02– 2:06, 13:47) sizes pages from the Control panel with the Page tool selected: X/Y, W/H, the page-size preset ("US Business Card"), orientation, Liquid Page Rule ("Controlled by Parent"), Objects Move with Page and Show Parent Page Overlay. DesignCraft's Control panel showed the (empty) object controls. - ui: with the Page tool, the Control panel shows the selected page (or the page in view): the reference point (W/H, preset and orientation keep it in place via layout.pageSize's anchor), X/Y (read-only), W/H, the preset dropdown, portrait/landscape, Liquid Page Rule, Objects Move with Page (always on: pages don't move within spreads yet) and Show Parent Page Overlay. - doc: LiquidRule::ControlledByParent (the parent page's rule, Off without one; resolved by liquid::effective_rule); Page.show_parent_overlay. - engine: layout.pageOptions {pages, showParentOverlay}; liquid.pageRule takes controlledByParent. - canvas: the parent page's size over a page with the overlay on. - Liquid Layout panel and Control panel share InDesign's rule list (Off, Scale, Re-center, Object-based, Guide-based, Controlled by Parent); "US Business Card" preset; translations. Builds on the Page tool PR (#862) for the selected page. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/build.rs | 1 + crates/doc/src/page.rs | 6 ++ crates/engine/src/cmd/layout.rs | 29 ++++++ crates/engine/src/cmd/liquid.rs | 24 ++++- crates/engine/src/tests.rs | 25 +++++ crates/idml/src/import.rs | 1 + crates/tools/src/nav.rs | 1 + crates/ui-egui/src/canvas.rs | 12 ++- crates/ui-egui/src/chrome.rs | 111 ++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 29 ++++++ crates/ui-egui/src/i18n/ar.rs | 5 + crates/ui-egui/src/i18n/it.rs | 5 + crates/ui-egui/src/i18n/ja.rs | 5 + crates/ui-egui/src/i18n/pt_br.rs | 5 + crates/ui-egui/src/i18n/uk.rs | 5 + crates/ui-egui/src/panels/interactive.rs | 33 +++---- crates/ui-egui/src/panels/properties.rs | 7 +- 18 files changed, 280 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 050c07d9..d9957d5d 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-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule (now with Controlled by Parent) and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. - **2026-10-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. 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. diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index c274f213..574049d7 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -226,6 +226,7 @@ impl Document { liquid: Default::default(), transition: None, view_rotation: 0, + show_parent_overlay: false, } } diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs index f1eaa48a..6aa32579 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -114,6 +114,9 @@ pub struct Page { /// Liquid page rule: how objects follow when the page changes size. #[serde(default, skip_serializing_if = "LiquidRule::is_off")] pub liquid: LiquidRule, + /// Page tool › Show Parent Page Overlay: the parent page's size drawn over this page. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub show_parent_overlay: bool, /// Window › Interactive › Page Transitions (the spread's first page holds the spread's). #[serde(default, skip_serializing_if = "Option::is_none")] pub transition: Option, @@ -208,6 +211,8 @@ pub enum LiquidRule { GuideBased, /// Each object's pins and resize settings decide (`Item::liquid`). ObjectBased, + /// The rule of the page's parent page (InDesign's "Controlled by Parent"; Off without one). + ControlledByParent, } impl LiquidRule { @@ -367,6 +372,7 @@ mod tests { liquid: Default::default(), transition: None, view_rotation: 0, + show_parent_overlay: false, } } diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 2c23640f..4055b080 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -380,6 +380,15 @@ pub fn specs() -> Vec { has_doc, page_size ), + cmd!( + "layout.pageOptions", + "Page Options", + [], + None, + "{pages: [1-based], showParentOverlay?: bool} — Page tool options (Show Parent Page Overlay: the parent page's size drawn over the page)", + has_doc, + page_options + ), cmd!(noundo "layout.selectPage", "Select Page", [], None, "{page: 1-based | null} — the page the Page tool selects (its handles, the Control panel)", has_doc, |s, p| { let n = s.doc()?.doc.page_count(); let page = match p.get("page") { @@ -664,6 +673,26 @@ fn page_size(s: &mut crate::Session, p: &Value) -> Result { }) } +fn page_options(s: &mut crate::Session, p: &Value) -> Result { + let pages: Vec = + p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default(); + let n = s.doc()?.doc.page_count(); + if pages.is_empty() || pages.iter().any(|x| *x == 0 || *x > n) { + return Err(bad("layout.pageOptions", "give `pages` (1-based)")); + } + let overlay = p.get("showParentOverlay").and_then(Value::as_bool); + s.edit(|d, _| { + for page in &pages { + let Some((si, pi)) = d.page_loc(page - 1) else { continue }; + let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pi]; + if let Some(v) = overlay { + pg.show_parent_overlay = v; + } + } + Ok(json!({"pages": pages.len()})) + }) +} + /// Give `pages` (1-based) their own size. Objects stay on their page; pages with a liquid rule /// re-flow their objects by it. pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option) { diff --git a/crates/engine/src/cmd/liquid.rs b/crates/engine/src/cmd/liquid.rs index 879fdb36..8c8d4be2 100644 --- a/crates/engine/src/cmd/liquid.rs +++ b/crates/engine/src/cmd/liquid.rs @@ -35,7 +35,7 @@ fn axis(lo: f64, hi: f64, o: (f64, f64), n: (f64, f64), pin_lo: bool, pin_hi: bo fn target(rule: LiquidRule, b: Rect, old: Rect, new: Rect, liquid: Option, vguides: &[f64], hguides: &[f64]) -> Rect { let (ox, oy, nx, ny) = ((old.x0, old.x1), (old.y0, old.y1), (new.x0, new.x1), (new.y0, new.y1)); match rule { - LiquidRule::Off => b, + LiquidRule::Off | LiquidRule::ControlledByParent => b, LiquidRule::ReCenter => b + (new.center() - old.center()), LiquidRule::Scale => { let k = (new.width() / old.width()).min(new.height() / old.height()); @@ -63,7 +63,7 @@ fn target(rule: LiquidRule, b: Rect, old: Rect, new: Rect, liquid: Option LiquidRule { + let Some(pg) = d.spread(sr).and_then(|sp| sp.pages.get(pi)) else { return LiquidRule::Off }; + if pg.liquid != LiquidRule::ControlledByParent { + return pg.liquid; + } + let SpreadRef::Doc(si) = sr else { return LiquidRule::Off }; + let abs = d.first_page_of_spread(si) + pi; + match d.parent_page_for(abs).and_then(|(ppi, ppg)| d.parents.get(ppi)?.pages.get(ppg).map(|p| p.liquid)) { + Some(LiquidRule::ControlledByParent) | None => LiquidRule::Off, + Some(r) => r, + } +} + fn rule_param(p: &Value, id: &str) -> Result { - serde_json::from_value(p.get("rule").cloned().unwrap_or(json!("off"))).map_err(|_| bad(id, "`rule`: off|scale|reCenter|guideBased|objectBased")) + serde_json::from_value(p.get("rule").cloned().unwrap_or(json!("off"))) + .map_err(|_| bad(id, "`rule`: off|scale|reCenter|guideBased|objectBased|controlledByParent")) } pub fn specs() -> Vec { @@ -119,7 +135,7 @@ pub fn specs() -> Vec { "Liquid Page Rule", ["Layout", "Liquid Layout"], None, - "{rule: off|scale|reCenter|guideBased|objectBased, pages?: [1-based] (default: all)}", + "{rule: off|scale|reCenter|guideBased|objectBased|controlledByParent (the parent page's rule), pages?: [1-based] (default: all)}", has_doc, |s, p| { let rule = rule_param(p, "liquid.pageRule")?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index deec4b9b..9557e03c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -642,3 +642,28 @@ fn page_size_keeps_objects_by_its_anchor() { assert!(s.execute("layout.selectPage", &json!({"page": 2})).is_err()); assert_eq!(s.execute("layout.selectPage", &json!({"page": 1})).unwrap()["page"], 1); } + +#[test] +fn controlled_by_parent_takes_the_parent_pages_rule() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + // The page follows its parent; the parent page re-centres. + s.execute("liquid.pageRule", &json!({"rule": "controlledByParent", "pages": [1]})).unwrap(); + s.edit(|d, _| { + std::sync::Arc::make_mut(&mut d.parents[0]).pages[0].liquid = designcraft_doc::LiquidRule::ReCenter; + Ok(()) + }) + .unwrap(); + s.execute("layout.pageSize", &json!({"pages": [1], "width": 600})).unwrap(); + assert_eq!(frame(&s).x0, 200.0, "re-centred: {:?}", frame(&s)); + // Without a parent (or a parent that's also controlled by its parent) nothing moves. + s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": null})).unwrap(); + s.execute("layout.pageSize", &json!({"pages": [1], "width": 800})).unwrap(); + assert_eq!(frame(&s).x0, 200.0); + // Show Parent Page Overlay is a page option. + s.execute("layout.pageOptions", &json!({"pages": [1], "showParentOverlay": true})).unwrap(); + assert!(s.doc().unwrap().doc.spreads[0].pages[0].show_parent_overlay); + assert!(s.execute("layout.pageOptions", &json!({"pages": [2], "showParentOverlay": true})).is_err()); +} diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 59ca44be..5bb4387d 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1963,6 +1963,7 @@ impl<'r> Importer<'r> { liquid: Default::default(), transition: None, view_rotation: 0, + show_parent_overlay: false, }); } let overrides: Vec> = diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index a94a9388..54db73d0 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -198,6 +198,7 @@ pub fn rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { R::ReCenter => "Re-center", R::ObjectBased => "Object-Based", R::GuideBased => "Guide-Based", + R::ControlledByParent => "Controlled by Parent", } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b3fa9233..003549a4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -604,8 +604,18 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document painter.rect_stroke(rect(br), 0.0, Stroke::new(hair(painter), c32(s.bleed_color)), StrokeKind::Middle); } let pasteboard = m.inverse().transform_rect_bbox(layout.pasteboard); - for p in &sp.pages { + for (pi, p) in sp.pages.iter().enumerate() { let pr = p.bounds(); + // Page tool › Show Parent Page Overlay: the parent page's size over the page. + if p.show_parent_overlay + && let SpreadRef::Doc(si) = slot.spread + && let Some((ppi, ppg)) = doc.parent_page_for(doc.first_page_of_spread(si) + pi) + && let Some(pp) = doc.parents.get(ppi).and_then(|ps| ps.pages.get(ppg)) + { + let r = rect(DRect::new(pr.x0, pr.y0, pr.x0 + pp.width, pr.y0 + pp.height)); + painter.rect_filled(r, 0.0, Color32::from_rgba_unmultiplied(110, 110, 230, 28)); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_rgb(110, 110, 230)), StrokeKind::Middle); + } if !app.ui.guides { continue; } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index c2f1cb48..f3a17ebf 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -182,7 +182,9 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { ui.horizontal_top(|ui| { let text_mode = matches!(app.session.tool_id(), "type" | "verticalType") || app.session.active().is_some_and(|d| d.selection.text.is_some()); - if text_mode { + if app.session.tool_id() == "page" { + control_page(app, ui); + } else if text_mode { control_text(app, ui); } else { control_object(app, ui); @@ -192,6 +194,96 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// The Control panel with the Page tool, as InDesign's: reference point, X/Y, W/H, page-size +/// preset, orientation, Liquid Page Rule, Objects Move with Page and Show Parent Page Overlay, for +/// the page the Page tool selected (else the page in view). W/H keep the reference point in place. +fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { + let lang = app.ui.language.clone(); + let Some(st) = app.session.active() else { return }; + let units = st.doc.settings.horizontal_units; + let Some(page) = st.selection.page.or_else(|| crate::canvas::current_page(app)) else { return }; + let Some(st) = app.session.active() else { return }; + let d = st.doc.clone(); + let Some((si, pi)) = d.page_loc(page) else { return }; + let pg = d.spreads[si].pages[pi].clone(); + let n = page + 1; + // Reference point: W/H edits keep it in place. + let (r, _) = ui.allocate_exact_size(vec2(30.0, 44.0), Sense::hover()); + if let Some(p) = panels::properties::ref_point_proxy(ui, egui::Rect::from_center_size(r.center(), vec2(27.0, 27.0)), app.ui.ref_point) { + app.ui.ref_point = p; + } + let anchor = app.ui.ref_point.min(8); + let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3) as f64 / 2.0); + // The reference point's position: from the spine across, from the top down. + let (x, y) = (pg.x - d.spreads[si].spine_x() + fx * pg.width, fy * pg.height); + egui::Grid::new("ctl_page_xywh").num_columns(4).spacing(vec2(4.0, 4.0)).show(ui, |ui| { + caption(ui, crate::i18n::tr(&lang, "X:")); + // Pages don't move within their spread yet. + ui.add_enabled_ui(false, |ui| measure(ui, "cpx", Some(x), units, 64.0)); + caption(ui, crate::i18n::tr(&lang, "W:")); + if let Some(v) = measure(ui, "cpw", Some(pg.width), units, 64.0) { + let _ = app.run("layout.pageSize", json!({"pages": [n], "width": v, "anchor": anchor})); + } + ui.end_row(); + caption(ui, crate::i18n::tr(&lang, "Y:")); + ui.add_enabled_ui(false, |ui| measure(ui, "cpy", Some(y), units, 64.0)); + caption(ui, crate::i18n::tr(&lang, "H:")); + if let Some(v) = measure(ui, "cph", Some(pg.height), units, 64.0) { + let _ = app.run("layout.pageSize", json!({"pages": [n], "height": v, "anchor": anchor})); + } + ui.end_row(); + }); + vsep(ui); + // Page-size preset and orientation. + let landscape = pg.width > pg.height; + ui.vertical(|ui| { + let cur = panels::properties::preset_name(pg.width, pg.height); + egui::ComboBox::from_id_salt("ctl_page_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, cur))).width(130.0).show_ui( + ui, + |ui| { + for (name, w, h) in panels::properties::PAGE_PRESETS { + if ui.selectable_label(cur == *name, crate::rtl::widget(ui, crate::i18n::tr(&lang, name))).clicked() { + let (w, h) = if landscape { (h, w) } else { (w, h) }; + let _ = app.run("layout.pageSize", json!({"pages": [n], "width": w, "height": h, "anchor": anchor})); + } + } + }, + ); + ui.horizontal(|ui| { + for (icon, tip, want) in [("orient-portrait", "Portrait", false), ("orient-landscape", "Landscape", true)] { + if crate::widgets::icon_toggle(ui, icon, landscape == want, crate::i18n::tr(&lang, tip)).clicked() && landscape != want { + let _ = app.run("layout.pageSize", json!({"pages": [n], "width": pg.height, "height": pg.width, "anchor": anchor})); + } + } + }); + }); + vsep(ui); + ui.vertical(|ui| { + caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:")); + let label = panels::interactive::liquid_rule_label(pg.liquid); + egui::ComboBox::from_id_salt("ctl_page_rule").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).width(150.0).show_ui( + ui, + |ui| { + for (r, l) in panels::interactive::LIQUID_RULES { + if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, l))).clicked() && pg.liquid != r { + let _ = app.run("liquid.pageRule", json!({"rule": r, "pages": [n]})); + } + } + }, + ); + }); + vsep(ui); + ui.vertical(|ui| { + // Objects follow a page that moves in its spread; pages don't move yet, so it's always on. + let mut follow = true; + ui.add_enabled(false, egui::Checkbox::new(&mut follow, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Objects Move with Page")))); + let mut overlay = pg.show_parent_overlay; + if ui.checkbox(&mut overlay, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show Parent Page Overlay"))).changed() { + let _ = app.run("layout.pageOptions", json!({"pages": [n], "showParentOverlay": overlay})); + } + }); +} + /// A field label drawn as a tool icon (hover for its name). fn icon_caption(ui: &mut egui::Ui, icon: &str, tip: &str) { let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); @@ -830,6 +922,23 @@ mod tests { assert!(short.min.x - first.min.x <= 2.0 * super::DOC_TAB_MAX_W, "the last tab {short:?} starts too far right"); } + #[test] + fn the_page_tool_has_its_own_control_panel() { + // Chris Cicchino's Page Tool and Liquid Layout video (1:02): with the Page tool, the + // Control panel shows the page's size, preset, orientation and liquid page rule. + let mut app = app(); + app.run("file.new", serde_json::json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); + app.session.set_tool("page"); + let mut h = test_window::open(app, vec2(1800.0, 600.0)); + for label in ["Liquid Page Rule:", "Objects Move with Page"] { + let _ = h.get_by_label(label); + } + assert_eq!(crate::panels::properties::preset_name(252.0, 144.0), "US Business Card"); + h.get_by_label("Show Parent Page Overlay").click(); + h.run_steps(4); + assert!(h.state().app.session.doc().unwrap().doc.spreads[0].pages[0].show_parent_overlay); + } + #[test] fn a_narrow_window_scrolls_the_control_bar() { let w = 700.0; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 63c70d4a..ab6b52fc 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2501,6 +2501,35 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Object-Based", ["Object-Based", "Object-Based", "Object-Based", "Object-Based", "基于对象"]), ("Guide-Based", ["Guide-Based", "Guide-Based", "Guide-Based", "Guide-Based", "基于参考线"]), ("Resize Page", ["Seitengröße ändern", "Redimensionner la page", "Cambiar tamaño de página", "ページのサイズを変更", "调整页面大小"]), + ( + "Liquid Page Rule:", + ["Regel für flüssige Seiten:", "Règle de page liquide :", "Regla de página líquida:", "リキッドページルール:", "自适应页面规则:"], + ), + ( + "Objects Move with Page", + [ + "Objekte mit Seite verschieben", + "Déplacer les objets avec la page", + "Los objetos se mueven con la página", + "オブジェクトをページと共に移動", + "对象随页面移动", + ], + ), + ( + "Show Parent Page Overlay", + [ + "Überlagerung der übergeordneten Seite anzeigen", + "Afficher la superposition de la page parent", + "Mostrar superposición de página principal", + "親ページオーバーレイを表示", + "显示父页面叠加", + ], + ), + ( + "Controlled by Parent", + ["Durch übergeordnete Seite gesteuert", "Contrôlé par le parent", "Controlado por principal", "親ページで制御", "由父页面控制"], + ), + ("US Business Card", ["US-Visitenkarte", "Carte de visite US", "Tarjeta de visita EE. UU.", "US 名刺", "美国名片"]), ("Loop", ["Loop", "Loop", "Loop", "Loop", "循环播放"]), ("Object-based", ["Object-based", "Object-based", "Object-based", "Object-based", "基于对象"]), ("Off", ["Off", "Off", "Off", "Off", "关闭"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index e0c17c88..84b003e0 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -712,6 +712,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "استنادًا إلى الكائنات"), ("Guide-Based", "استنادًا إلى الأدلة"), ("Resize Page", "تغيير حجم الصفحة"), + ("Liquid Page Rule:", "قاعدة الصفحة المرنة:"), + ("Objects Move with Page", "تحريك الكائنات مع الصفحة"), + ("Show Parent Page Overlay", "إظهار تراكب الصفحة الأصل"), + ("Controlled by Parent", "تتحكم فيه الصفحة الأصل"), + ("US Business Card", "بطاقة عمل أمريكية"), ("Loop", "تكرار"), ("Object-based", "استنادًا إلى الكائنات"), ("Play on Page Load", "تشغيل عند تحميل الصفحة"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 3f0915aa..8195bac6 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1298,6 +1298,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "Basato su oggetti"), ("Guide-Based", "Basato su guide"), ("Resize Page", "Ridimensiona pagina"), + ("Liquid Page Rule:", "Regola pagina liquida:"), + ("Objects Move with Page", "Sposta oggetti con la pagina"), + ("Show Parent Page Overlay", "Mostra sovrapposizione pagina principale"), + ("Controlled by Parent", "Controllato dalla pagina principale"), + ("US Business Card", "Biglietto da visita USA"), ("Loop", "Ciclo continuo"), ("Object-based", "Basato su oggetti"), ("Off", "Disattivato"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 9e0f7a95..ead7f576 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1294,6 +1294,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "オブジェクト基準"), ("Guide-Based", "ガイド基準"), ("Resize Page", "ページのサイズを変更"), + ("Liquid Page Rule:", "リキッドページルール:"), + ("Objects Move with Page", "オブジェクトをページと共に移動"), + ("Show Parent Page Overlay", "親ページオーバーレイを表示"), + ("Controlled by Parent", "親ページで制御"), + ("US Business Card", "US 名刺"), ("Loop", "繰り返し再生"), ("Object-based", "オブジェクト基準"), ("Off", "オフ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index e1fbf308..52874f54 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -711,6 +711,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "Baseado em objetos"), ("Guide-Based", "Baseado em guias"), ("Resize Page", "Redimensionar página"), + ("Liquid Page Rule:", "Regra de página líquida:"), + ("Objects Move with Page", "Objetos se movem com a página"), + ("Show Parent Page Overlay", "Mostrar sobreposição da página principal"), + ("Controlled by Parent", "Controlado pela página principal"), + ("US Business Card", "Cartão de visita EUA"), ("Loop", "Repetir"), ("Object-based", "Baseado em objetos"), ("Play on Page Load", "Reproduzir ao carregar a página"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 5994dc13..68e15e62 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1295,6 +1295,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "На основі об’єктів"), ("Guide-Based", "На основі напрямних"), ("Resize Page", "Змінити розмір сторінки"), + ("Liquid Page Rule:", "Правило гнучкої сторінки:"), + ("Objects Move with Page", "Об’єкти рухаються зі сторінкою"), + ("Show Parent Page Overlay", "Показати накладення батьківської сторінки"), + ("Controlled by Parent", "Керується батьківською сторінкою"), + ("US Business Card", "Візитна картка (США)"), ("Loop", "Зациклити"), ("Object-based", "На основі об’єктів"), ("Off", "Вимкнено"), diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index 5b770aec..cd36926f 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -486,6 +486,21 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { } } +/// Liquid page rules in InDesign's order, with their menu labels. +pub const LIQUID_RULES: [(designcraft_doc::LiquidRule, &str); 6] = [ + (designcraft_doc::LiquidRule::Off, "Off"), + (designcraft_doc::LiquidRule::Scale, "Scale"), + (designcraft_doc::LiquidRule::ReCenter, "Re-center"), + (designcraft_doc::LiquidRule::ObjectBased, "Object-based"), + (designcraft_doc::LiquidRule::GuideBased, "Guide-based"), + (designcraft_doc::LiquidRule::ControlledByParent, "Controlled by Parent"), +]; + +/// A liquid page rule's menu label. +pub fn liquid_rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { + LIQUID_RULES.iter().find(|(r, _)| *r == rule).map_or("Off", |(_, l)| l) +} + /// Liquid Layout panel: the current page's rule, the selection's pins (object-based), the page's /// liquid guides, and Create Alternate Layout. pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -501,23 +516,9 @@ pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, egui::RichText::new(format!("{} {}", crate::i18n::tr(&app.ui.language, "Page"), d.page_name(page))).strong()); let mut rule = pg.liquid; egui::ComboBox::from_id_salt("liquid_rule") - .selected_text(crate::rtl::widget( - ui, - crate::i18n::tr( - &app.ui.language, - match rule { - L::Off => "Off", - L::Scale => "Scale", - L::ReCenter => "Re-center", - L::GuideBased => "Guide-based", - L::ObjectBased => "Object-based", - }, - ), - )) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, liquid_rule_label(rule)))) .show_ui(ui, |ui| { - for (r, label) in - [(L::Off, "Off"), (L::Scale, "Scale"), (L::ReCenter, "Re-center"), (L::GuideBased, "Guide-based"), (L::ObjectBased, "Object-based")] - { + for (r, label) in LIQUID_RULES { ui.selectable_value(&mut rule, r, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))); } }); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d9c74e40..b67be2d8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -160,7 +160,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // ---------------------------------------------------------------- No Selection /// Page-size presets for the Document dropdown (portrait, points). -const PAGE_PRESETS: &[(&str, f64, f64)] = &[ +pub(crate) const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("Letter", 612.0, 792.0), ("Legal", 612.0, 1008.0), ("Tabloid", 792.0, 1224.0), @@ -171,9 +171,10 @@ const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("A5", 419.528, 595.276), ("A6", 297.638, 419.528), ("B5", 498.898, 708.661), + ("US Business Card", 144.0, 252.0), ]; -fn preset_name(w: f64, h: f64) -> &'static str { +pub(crate) fn preset_name(w: f64, h: f64) -> &'static str { let (a, b) = (w.min(h), w.max(h)); PAGE_PRESETS.iter().find(|p| (p.1 - a).abs() < 0.6 && (p.2 - b).abs() < 0.6).map(|p| p.0).unwrap_or("Custom") } @@ -386,7 +387,7 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { // ---------------------------------------------------------------- Objects /// The 9-point reference-point proxy; returns a newly picked point (0..8, row-major). -fn ref_point_proxy(ui: &mut Ui, r: Rect, cur: u8) -> Option { +pub(crate) fn ref_point_proxy(ui: &mut Ui, r: Rect, cur: u8) -> Option { let t = Tokens::get(ui.ctx()); let mut out = None; let pitch = r.width() / 3.0; From 8864307d7c8567c80c8825aa4a13a13b9202ec92 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 00:52:06 +0200 Subject: [PATCH 20/32] docs: the Page tool's Control panel in the parity notes Co-Authored-By: Claude Opus 5.5 --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 84b602d5..cbcebc37 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -396,7 +396,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. - Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. -- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. +- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule including Controlled by Parent, Show Parent Page Overlay); X/Y and Objects Move with Page are shown, but pages don't move within their spread yet. - File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document). - Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. - Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. From 8f71219533f325779c678835dc8c4bca5c26f446 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 01:13:23 +0200 Subject: [PATCH 21/32] feat(tools): Page tool pins and locks objects on object-based pages, on the canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (6:32– 9:58) clicks an object with the Page tool on an object-based page and gets InDesign's on-canvas liquid controls: a pin outside each edge ("Object is pinned relative to the page edge. Click to release.") and a padlock on the width and height lines ("The object's width is locked. Click to let the object change width as the page changes width."). The pins and resize settings existed (liquid.object, the Liquid Layout panel) but the canvas showed none of it. - tools: on an object-based page a Page tool click selects the object with its page (layout.selectPage takes `ids`); its pins (14 px outside each edge's middle) and width/height locks (beside the centre) toggle liquid.object on click, show InDesign's tooltips on hover and a hand cursor. - canvas: LiquidPins (orange dashed width/height lines with padlocks, open when the object follows the page; hollow pins, or a line to the page edge when pinned) and Tip overlays; tooltip translations. Builds on the Page tool PR (#862). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/layout.rs | 21 ++++- crates/engine/src/tests.rs | 56 +++++++++++++ crates/tools/src/lib.rs | 15 ++++ crates/tools/src/nav.rs | 139 ++++++++++++++++++++++++++++++- crates/tools/src/tests.rs | 2 +- crates/ui-egui/src/canvas.rs | 52 ++++++++++++ crates/ui-egui/src/i18n.rs | 60 +++++++++++++ crates/ui-egui/src/i18n/ar.rs | 18 ++++ crates/ui-egui/src/i18n/it.rs | 18 ++++ crates/ui-egui/src/i18n/ja.rs | 18 ++++ crates/ui-egui/src/i18n/pt_br.rs | 18 ++++ crates/ui-egui/src/i18n/uk.rs | 18 ++++ docs/target-app-parity.md | 2 +- 14 files changed, 429 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d9957d5d..5c16da0c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,6 +130,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule (now with Controlled by Parent) and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. +- **2026-10-14** · On an object-based page the Page tool shows a selected object's liquid pins and resize locks as InDesign does: a click pins a side to the page edge or locks the width or height, with InDesign's tooltips. No readiness percentage change. - **2026-10-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. 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. diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 4055b080..1650d389 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -389,20 +389,35 @@ pub fn specs() -> Vec { has_doc, page_options ), - cmd!(noundo "layout.selectPage", "Select Page", [], None, "{page: 1-based | null} — the page the Page tool selects (its handles, the Control panel)", has_doc, |s, p| { + cmd!( + noundo "layout.selectPage", + "Select Page", + [], + None, + "{page: 1-based | null, ids?: [item ids] (objects selected with it: the Page tool on an object-based page)} — the page the Page tool selects (its handles, the Control panel)", + has_doc, + |s, p| { let n = s.doc()?.doc.page_count(); let page = match p.get("page") { None | Some(Value::Null) => None, Some(v) => Some(v.as_u64().filter(|v| *v >= 1 && *v as usize <= n).ok_or_else(|| bad("layout.selectPage", format!("`page`: 1–{n}")))? as usize - 1), }; + let ids: Vec = { + let d = &s.doc()?.doc; + p.get("ids") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_u64).map(designcraft_doc::ItemId).filter(|id| d.item(*id).is_some()).collect()) + .unwrap_or_default() + }; let st = s.doc_mut()?; if page.is_some() { - st.selection = Default::default(); + st.selection = designcraft_doc::Selection::items(ids); } st.selection.page = page; st.revision += 1; Ok(json!({"page": page.map(|p| p + 1)})) - }), + } + ), cmd!( "layout.section", "Numbering & Section Options…", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9557e03c..af54bb94 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -667,3 +667,59 @@ fn controlled_by_parent_takes_the_parent_pages_rule() { assert!(s.doc().unwrap().doc.spreads[0].pages[0].show_parent_overlay); assert!(s.execute("layout.pageOptions", &json!({"pages": [2], "showParentOverlay": true})).is_err()); } + +#[test] +fn page_tool_pins_and_locks_objects_on_object_based_pages() { + use designcraft_tools::{CanvasLayout, Mods, PointerEvent, PointerKind}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [256, 72, 356, 132]})).unwrap()["id"].as_u64().unwrap(); + s.execute("liquid.pageRule", &json!({"rule": "objectBased"})).unwrap(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + s.set_tool("page"); + let v = ViewInfo::at_zoom(1.0); + let ev = + |kind, p: designcraft_geom::Point, alt: bool| PointerEvent { mods: Mods { alt, ..Default::default() }, ..PointerEvent::new(kind, p.x, p.y) }; + let canvas = |s: &Session| { + let d = &s.doc().unwrap().doc; + let l = CanvasLayout::new(d, false); + ( + l.page_rect(d, 0).unwrap(), + l.xf(designcraft_doc::SpreadRef::Doc(0)).transform_rect_bbox(d.item(designcraft_doc::ItemId(id)).unwrap().bounds()), + ) + }; + let click = |s: &mut Session, p: designcraft_geom::Point| { + s.pointer(&ev(PointerKind::Down, p, false), v).unwrap(); + s.pointer(&ev(PointerKind::Up, p, false), v).unwrap(); + }; + let liquid = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().liquid; + // A click on the object selects it with its page; its pins and locks show. + let (_, o) = canvas(&s); + click(&mut s, o.center()); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!((sel.page, sel.items.clone()), (Some(0), vec![designcraft_doc::ItemId(id)])); + assert!( + s.overlays(v) + .iter() + .any(|o| matches!(o, designcraft_tools::Overlay::LiquidPins { pins: [false, false, false, false], resize: [true, true], .. })) + ); + // The right pin (14 px outside the right edge) and the width lock (10 px right of the centre). + click(&mut s, designcraft_geom::Point::new(o.x1 + 14.0, o.center().y)); + click(&mut s, designcraft_geom::Point::new(o.center().x + 10.0, o.center().y)); + let l = liquid(&s).unwrap(); + assert!(l.pin_right && !l.resize_width && l.resize_height && !l.pin_left, "{l:?}"); + // Alt-drag the page 100 pt wider: the pinned, width-locked frame keeps its size by the right edge. + let (p, _) = canvas(&s); + let right = designcraft_geom::Point::new(p.x1, p.center().y); + s.pointer(&ev(PointerKind::Down, right, true), v).unwrap(); + s.pointer(&ev(PointerKind::Drag, right + designcraft_geom::Vec2::new(100.0, 0.0), true), v).unwrap(); + s.pointer(&ev(PointerKind::Up, right + designcraft_geom::Vec2::new(100.0, 0.0), true), v).unwrap(); + let d = &s.doc().unwrap().doc; + let (pg, b) = (&d.spreads[0].pages[0], d.item(designcraft_doc::ItemId(id)).unwrap().bounds()); + assert_eq!(pg.width, 712.0); + assert!((b.width() - 100.0).abs() < 1e-6 && (pg.x + pg.width - b.x1 - 256.0).abs() < 1e-6, "{b:?}"); + // Hovering a pin shows its tooltip. + let (_, o) = canvas(&s); + s.pointer(&ev(PointerKind::Move, designcraft_geom::Point::new(o.x1 + 14.0, o.center().y), false), v).unwrap(); + assert!(s.overlays(v).iter().any(|o| matches!(o, designcraft_tools::Overlay::Tip { text, .. } if text.starts_with("Object is pinned")))); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index dd09e9c2..db983fab 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -15,6 +15,7 @@ mod frame; mod gradient; pub mod layout; mod nav; +pub use nav::{LOCK_OFFSET_PX, PIN_OFFSET_PX}; mod pen; mod pencil; pub mod select; @@ -322,6 +323,20 @@ pub enum Overlay { p: Point, text: String, }, + /// A tooltip near the pointer (English; the UI translates it). + Tip { + p: Point, + text: String, + }, + /// An object's liquid pins and resize locks on an object-based page (Page tool), canvas + /// coordinates: pinned sides (top, right, bottom, left) and whether width and height follow the + /// page. + LiquidPins { + object: Rect, + page: Rect, + pins: [bool; 4], + resize: [bool; 2], + }, } /// Correction from a snap, in spread coordinates for `delta`. diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index 54db73d0..bd53e4ca 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -168,6 +168,75 @@ impl Tool for PlaceGun { #[derive(Default)] pub struct PageTool { drag: Option, + /// The pin or lock under the pointer (for its tooltip). + hover: Option<(LiquidControl, Point)>, +} + +/// An object's liquid control on an object-based page: a side's pin (top, right, bottom, left) or +/// the width/height lock. +#[derive(Clone, Copy, Debug, PartialEq)] +enum LiquidControl { + Pin(usize), + Width, + Height, +} + +impl LiquidControl { + /// The `liquid.object` parameter it toggles. + fn key(self) -> &'static str { + match self { + LiquidControl::Pin(0) => "pinTop", + LiquidControl::Pin(1) => "pinRight", + LiquidControl::Pin(2) => "pinBottom", + LiquidControl::Pin(_) => "pinLeft", + LiquidControl::Width => "resizeWidth", + LiquidControl::Height => "resizeHeight", + } + } + + /// InDesign's tooltip for the control in its current state. + fn tip(self, l: &designcraft_doc::ObjectLiquid) -> &'static str { + match self { + LiquidControl::Pin(i) if [l.pin_top, l.pin_right, l.pin_bottom, l.pin_left][i.min(3)] => { + "Object is pinned relative to the page edge. Click to release." + } + LiquidControl::Pin(_) => "Click to pin the object relative to the page edge.", + LiquidControl::Width if l.resize_width => "The object changes width as the page changes width. Click to lock the object width.", + LiquidControl::Width => "The object's width is locked. Click to let the object change width as the page changes width.", + LiquidControl::Height if l.resize_height => "The object changes height as the page changes height. Click to lock the object height.", + LiquidControl::Height => "The object's height is locked. Click to let the object change height as the page changes height.", + } + } + + fn value(self, l: &designcraft_doc::ObjectLiquid) -> bool { + match self { + LiquidControl::Pin(0) => l.pin_top, + LiquidControl::Pin(1) => l.pin_right, + LiquidControl::Pin(2) => l.pin_bottom, + LiquidControl::Pin(_) => l.pin_left, + LiquidControl::Width => l.resize_width, + LiquidControl::Height => l.resize_height, + } + } +} + +/// Screen pixels between an object's edge and its pin (the canvas draws them there too). +pub const PIN_OFFSET_PX: f64 = 14.0; +/// Screen pixels between an object's centre and its width/height locks. +pub const LOCK_OFFSET_PX: f64 = 10.0; + +/// Where an object's liquid controls sit (canvas): pins outside each edge's middle, the width lock +/// right of the centre, the height lock above it. +fn liquid_control_points(cx: &ToolContext, r: designcraft_geom::Rect) -> [(LiquidControl, Point); 6] { + let (c, o, k) = (r.center(), cx.tol(PIN_OFFSET_PX), cx.tol(LOCK_OFFSET_PX)); + [ + (LiquidControl::Pin(0), Point::new(c.x, r.y0 - o)), + (LiquidControl::Pin(1), Point::new(r.x1 + o, c.y)), + (LiquidControl::Pin(2), Point::new(c.x, r.y1 + o)), + (LiquidControl::Pin(3), Point::new(r.x0 - o, c.y)), + (LiquidControl::Width, Point::new(c.x + k, c.y)), + (LiquidControl::Height, Point::new(c.x, c.y - k)), + ] } /// A page handle being dragged. @@ -202,7 +271,43 @@ pub fn rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { } } +/// Whether page `abs` follows the object-based rule (its own, or its parent page's). +fn object_based(doc: &designcraft_doc::Document, abs: usize) -> bool { + use designcraft_doc::LiquidRule as R; + let Some((si, pi)) = doc.page_loc(abs) else { return false }; + match doc.spreads[si].pages[pi].liquid { + R::ObjectBased => true, + R::ControlledByParent => { + doc.parent_page_for(abs).and_then(|(ppi, ppg)| doc.parents.get(ppi)?.pages.get(ppg).map(|p| p.liquid == R::ObjectBased)).unwrap_or(false) + } + _ => false, + } +} + impl PageTool { + /// The selected object's liquid settings, canvas bounds and page bounds when the Page tool + /// shows its pins: one object selected on the selected page, whose rule is object-based. + fn liquid_target( + cx: &ToolContext, + ) -> Option<(designcraft_doc::ItemId, designcraft_doc::ObjectLiquid, designcraft_geom::Rect, designcraft_geom::Rect)> { + let page = cx.selection.page?; + let [id] = cx.selection.items.as_slice() else { return None }; + let (si, _) = cx.doc.page_loc(page)?; + if !object_based(cx.doc, page) || cx.doc.page_of_item(*id) != Some(page) { + return None; + } + let it = cx.doc.item(*id)?; + let l = it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() }); + let r = cx.layout.xf(designcraft_doc::SpreadRef::Doc(si)).transform_rect_bbox(it.bounds()); + Some((*id, l, r, cx.layout.page_rect(cx.doc, page)?)) + } + + /// The selected object's pin or lock under `p`. + fn control_at(cx: &ToolContext, p: Point) -> Option<(designcraft_doc::ItemId, designcraft_doc::ObjectLiquid, LiquidControl)> { + let (id, l, r, _) = Self::liquid_target(cx)?; + liquid_control_points(cx, r).into_iter().find(|(_, q)| (q.x - p.x).hypot(q.y - p.y) <= cx.tol(6.0)).map(|(c, _)| (id, l, c)) + } + /// The selected page's handle under `p`. fn handle_at(&self, cx: &ToolContext, p: Point) -> Option<(usize, usize)> { let page = cx.selection.page?; @@ -255,12 +360,21 @@ impl Tool for PageTool { self.drag = Some(PageDrag { page, handle, start: ev.pos, cur: ev.pos, size: (pg.width, pg.height), alt: ev.mods.alt }); return vec![Action::Begin("Resize Page".into())]; } - // A click selects the page under the pointer (none on the pasteboard). + // An object-based page's object: a click on a pin or lock toggles it. + if let Some((id, l, c)) = Self::control_at(cx, ev.pos) { + return vec![Action::Exec("liquid.object".into(), json!({"ids": [id.0], c.key(): !c.value(&l)}))]; + } + // A click selects the page under the pointer (none on the pasteboard), and on an + // object-based page the object clicked too. let page = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))); - if page == cx.selection.page { + let object = page + .filter(|p| object_based(cx.doc, *p)) + .and_then(|p| cx.hit(ev.pos).map(|(_, id)| id).filter(|id| cx.doc.page_of_item(*id) == Some(p))); + let ids: Vec = object.map(|id| id.0).into_iter().collect(); + if page == cx.selection.page && cx.selection.items.iter().map(|i| i.0).eq(ids.iter().copied()) { return vec![]; } - vec![Action::Exec("layout.selectPage".into(), json!({"page": page.map(|p| p + 1)}))] + vec![Action::Exec("layout.selectPage".into(), json!({"page": page.map(|p| p + 1), "ids": ids}))] } PointerKind::Drag => { let Some(d) = self.drag.as_mut() else { return vec![] }; @@ -285,7 +399,10 @@ impl Tool for PageTool { let pg = &cx.doc.spreads[si].pages[pi]; vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [page + 1], "width": pg.width, "height": pg.height}))] } - PointerKind::Move => vec![], + PointerKind::Move => { + self.hover = Self::control_at(cx, ev.pos).map(|(_, _, c)| (c, ev.pos)); + vec![] + } } } fn overlays(&self, cx: &ToolContext) -> Vec { @@ -294,6 +411,17 @@ impl Tool for PageTool { let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; let pg = &cx.doc.spreads[si].pages[pi]; let mut v = vec![Overlay::PageHandles(r), Overlay::Tag { p: Point::new(r.x0, r.y0 - cx.tol(6.0)), text: rule_label(pg.liquid).into() }]; + if let Some((_, l, object, page)) = Self::liquid_target(cx) { + v.push(Overlay::LiquidPins { + object, + page, + pins: [l.pin_top, l.pin_right, l.pin_bottom, l.pin_left], + resize: [l.resize_width, l.resize_height], + }); + if let Some((c, p)) = self.hover { + v.push(Overlay::Tip { p, text: c.tip(&l).into() }); + } + } if let Some(d) = self.drag { v.push(Overlay::Measure { p: d.cur, @@ -307,6 +435,9 @@ impl Tool for PageTool { v } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if self.drag.is_none() && Self::control_at(cx, p).is_some() { + return Cursor::Hand; + } let handle = self.drag.map(|d| d.handle).or_else(|| self.handle_at(cx, p).map(|(_, h)| h)); match handle { Some(1 | 7) => Cursor::ResizeV, diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index b2e59724..81d67c30 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -235,7 +235,7 @@ fn page_tool_selects_a_page_and_opens_page_size_on_double_click() { let (x, y) = (200.0 + off.x + d.spreads[0].pages[0].x, 200.0 + off.y); let mut t = create("page"); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)); - assert_eq!(a, vec![Action::Exec("layout.selectPage".into(), serde_json::json!({"page": 1}))]); + assert_eq!(a, vec![Action::Exec("layout.selectPage".into(), serde_json::json!({"page": 1, "ids": []}))]); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, x, y)); assert_eq!(a, vec![Action::Dialog("cmd:layout.pageSize".into(), serde_json::json!({"pages": [1], "width": 612.0, "height": 792.0}))]); // The selected page shows its handles and its liquid page rule. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 003549a4..f20e6402 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1268,11 +1268,63 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { painter.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_rgb(79, 153, 255)), StrokeKind::Inside); painter.galley(r.min + vec2(4.0, 2.0), g, tok.measure_text); } + Overlay::Tip { p, text } => { + let text = crate::i18n::tr(&app.ui.language, &text).to_owned(); + let s = xf.to_screen(p) + vec2(14.0, 14.0); + let g = painter.layout(text, egui::FontId::proportional(11.0), tok.measure_text, 260.0); + let r = Rect::from_min_size(s, g.size() + vec2(10.0, 6.0)); + painter.rect_filled(r, 3.0, tok.measure_bg); + painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); + } + Overlay::LiquidPins { object, page, pins, resize } => draw_liquid_pins(painter, xf.rect(object), xf.rect(page), pins, resize), Overlay::Path { .. } => {} } } } +/// An object's liquid controls on an object-based page, as InDesign draws them in orange: a +/// dashed line across the object for width and height with a padlock (closed: locked), and a pin +/// outside each edge (pinned: a line to the page edge). +fn draw_liquid_pins(painter: &egui::Painter, o: Rect, page: Rect, pins: [bool; 4], resize: [bool; 2]) { + let col = Color32::from_rgb(178, 88, 18); + let st = Stroke::new(1.5, col); + let c = o.center(); + let mids = [pos2(c.x, o.min.y), pos2(o.max.x, c.y), pos2(c.x, o.max.y), pos2(o.min.x, c.y)]; + // The width and height lines between the edges' middles. + for (a, b) in [(mids[3], mids[1]), (mids[0], mids[2])] { + dashed(painter, a, b, Stroke::new(1.0, col), 3.0, 3.0); + } + for m in mids { + painter.circle_filled(m, 3.5, col); + } + let (po, lo) = (designcraft_tools::PIN_OFFSET_PX as f32, designcraft_tools::LOCK_OFFSET_PX as f32); + let outward = [vec2(0.0, -1.0), vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(-1.0, 0.0)]; + let edge = [pos2(c.x, page.min.y), pos2(page.max.x, c.y), pos2(c.x, page.max.y), pos2(page.min.x, c.y)]; + for i in 0..4 { + if pins[i] { + painter.line_segment([mids[i], edge[i]], st); + painter.circle_filled(edge[i], 4.0, col); + } else { + painter.circle_stroke(mids[i] + outward[i] * po, 4.0, st); + } + } + // Padlocks: the width lock right of the centre, the height lock above it. + for (at, locked) in [(c + vec2(lo, 0.0), !resize[0]), (c + vec2(0.0, -lo), !resize[1])] { + let body = Rect::from_center_size(at + vec2(0.0, 2.0), vec2(8.0, 6.0)); + painter.rect_filled(body.expand(1.0), 1.0, Color32::WHITE); + painter.rect_filled(body, 1.0, col); + // The shackle: over the body when locked, swung open when not. + let base = if locked { body.center().x } else { body.center().x + 3.0 }; + let pts: Vec<_> = (0..=8) + .map(|k| { + let a = std::f32::consts::PI * k as f32 / 8.0; + pos2(base - 2.6 * a.cos(), body.min.y - 3.0 * a.sin()) + }) + .collect(); + painter.add(egui::Shape::line(pts, Stroke::new(1.3, col))); + } +} + #[allow(clippy::too_many_arguments)] fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) { let painter = ui.painter_at(full); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ab6b52fc..5fd1d5fd 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2530,6 +2530,66 @@ const TABLE: &[(&str, [&str; 5])] = &[ ["Durch übergeordnete Seite gesteuert", "Contrôlé par le parent", "Controlado por principal", "親ページで制御", "由父页面控制"], ), ("US Business Card", ["US-Visitenkarte", "Carte de visite US", "Tarjeta de visita EE. UU.", "US 名刺", "美国名片"]), + ( + "Object is pinned relative to the page edge. Click to release.", + [ + "Das Objekt ist relativ zum Seitenrand fixiert. Zum Lösen klicken.", + "L’objet est épinglé par rapport au bord de la page. Cliquez pour le libérer.", + "El objeto está fijado respecto al borde de la página. Haga clic para soltarlo.", + "オブジェクトはページの端を基準に固定されています。クリックすると解除します。", + "对象已相对于页面边缘固定。单击可释放。", + ], + ), + ( + "Click to pin the object relative to the page edge.", + [ + "Klicken, um das Objekt relativ zum Seitenrand zu fixieren.", + "Cliquez pour épingler l’objet par rapport au bord de la page.", + "Haga clic para fijar el objeto respecto al borde de la página.", + "クリックすると、オブジェクトをページの端を基準に固定します。", + "单击可将对象相对于页面边缘固定。", + ], + ), + ( + "The object changes width as the page changes width. Click to lock the object width.", + [ + "Die Objektbreite ändert sich mit der Seitenbreite. Zum Sperren der Breite klicken.", + "La largeur de l’objet suit celle de la page. Cliquez pour verrouiller la largeur.", + "El ancho del objeto cambia con el de la página. Haga clic para bloquear el ancho.", + "オブジェクトの幅はページの幅に合わせて変わります。クリックすると幅をロックします。", + "对象宽度随页面宽度变化。单击可锁定对象宽度。", + ], + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + [ + "Die Objektbreite ist gesperrt. Klicken, damit sie sich mit der Seitenbreite ändert.", + "La largeur de l’objet est verrouillée. Cliquez pour qu’elle suive celle de la page.", + "El ancho del objeto está bloqueado. Haga clic para que cambie con el de la página.", + "オブジェクトの幅はロックされています。クリックすると、ページの幅に合わせて変わります。", + "对象宽度已锁定。单击可让其随页面宽度变化。", + ], + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + [ + "Die Objekthöhe ändert sich mit der Seitenhöhe. Zum Sperren der Höhe klicken.", + "La hauteur de l’objet suit celle de la page. Cliquez pour verrouiller la hauteur.", + "El alto del objeto cambia con el de la página. Haga clic para bloquear el alto.", + "オブジェクトの高さはページの高さに合わせて変わります。クリックすると高さをロックします。", + "对象高度随页面高度变化。单击可锁定对象高度。", + ], + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + [ + "Die Objekthöhe ist gesperrt. Klicken, damit sie sich mit der Seitenhöhe ändert.", + "La hauteur de l’objet est verrouillée. Cliquez pour qu’elle suive celle de la page.", + "El alto del objeto está bloqueado. Haga clic para que cambie con el de la página.", + "オブジェクトの高さはロックされています。クリックすると、ページの高さに合わせて変わります。", + "对象高度已锁定。单击可让其随页面高度变化。", + ], + ), ("Loop", ["Loop", "Loop", "Loop", "Loop", "循环播放"]), ("Object-based", ["Object-based", "Object-based", "Object-based", "Object-based", "基于对象"]), ("Off", ["Off", "Off", "Off", "Off", "关闭"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 84b003e0..b94d63e7 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -717,6 +717,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Parent Page Overlay", "إظهار تراكب الصفحة الأصل"), ("Controlled by Parent", "تتحكم فيه الصفحة الأصل"), ("US Business Card", "بطاقة عمل أمريكية"), + ("Object is pinned relative to the page edge. Click to release.", "الكائن مثبت بالنسبة إلى حافة الصفحة. انقر لتحريره."), + ("Click to pin the object relative to the page edge.", "انقر لتثبيت الكائن بالنسبة إلى حافة الصفحة."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "يتغير عرض الكائن مع تغير عرض الصفحة. انقر لقفل عرض الكائن.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "عرض الكائن مقفل. انقر للسماح بتغيره مع عرض الصفحة.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "يتغير ارتفاع الكائن مع تغير ارتفاع الصفحة. انقر لقفل ارتفاع الكائن.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "ارتفاع الكائن مقفل. انقر للسماح بتغيره مع ارتفاع الصفحة.", + ), ("Loop", "تكرار"), ("Object-based", "استنادًا إلى الكائنات"), ("Play on Page Load", "تشغيل عند تحميل الصفحة"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 8195bac6..eb93f1a9 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1303,6 +1303,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Parent Page Overlay", "Mostra sovrapposizione pagina principale"), ("Controlled by Parent", "Controllato dalla pagina principale"), ("US Business Card", "Biglietto da visita USA"), + ("Object is pinned relative to the page edge. Click to release.", "L’oggetto è fissato rispetto al bordo della pagina. Fai clic per sbloccarlo."), + ("Click to pin the object relative to the page edge.", "Fai clic per fissare l’oggetto rispetto al bordo della pagina."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "La larghezza dell’oggetto cambia con quella della pagina. Fai clic per bloccarla.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "La larghezza dell’oggetto è bloccata. Fai clic perché cambi con quella della pagina.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "L’altezza dell’oggetto cambia con quella della pagina. Fai clic per bloccarla.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "L’altezza dell’oggetto è bloccata. Fai clic perché cambi con quella della pagina.", + ), ("Loop", "Ciclo continuo"), ("Object-based", "Basato su oggetti"), ("Off", "Disattivato"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index ead7f576..6d805ec3 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1299,6 +1299,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Parent Page Overlay", "親ページオーバーレイを表示"), ("Controlled by Parent", "親ページで制御"), ("US Business Card", "US 名刺"), + ("Object is pinned relative to the page edge. Click to release.", "オブジェクトはページの端を基準に固定されています。クリックすると解除します。"), + ("Click to pin the object relative to the page edge.", "クリックすると、オブジェクトをページの端を基準に固定します。"), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "オブジェクトの幅はページの幅に合わせて変わります。クリックすると幅をロックします。", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "オブジェクトの幅はロックされています。クリックすると、ページの幅に合わせて変わります。", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "オブジェクトの高さはページの高さに合わせて変わります。クリックすると高さをロックします。", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "オブジェクトの高さはロックされています。クリックすると、ページの高さに合わせて変わります。", + ), ("Loop", "繰り返し再生"), ("Object-based", "オブジェクト基準"), ("Off", "オフ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 52874f54..5b61cc42 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -716,6 +716,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Parent Page Overlay", "Mostrar sobreposição da página principal"), ("Controlled by Parent", "Controlado pela página principal"), ("US Business Card", "Cartão de visita EUA"), + ("Object is pinned relative to the page edge. Click to release.", "O objeto está fixado em relação à borda da página. Clique para liberar."), + ("Click to pin the object relative to the page edge.", "Clique para fixar o objeto em relação à borda da página."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "A largura do objeto muda com a da página. Clique para bloquear a largura.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "A largura do objeto está bloqueada. Clique para que mude com a da página.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "A altura do objeto muda com a da página. Clique para bloquear a altura.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "A altura do objeto está bloqueada. Clique para que mude com a da página.", + ), ("Loop", "Repetir"), ("Object-based", "Baseado em objetos"), ("Play on Page Load", "Reproduzir ao carregar a página"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 68e15e62..85ab2029 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1300,6 +1300,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Parent Page Overlay", "Показати накладення батьківської сторінки"), ("Controlled by Parent", "Керується батьківською сторінкою"), ("US Business Card", "Візитна картка (США)"), + ("Object is pinned relative to the page edge. Click to release.", "Об’єкт закріплено відносно краю сторінки. Клацніть, щоб звільнити."), + ("Click to pin the object relative to the page edge.", "Клацніть, щоб закріпити об’єкт відносно краю сторінки."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "Ширина об’єкта змінюється разом зі сторінкою. Клацніть, щоб заблокувати ширину.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "Ширину об’єкта заблоковано. Клацніть, щоб вона змінювалася разом зі сторінкою.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "Висота об’єкта змінюється разом зі сторінкою. Клацніть, щоб заблокувати висоту.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "Висоту об’єкта заблоковано. Клацніть, щоб вона змінювалася разом зі сторінкою.", + ), ("Loop", "Зациклити"), ("Object-based", "На основі об’єктів"), ("Off", "Вимкнено"), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cbcebc37..816f5940 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -396,7 +396,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. - Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. -- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule including Controlled by Parent, Show Parent Page Overlay); X/Y and Objects Move with Page are shown, but pages don't move within their spread yet. +- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule including Controlled by Parent, Show Parent Page Overlay); X/Y and Objects Move with Page are shown, but pages don't move within their spread yet. On an object-based page it selects objects and shows their pins and width/height locks on the canvas (click to toggle, with tooltips). - File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document). - Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. - Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. From 9da44a042ff0d4d3eff1a5c5bb7a8dd234995960 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 01:29:44 +0200 Subject: [PATCH 22/32] feat(ui): Liquid Layout panel in InDesign's layout; Create Alternate Layout in the Layout menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (2:47, 7:24) uses the Liquid Layout panel: "Liquid Page Rule:" with "Page: n", Content › Auto-Fit, and Object Constraints › Resize with Page (Height, Width) and Pin (Top, Left, Bottom, Right), greyed until an object on an object-based page is selected. DesignCraft's panel had a "Page 1" label, the rule, six wrapped checkboxes ("Resize width", "Pin top"…) only once an object was selected, and a Create Alternate Layout form. - Liquid Layout panel: InDesign's layout and labels; the page is the one the Page tool selected (else the page in view); Auto-Fit (graphic frames) runs object.fittingOptions; the constraints apply on object-based pages (also through Controlled by Parent) and stay greyed otherwise. The page's liquid guides stay listed below for now. - Layout menu: Create Alternate Layout… (as in InDesign). - Translations for the new labels. Builds on #864 (rule list, Controlled by Parent) and #862. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 8 ++ crates/ui-egui/src/i18n/ar.rs | 5 + crates/ui-egui/src/i18n/it.rs | 5 + crates/ui-egui/src/i18n/ja.rs | 5 + crates/ui-egui/src/i18n/pt_br.rs | 5 + crates/ui-egui/src/i18n/uk.rs | 5 + crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/panels/interactive.rs | 170 +++++++++++++++++------ 9 files changed, 160 insertions(+), 45 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d9957d5d..522ea8da 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-14** · The Liquid Layout panel follows InDesign's: Liquid Page Rule with the page number, Content › Auto-Fit, and Object Constraints › Resize with Page and Pin for the selected object on an object-based page; Create Alternate Layout moved to the Layout menu. No readiness percentage change. - **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule (now with Controlled by Parent) and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. - **2026-10-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. 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. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ab6b52fc..4d49c315 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2498,6 +2498,14 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Added", ["Added", "Added", "Added", "Added", "已添加"]), ("Deleted", ["Deleted", "Deleted", "Deleted", "Deleted", "已删除"]), ("Guide-based", ["Guide-based", "Guide-based", "Guide-based", "Guide-based", "基于参考线"]), + ("Page:", ["Seite:", "Page :", "Página:", "ページ:", "页面:"]), + ("Content:", ["Inhalt:", "Contenu :", "Contenido:", "コンテンツ:", "内容:"]), + ("Object Constraints:", ["Objektbeschränkungen:", "Contraintes d’objet :", "Restricciones de objeto:", "オブジェクトの制約:", "对象约束:"]), + ( + "Resize with Page:", + ["Mit Seite skalieren:", "Redimensionner avec la page :", "Cambiar tamaño con la página:", "ページと共にサイズ変更:", "随页面调整大小:"], + ), + ("Pin:", ["Fixieren:", "Épingler :", "Fijar:", "固定:", "固定:"]), ("Object-Based", ["Object-Based", "Object-Based", "Object-Based", "Object-Based", "基于对象"]), ("Guide-Based", ["Guide-Based", "Guide-Based", "Guide-Based", "Guide-Based", "基于参考线"]), ("Resize Page", ["Seitengröße ändern", "Redimensionner la page", "Cambiar tamaño de página", "ページのサイズを変更", "调整页面大小"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 84b003e0..9231209e 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -709,6 +709,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "مضاف"), ("Deleted", "محذوف"), ("Guide-based", "استنادًا إلى الأدلة"), + ("Page:", "الصفحة:"), + ("Content:", "المحتوى:"), + ("Object Constraints:", "قيود الكائن:"), + ("Resize with Page:", "تغيير الحجم مع الصفحة:"), + ("Pin:", "تثبيت:"), ("Object-Based", "استنادًا إلى الكائنات"), ("Guide-Based", "استنادًا إلى الأدلة"), ("Resize Page", "تغيير حجم الصفحة"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 8195bac6..591b4904 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1295,6 +1295,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Aggiunto"), ("Deleted", "Eliminato"), ("Guide-based", "Basato su guide"), + ("Page:", "Pagina:"), + ("Content:", "Contenuto:"), + ("Object Constraints:", "Vincoli oggetto:"), + ("Resize with Page:", "Ridimensiona con la pagina:"), + ("Pin:", "Fissa:"), ("Object-Based", "Basato su oggetti"), ("Guide-Based", "Basato su guide"), ("Resize Page", "Ridimensiona pagina"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index ead7f576..c125db3f 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1291,6 +1291,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "追加済み"), ("Deleted", "削除済み"), ("Guide-based", "ガイド基準"), + ("Page:", "ページ:"), + ("Content:", "コンテンツ:"), + ("Object Constraints:", "オブジェクトの制約:"), + ("Resize with Page:", "ページと共にサイズ変更:"), + ("Pin:", "固定:"), ("Object-Based", "オブジェクト基準"), ("Guide-Based", "ガイド基準"), ("Resize Page", "ページのサイズを変更"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 52874f54..0f5d3232 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -708,6 +708,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Adicionado"), ("Deleted", "Excluído"), ("Guide-based", "Baseado em guias"), + ("Page:", "Página:"), + ("Content:", "Conteúdo:"), + ("Object Constraints:", "Restrições de objeto:"), + ("Resize with Page:", "Redimensionar com a página:"), + ("Pin:", "Fixar:"), ("Object-Based", "Baseado em objetos"), ("Guide-Based", "Baseado em guias"), ("Resize Page", "Redimensionar página"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 68e15e62..c7e73bd4 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1292,6 +1292,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Додано"), ("Deleted", "Видалено"), ("Guide-based", "На основі напрямних"), + ("Page:", "Сторінка:"), + ("Content:", "Вміст:"), + ("Object Constraints:", "Обмеження об’єкта:"), + ("Resize with Page:", "Змінювати розмір зі сторінкою:"), + ("Pin:", "Закріпити:"), ("Object-Based", "На основі об’єктів"), ("Guide-Based", "На основі напрямних"), ("Resize Page", "Змінити розмір сторінки"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81d..26715dcd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -308,6 +308,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", "cmd:layout.marginsAndColumns", "cmd:layout.createGuides", + "cmd:layout.createAlternate", "-", "ui:view.goToPage", "-", diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index cd36926f..0968c901 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -501,50 +501,104 @@ pub fn liquid_rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { LIQUID_RULES.iter().find(|(r, _)| *r == rule).map_or("Off", |(_, l)| l) } -/// Liquid Layout panel: the current page's rule, the selection's pins (object-based), the page's -/// liquid guides, and Create Alternate Layout. +/// Whether page `abs` follows the object-based rule (its own, or its parent page's). +fn object_based(d: &designcraft_doc::Document, abs: usize) -> bool { + use designcraft_doc::LiquidRule as L; + let Some((si, pi)) = d.page_loc(abs) else { return false }; + match d.spreads[si].pages[pi].liquid { + L::ObjectBased => true, + L::ControlledByParent => { + d.parent_page_for(abs).and_then(|(ppi, ppg)| d.parents.get(ppi)?.pages.get(ppg).map(|p| p.liquid == L::ObjectBased)).unwrap_or(false) + } + _ => false, + } +} + +/// Liquid Layout panel, laid out as InDesign's: the page's Liquid Page Rule (with "Page: n"), +/// Content › Auto-Fit, and Object Constraints › Resize with Page (Height, Width) and Pin (Top, +/// Left, Bottom, Right) for the selected object on an object-based page. The page's liquid +/// guides follow (Create Alternate Layout is in the Layout menu). pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let Some(page) = crate::canvas::current_page(app) else { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "No document.")).size(11.0).color(t.text_dim)); + let lang = app.ui.language.clone(); + // The Page tool's page, the page in view, or (before the canvas lays out) the first. + let Some(page) = app.session.active().map(|s| s.selection.page.or_else(|| crate::canvas::current_page(app)).unwrap_or(0)) else { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "No document.")).size(11.0).color(t.text_dim)); return; }; let Some(d) = app.session.active().map(|d| d.doc.clone()) else { return }; let Some((si, pi)) = d.page_loc(page) else { return }; let pg = d.spreads[si].pages[pi].clone(); - use designcraft_doc::LiquidRule as L; - crate::rtl::label(ui, egui::RichText::new(format!("{} {}", crate::i18n::tr(&app.ui.language, "Page"), d.page_name(page))).strong()); - let mut rule = pg.liquid; + ui.horizontal(|ui| { + crate::widgets::caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + crate::widgets::caption(ui, &format!("{} {}", crate::i18n::tr(&lang, "Page:"), d.page_name(page))); + }); + }); egui::ComboBox::from_id_salt("liquid_rule") - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, liquid_rule_label(rule)))) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, liquid_rule_label(pg.liquid)))) + .width(190.0) .show_ui(ui, |ui| { for (r, label) in LIQUID_RULES { - ui.selectable_value(&mut rule, r, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))); - } - }); - if rule != pg.liquid { - let _ = app.run("liquid.pageRule", json!({"rule": rule, "pages": [page + 1]})); - } - if rule == L::ObjectBased - && let Some(id) = app.session.active().and_then(|s| s.selection.items.first().copied()) - && let Some(it) = d.item(id) - { - ui.separator(); - let l = it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() }); - let mut v = [l.resize_width, l.resize_height, l.pin_top, l.pin_bottom, l.pin_left, l.pin_right]; - let keys = ["resizeWidth", "resizeHeight", "pinTop", "pinBottom", "pinLeft", "pinRight"]; - let labels = ["Resize width", "Resize height", "Pin top", "Pin bottom", "Pin left", "Pin right"]; - ui.horizontal_wrapped(|ui| { - for ((on, key), label) in v.iter_mut().zip(keys).zip(labels) { - if ui.checkbox(on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() { - let _ = app.run("liquid.object", json!({key: *on})); + if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).clicked() && r != pg.liquid { + let _ = app.run("liquid.pageRule", json!({"rule": r, "pages": [page + 1]})); } } }); - } + ui.separator(); + // The selected object (one on this page). + let item = app + .session + .active() + .and_then(|s| s.selection.items.first().copied()) + .and_then(|id| d.item(id)) + .filter(|it| d.page_of_item(it.id) == Some(page)); + // Content: Auto-Fit (graphic frames). + crate::widgets::caption(ui, crate::i18n::tr(&lang, "Content:")); + let graphic = item.and_then(|it| if let designcraft_doc::Content::Graphic(g) = &it.content { Some(g.auto_fit) } else { None }); + let mut auto = graphic.is_some_and(|f| f != designcraft_doc::Fitting::None); + ui.indent("liquid_content", |ui| { + let r = ui.add_enabled(graphic.is_some(), egui::Checkbox::new(&mut auto, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Auto-Fit")))); + if r.changed() { + let _ = app.run("object.fittingOptions", json!({"autoFit": auto})); + } + }); + // Object Constraints: for an object on an object-based page. + crate::widgets::caption(ui, crate::i18n::tr(&lang, "Object Constraints:")); + let on = item.is_some() && object_based(&d, page); + let l = item + .filter(|_| on) + .map(|it| it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() })) + .unwrap_or_default(); + ui.indent("liquid_constraints", |ui| { + ui.add_enabled_ui(on, |ui| { + let rows: [(&str, [(&str, &str, bool); 2]); 2] = [ + ("Resize with Page:", [("Height", "resizeHeight", l.resize_height), ("Width", "resizeWidth", l.resize_width)]), + ("Pin:", [("Top", "pinTop", l.pin_top), ("Left", "pinLeft", l.pin_left)]), + ]; + for (title, boxes) in rows { + crate::widgets::caption(ui, crate::i18n::tr(&lang, title)); + let mut grid_boxes = vec![boxes.to_vec()]; + if title == "Pin:" { + grid_boxes.push(vec![("Bottom", "pinBottom", l.pin_bottom), ("Right", "pinRight", l.pin_right)]); + } + egui::Grid::new(("liquid_grid", title)).num_columns(2).min_col_width(90.0).show(ui, |ui| { + for row in grid_boxes { + for (label, key, value) in row { + let mut v = value; + if ui.checkbox(&mut v, crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).changed() { + let _ = app.run("liquid.object", json!({key: v})); + } + } + ui.end_row(); + } + }); + } + }); + }); if !pg.guides.is_empty() { ui.separator(); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Liquid guides")).size(11.0).color(t.text_dim)); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Liquid guides")).size(11.0).color(t.text_dim)); for (gi, g) in pg.guides.iter().enumerate() { let mut on = g.liquid; let label = format!("{:?} at {:.1}", g.orientation, g.position); @@ -553,22 +607,6 @@ pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { } } } - ui.separator(); - let key = egui::Id::new("alt_layout"); - let (mut name, mut w, mut h): (String, f64, f64) = ui.data(|x| x.get_temp(key)).unwrap_or(("Tablet H".into(), 1024.0, 768.0)); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Create Alternate Layout")).size(11.0).color(t.text_dim)); - ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut name).desired_width(90.0)); - ui.add(egui::DragValue::new(&mut w).range(1.0..=15552.0).suffix(" pt")); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "×")); - ui.add(egui::DragValue::new(&mut h).range(1.0..=15552.0).suffix(" pt")); - }); - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Create"))).clicked() - && let Err(e) = app.run("layout.createAlternate", json!({"name": name, "width": w, "height": h})) - { - app.status(format!("Alternate layout: {e}")); - } - ui.data_mut(|x| x.insert_temp(key, (name, w, h))); } /// Media panel: the selected video or sound's playback options and poster. @@ -734,3 +772,45 @@ pub fn track_changes(app: &mut DesignApp, ui: &mut egui::Ui) { } }); } + +#[cfg(test)] +mod liquid_panel_tests { + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + #[test] + fn object_constraints_set_the_selected_objects_pins_and_resizing() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(260.0, 420.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + liquid(app, ui); + }, + app, + ); + h.run_steps(3); + let l = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().liquid; + // With the rule off the constraints don't apply. + h.get_by_label("Right").click(); + h.run_steps(2); + assert_eq!(l(&h), None); + h.state_mut().run("liquid.pageRule", json!({"rule": "objectBased"})).unwrap(); + h.run_steps(2); + h.get_by_label("Right").click(); + h.run_steps(2); + h.get_by_label("Width").click(); + h.run_steps(2); + let v = l(&h).unwrap(); + assert!(v.pin_right && !v.resize_width && v.resize_height, "{v:?}"); + let _ = h.get_by_label("Auto-Fit"); + } +} From db19963bad7969fb6832483c8ae1fe6c79508c09 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 01:37:14 +0200 Subject: [PATCH 23/32] feat(ui): the Page tool draws liquid guides from the rulers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (11:00– 13:30) drags guides out of the rulers with the Page tool on a guide-based page: "these guides are a little bit different... they are a dashed line, a cyan dashed line" - liquid guides, which let the objects they cross stretch with the page. DesignCraft drew liquid guides dashed and applied them, but a guide could only become liquid through the Liquid Layout panel's list: ruler drags always made ruler guides. - canvas: with the Page tool, a page guide dragged from a ruler is a liquid guide; its preview is dashed while it's dragged. - engine: guide.add takes `liquid`. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/guides.rs | 12 +++++++-- crates/ui-egui/src/canvas.rs | 48 +++++++++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f8207..92513fc3 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-14** · With the Page tool, a guide dragged from a ruler is a liquid guide (dashed), as in InDesign; guide-based pages stretch the objects it crosses. 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/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs index 1111cb3b..f4fb8e3d 100644 --- a/crates/engine/src/cmd/guides.rs +++ b/crates/engine/src/cmd/guides.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "New Guide", [], None, - "{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool} → {page, index}", + "{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} → {page, index}", has_doc, add ), @@ -98,6 +98,7 @@ fn add(s: &mut Session, p: &Value) -> Result { let pos = finite_number(p, "position", "guide.add")?.ok_or_else(|| bad("guide.add", "missing position"))?; let r = super::spread_param(p, "guide.add")?; let spread_guide = p.get("spreadGuide").and_then(Value::as_bool).unwrap_or(false); + let liquid = p.get("liquid").and_then(Value::as_bool).unwrap_or(false); let d = &s.doc()?.doc; let pi = match optional_usize(p, "page", "guide.add")? { Some(pi) => pi, @@ -114,7 +115,14 @@ fn add(s: &mut Session, p: &Value) -> Result { s.edit(|d, _| { let sp = d.spread_mut(r).ok_or_else(|| bad("guide.add", "no such spread"))?; let pg = sp.pages.get_mut(pi).ok_or_else(|| bad("guide.add", "no such page"))?; - pg.guides.push(Guide { orientation: o, position: pos, spread: spread_guide, locked: false, layer: Some(layer), liquid: false }); + pg.guides.push(Guide { + orientation: o, + position: pos, + spread: spread_guide, + locked: false, + layer: Some(layer), + liquid: liquid && !spread_guide, + }); Ok(json!({"page": pi, "index": pg.guides.len() - 1})) }) } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae294..eb74b4f5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -301,7 +301,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { designcraft_doc::Orientation::Horizontal => (pos2(rect.min.x, p.y), pos2(rect.max.x, p.y)), designcraft_doc::Orientation::Vertical => (pos2(p.x, rect.min.y), pos2(p.x, rect.max.y)), }; - painter.line_segment([a, b], Stroke::new(1.0, col)); + // A new guide from the rulers with the Page tool is a liquid guide: dashed. + if g.from.is_none() && app.session.tool_id() == "page" { + dashed(&painter, a, b, Stroke::new(1.0, col), 6.0, 3.0); + } else { + painter.line_segment([a, b], Stroke::new(1.0, col)); + } } draw_frames(app, &painter, &xf, &doc, &layout); if app.ui.hidden_characters { @@ -1760,7 +1765,10 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec let _ = app.run("guide.move", json!({"spread": r, "page": pi, "index": gi, "position": pos_of(sp)})); } (None, true, Some((r, sp, on_page))) => { - let mut params = json!({"spread": r, "orientation": orient, "position": pos_of(sp), "at": sp.x, "spreadGuide": on_page.is_none()}); + // The Page tool draws liquid guides from the rulers. + let liquid = app.session.tool_id() == "page" && on_page.is_some(); + let mut params = + json!({"spread": r, "orientation": orient, "position": pos_of(sp), "at": sp.x, "spreadGuide": on_page.is_none(), "liquid": liquid}); if let Some(pi) = on_page { params["page"] = json!(pi); } @@ -1791,6 +1799,42 @@ mod tests { h } + /// A primary-button drag from `a` to `b` over a few frames. + fn drag(h: &mut Harness<'_, DesignApp>, a: Pos2, b: Pos2) { + h.hover_at(a); + h.step(); + h.event(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=4 { + h.hover_at(a + (b - a) * (k as f32 / 4.0)); + h.step(); + } + h.event(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + } + + #[test] + fn the_page_tool_draws_liquid_guides_from_the_rulers() { + // Chris Cicchino's Page Tool and Liquid Layout video (11:00): with the Page tool, a guide + // dragged from the ruler is a liquid guide (dashed). + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.session.set_tool("page"); + let mut h = harness(app); + let r = h.state().canvas_rect.unwrap(); + let guides = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.spreads[0].pages[0].guides.clone(); + drag(&mut h, pos2(r.center().x, r.min.y - 8.0), r.center()); + let g = guides(&h); + assert_eq!(g.len(), 1, "{g:?}"); + assert!(g[0].liquid && g[0].orientation == designcraft_doc::Orientation::Horizontal); + // With the Selection tool it's a ruler guide. + h.state_mut().session.set_tool("selection"); + drag(&mut h, pos2(r.center().x, r.min.y - 8.0), r.center() + vec2(0.0, 40.0)); + let g = guides(&h); + assert_eq!(g.len(), 2); + assert!(!g[1].liquid); + } + fn right_click(h: &mut Harness<'_, DesignApp>, p: Pos2) { h.hover_at(p); h.step(); From 3939b825d3a5ff6709079b6e6fcdf6a8605dd3d3 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 02:02:16 +0200 Subject: [PATCH 24/32] feat(ui): Pages panel duplicates pages by Alt-drag and the New Page button; Edit page size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (0:42, 2:06) holds Option and drags a page in the Pages panel to copy it, drags a page onto the New Page button to duplicate it ("click and drag onto my plus sign I can duplicate that"), and resizes pages; InDesign's panel also has an Edit page size button. DesignCraft's panel only moved pages on drag, had no Edit page size button, and duplicating a page gave the copy the size of the page before it (not its own) without its liquid rule or guides. A page of its own size also drew its thumbnail at the document's scale, over the panel's footer. - Pages panel: Alt-drag copies the page to where it's dropped; a page dropped on Create new page is duplicated at the end of the document; Edit page size lists the presets (in the page's orientation) and Custom Page Size…; InDesign's tooltips (Create new page, Delete selected pages). - Thumbnails share one scale (the document's page size, smaller when a taller page would pass 1.8 times the usual height); rows fit their spread's tallest page. - engine: layout.pages.duplicateSpread copies the pages' size, margins, columns, guides and liquid rule, takes `atEnd`, and returns the copy's first page. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/layout.rs | 53 ++++++++---- crates/engine/src/tests.rs | 26 ++++++ crates/ui-egui/src/i18n.rs | 16 ++++ crates/ui-egui/src/i18n/ar.rs | 4 + crates/ui-egui/src/i18n/it.rs | 4 + crates/ui-egui/src/i18n/ja.rs | 4 + crates/ui-egui/src/i18n/pt_br.rs | 4 + crates/ui-egui/src/i18n/uk.rs | 4 + crates/ui-egui/src/panels/pages.rs | 106 +++++++++++++++++++++++- crates/ui-egui/src/panels/properties.rs | 4 +- 11 files changed, 205 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f8207..cf4d92cf 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-14** · Pages panel: Alt-drag copies a page, a page dropped on Create new page is duplicated at the end, Edit page size offers the presets and Custom Page Size…; duplicated pages keep their own size, rule and guides; thumbnails of pages of their own size stay in their rows. 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/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bd532079..663e1079 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -135,21 +135,44 @@ pub fn specs() -> Vec { ok() }) }), - cmd!("layout.pages.duplicateSpread", "Duplicate Spread", ["Layout", "Pages"], None, "{spread}", has_doc, |s, p| { - let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; - s.edit(|d, _| { - let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; - let after = d.first_page_of_spread(si) + sp.pages.len() - 1; - let parent = sp.pages.first().and_then(|p| p.parent); - d.insert_pages(Some(after), sp.pages.len(), parent)?; - // Copy items onto the new spread(s) at the same positions. - let src = d.clone(); - let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect(); - let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?; - super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?; - ok() - }) - }), + cmd!( + "layout.pages.duplicateSpread", + "Duplicate Spread", + ["Layout", "Pages"], + None, + "{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items", + has_doc, + |s, p| { + let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; + let at_end = p.get("atEnd").and_then(Value::as_bool).unwrap_or(false); + s.edit(|d, _| { + let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; + let after = if at_end { d.page_count() - 1 } else { d.first_page_of_spread(si) + sp.pages.len() - 1 }; + let parent = sp.pages.first().and_then(|p| p.parent); + d.insert_pages(Some(after), sp.pages.len(), parent)?; + // The copies are the pages they copy: size, margins, columns, guides, liquid rule. + for (k, src) in sp.pages.iter().enumerate() { + let Some((nsi, npi)) = d.page_loc(after + 1 + k) else { continue }; + let nsp = std::sync::Arc::make_mut(&mut d.spreads[nsi]); + let pg = &mut nsp.pages[npi]; + let dx = pg.x - src.x; + (pg.width, pg.height, pg.margins, pg.columns, pg.liquid) = + (src.width, src.height, src.margins, src.columns.clone(), src.liquid); + pg.guides = src.guides.clone(); + for g in pg.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { + g.position += dx; + } + nsp.relayout(); + } + // Copy items onto the new spread(s) at the same positions. + let src = d.clone(); + let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect(); + let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?; + super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?; + Ok(json!({"page": after + 1})) + }) + } + ), cmd!( "layout.pages.applyParent", "Apply Parent to Pages…", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 17d1ed09..f896ab3c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -569,3 +569,29 @@ fn commands_take_non_object_params_without_panicking() { assert!(r.is_ok(), "{p}: {r:?}"); } } + +#[test] +fn duplicating_a_page_keeps_its_own_size_rule_guides_and_objects() { + // Chris Cicchino's Page Tool video (2:06): the letterhead page is duplicated, then resized. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); + s.execute("layout.pages.insert", &json!({"count": 1})).unwrap(); + s.execute("layout.pageSize", &json!({"pages": [2], "width": 612, "height": 792})).unwrap(); + s.execute("liquid.pageRule", &json!({"rule": "scale", "pages": [2]})).unwrap(); + s.execute("guide.add", &json!({"spread": 1, "page": 0, "orientation": "vertical", "position": 300})).unwrap(); + s.execute("frame.create", &json!({"spread": 1, "rect": [100, 100, 200, 200]})).unwrap(); + let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 1})).unwrap(); + assert_eq!(r["page"], 2); + let d = s.doc().unwrap().doc.clone(); + let (si, pi) = d.page_loc(2).unwrap(); + let pg = &d.spreads[si].pages[pi]; + assert_eq!((pg.width, pg.height, pg.liquid), (612.0, 792.0, designcraft_doc::LiquidRule::Scale)); + assert_eq!(pg.guides.len(), 1); + assert_eq!(d.spreads[si].items.len(), 1); + // At the end of the document (the New Page button): the business card, not the last page's size. + let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "atEnd": true})).unwrap(); + assert_eq!(r["page"], 3); + let d = s.doc().unwrap().doc.clone(); + let (si, pi) = d.page_loc(3).unwrap(); + assert_eq!((d.spreads[si].pages[pi].width, d.spreads[si].pages[pi].height), (252.0, 144.0)); +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5252c388..93728dde 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -612,6 +612,22 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Edit Page", ["Edit Page", "Edit Page", "Edit Page", "Edit Page", "编辑页面"]), ("Import File", ["Import File", "Import File", "Import File", "Import File", "导入文件"]), ("Insert Page", ["Insert Page", "Insert Page", "Insert Page", "Insert Page", "插入页面"]), + ( + "Delete selected pages", + ["Ausgewählte Seiten löschen", "Supprimer les pages sélectionnées", "Eliminar páginas seleccionadas", "選択したページを削除", "删除选定页面"], + ), + ("Create new page", ["Neue Seite erstellen", "Créer une page", "Crear página nueva", "新規ページを作成", "新建页面"]), + ("Edit page size", ["Seitenformat bearbeiten", "Modifier le format de page", "Editar tamaño de página", "ページサイズを編集", "编辑页面大小"]), + ( + "Custom Page Size…", + [ + "Benutzerdefiniertes Seitenformat …", + "Format de page personnalisé…", + "Tamaño de página personalizado…", + "カスタムページサイズ…", + "自定页面大小…", + ], + ), ("Hide Bar", ["Hide Bar", "Hide Bar", "Hide Bar", "Hide Bar", "隐藏栏"]), ("Properties Panel", ["Properties Panel", "Properties Panel", "Properties Panel", "Properties Panel", "属性面板"]), ("New Workspace…", ["New Workspace…", "New Workspace…", "New Workspace…", "New Workspace…", "新建工作区…"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index a182d09e..4373051b 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -270,6 +270,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Quick Actions", "الإجراءات السريعة"), ("Import File", "استيراد ملف"), ("Insert Page", "إدراج صفحة"), + ("Delete selected pages", "حذف الصفحات المحددة"), + ("Create new page", "إنشاء صفحة جديدة"), + ("Edit page size", "تحرير حجم الصفحة"), + ("Custom Page Size…", "حجم صفحة مخصص…"), ("Edit Page", "تحرير الصفحة"), ("Adjust Layout", "ضبط التخطيط"), ("Smart Guides", "أدلة ذكية"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 75ece03f..f86e1b19 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -363,6 +363,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Edit Page", "Modifica pagina"), ("Import File", "Importa file"), ("Insert Page", "Inserisci pagina"), + ("Delete selected pages", "Elimina pagine selezionate"), + ("Create new page", "Crea nuova pagina"), + ("Edit page size", "Modifica dimensioni pagina"), + ("Custom Page Size…", "Dimensioni pagina personalizzate…"), ("Hide Bar", "Nascondi barra"), ("Properties Panel", "Pannello Proprietà"), ("New Workspace…", "Nuova area di lavoro…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 642b4a4c..53833dd0 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -365,6 +365,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Edit Page", "ページを編集"), ("Import File", "ファイルを読み込み"), ("Insert Page", "ページを挿入"), + ("Delete selected pages", "選択したページを削除"), + ("Create new page", "新規ページを作成"), + ("Edit page size", "ページサイズを編集"), + ("Custom Page Size…", "カスタムページサイズ…"), ("Hide Bar", "バーを隠す"), ("Properties Panel", "プロパティパネル"), ("New Workspace…", "新規ワークスペース…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 982dfb20..25229c80 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -271,6 +271,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Quick Actions", "Ações rápidas"), ("Import File", "Importar arquivo"), ("Insert Page", "Inserir página"), + ("Delete selected pages", "Excluir páginas selecionadas"), + ("Create new page", "Criar nova página"), + ("Edit page size", "Editar tamanho da página"), + ("Custom Page Size…", "Tamanho de página personalizado…"), ("Edit Page", "Editar página"), ("Adjust Layout", "Ajustar layout"), ("Smart Guides", "Guias inteligentes"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f3bc8fb3..7adad164 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -366,6 +366,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Edit Page", "Редагувати сторінку"), ("Import File", "Імпортувати файл"), ("Insert Page", "Вставити сторінку"), + ("Delete selected pages", "Видалити вибрані сторінки"), + ("Create new page", "Створити нову сторінку"), + ("Edit page size", "Змінити розмір сторінки"), + ("Custom Page Size…", "Власний розмір сторінки…"), ("Hide Bar", "Сховати панель"), ("Properties Panel", "Панель властивостей"), ("New Workspace…", "Нове робоче середовище…"), diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index ae323225..d70b3427 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -114,11 +114,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let cur_spread = doc.page_loc(cur).map(|l| l.0); let center = ui.available_width() / 2.0; let dragging = egui::DragAndDrop::has_any_payload(ui.ctx()); + let tallest = doc.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.height)).fold(doc.settings.page_height, f64::max).max(1.0); for (si, sp) in doc.spreads.iter().enumerate() { let first = doc.first_page_of_spread(si); - let scale = THUMB_H / doc.settings.page_height.max(1.0) as f32; + // One scale for the whole document (pages of their own size show in proportion): the + // document's page size at the usual height, unless a taller page would pass 1.8 times it. + // Each row is as tall as its spread's tallest page. + let scale = (THUMB_H / doc.settings.page_height.max(1.0) as f32).min(1.8 * THUMB_H / tallest as f32); + let row_h = sp.pages.iter().map(|p| p.height as f32 * scale).fold(0.0, f32::max).max(THUMB_H * 0.25); let spine = sp.spine_x() as f32; - let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + THUMB_H + 22.0), Sense::hover()); + let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + row_h + 22.0), Sense::hover()); let top = row.min.y + 8.0; // Marker above the current spread. if cur_spread == Some(si) && !editing_parents { @@ -197,15 +202,55 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { .color(t.text_dim), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete Page")).clicked() { + if icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete selected pages")).clicked() { let _ = app.run("layout.pages.delete", json!({"pages": [cur]})); } - if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "Insert Page")).clicked() { + // Create new page; a page dropped on it is duplicated at the end of the document. + let plus = icons::button(ui, "plus", 20.0, dragging, crate::i18n::tr(&app.ui.language, "Create new page")); + if let Some(payload) = plus.dnd_release_payload::() { + if let PageDrag::Page(from) = *payload + && let Some((si, _)) = doc.page_loc(from) + { + let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "atEnd": true})); + } + } else if plus.clicked() { let _ = app.run("layout.pages.insert", json!({"after": cur, "count": 1})); } + // Edit page size: InDesign's presets for the current page, or Custom Page Size…. + let size = icons::button(ui, "panel-pages", 20.0, false, crate::i18n::tr(&app.ui.language, "Edit page size")); + egui::Popup::menu(&size).show(|ui| { + let Some((si, pi)) = doc.page_loc(cur) else { return }; + let pg = &doc.spreads[si].pages[pi]; + let landscape = pg.width > pg.height; + let current = crate::panels::properties::preset_name(pg.width, pg.height); + for (name, w, h) in crate::panels::properties::PAGE_PRESETS { + let (w, h) = if landscape { (h, w) } else { (w, h) }; + if ui.selectable_label(current == *name, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, name))).clicked() { + let _ = app.run("layout.pageSize", json!({"pages": [cur + 1], "width": w, "height": h})); + ui.close(); + } + } + ui.separator(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Custom Page Size…"))).clicked() { + app.ui.dialog = + Some(crate::dialogs::Dialog::new("cmd:layout.pageSize", json!({"pages": [cur + 1], "width": pg.width, "height": pg.height}))); + ui.close(); + } + }); }); }); + // Alt-drag (InDesign's Option-drag) copies the page to where it's dropped. + let copy = ui.input(|i| i.modifiers.alt); match drop { + Some((PageDrag::Page(from), to)) if copy => { + if let Some((si, _)) = doc.page_loc(from) + && let Ok(r) = app.run("layout.pages.duplicateSpread", json!({"spread": si})) + && let Some(new) = r["page"].as_u64().map(|p| p as usize) + && new != to + { + let _ = app.run("layout.pages.move", json!({"from": new, "to": to})); + } + } Some((PageDrag::Page(from), to)) if from != to => { let _ = app.run("layout.pages.move", json!({"from": from, "to": to})); } @@ -289,3 +334,56 @@ fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) { painter.rect(r, 2.0, t.panel_darker, Stroke::new(1.0, t.field_border), StrokeKind::Inside); painter.galley(r.min + vec2(5.0, 3.0), g, t.text_strong); } + +#[cfg(test)] +mod tests { + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(vec2(280.0, 520.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + } + + /// Release a dragged page over `at`. + fn drop_page(h: &mut Harness<'_, DesignApp>, page: usize, at: egui::Pos2, alt: bool) { + let modifiers = egui::Modifiers { alt, ..Default::default() }; + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers }); + egui::DragAndDrop::set_payload(&h.ctx, PageDrag::Page(page)); + h.event(egui::Event::PointerMoved(at)); + h.run_steps(2); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(3); + } + + #[test] + fn a_page_dropped_on_create_new_page_is_duplicated_at_the_end() { + // Chris Cicchino's Page Tool video (2:06): "click and drag onto my plus sign I can duplicate that". + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); + app.run("layout.pages.insert", json!({"count": 1})).unwrap(); + app.run("layout.pageSize", json!({"pages": [2], "width": 612, "height": 792})).unwrap(); + let mut h = harness(app); + let plus = h.get_by_label("Create new page").rect().center(); + drop_page(&mut h, 1, plus, false); + let d = h.state().session.doc().unwrap().doc.clone(); + assert_eq!(d.page_count(), 3); + let (si, pi) = d.page_loc(2).unwrap(); + assert_eq!(d.spreads[si].pages[pi].width, 612.0, "a copy of the letterhead"); + let _ = h.get_by_label("Edit page size"); + } +} diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d9c74e40..89e12890 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -160,7 +160,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // ---------------------------------------------------------------- No Selection /// Page-size presets for the Document dropdown (portrait, points). -const PAGE_PRESETS: &[(&str, f64, f64)] = &[ +pub(crate) const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("Letter", 612.0, 792.0), ("Legal", 612.0, 1008.0), ("Tabloid", 792.0, 1224.0), @@ -173,7 +173,7 @@ const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("B5", 498.898, 708.661), ]; -fn preset_name(w: f64, h: f64) -> &'static str { +pub(crate) fn preset_name(w: f64, h: f64) -> &'static str { let (a, b) = (w.min(h), w.max(h)); PAGE_PRESETS.iter().find(|p| (p.1 - a).abs() < 0.6 && (p.2 - b).abs() < 0.6).map(|p| p.0).unwrap_or("Custom") } From 8156e575bc1b403e6c62054fca132e4a35ff927e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 02:15:27 +0200 Subject: [PATCH 25/32] feat(ui): Pages panel menu in InDesign's order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pages panel menu in Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (0:47) reads: Insert Pages…, Move Pages…, Duplicate Spread, Duplicate Spread at End of Document, Delete Spread, … New Parent…, Apply Parent to Pages…, Override All Parent Page Items, Parent Pages ▸, Create Alternate Layout…, Numbering & Section Options…, Allow Selected Spread to Shuffle. DesignCraft's menu (and the page thumbnails' context menu) had Insert Page After, Delete Page, Duplicate Spread, Add to Previous Spread, Allow Spread to Shuffle and the parent items in another order. - Pages panel menu and page context menu in InDesign's order and labels: Insert Pages… and Move Pages… open their dialogs for the page; Delete Spread for a spread of several pages; Parent Pages ▸ holds Remove All Local Overrides and Detach All Objects from Parent; Create Alternate Layout… and Numbering & Section Options… open theirs. Add to Previous Spread stays last (InDesign drags pages into spreads instead). - Translations (pt-BR gains the existing items it lacked). Builds on #869 (Duplicate Spread at End of Document). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 22 ++++ crates/ui-egui/src/i18n/ar.rs | 4 + crates/ui-egui/src/i18n/it.rs | 4 + crates/ui-egui/src/i18n/ja.rs | 3 + crates/ui-egui/src/i18n/pt_br.rs | 12 +++ crates/ui-egui/src/i18n/uk.rs | 4 + crates/ui-egui/src/panel_docking_tests.rs | 2 +- crates/ui-egui/src/panels/pages.rs | 119 +++++++++++++++++----- 9 files changed, 142 insertions(+), 29 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cf4d92cf..c091b665 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-14** · The Pages panel menu follows InDesign's order: Insert Pages…, Move Pages…, Duplicate Spread (and at End of Document), Delete Page/Spread, New Parent…, Apply Parent to Pages, Override All Parent Page Items, Parent Pages, Create Alternate Layout…, Numbering & Section Options…, Allow Selected Spread to Shuffle. No readiness percentage change. - **2026-10-14** · Pages panel: Alt-drag copies a page, a page dropped on Create new page is duplicated at the end, Edit page size offers the presets and Custom Page Size…; duplicated pages keep their own size, rule and guides; thumbnails of pages of their own size stay in their rows. 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. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 93728dde..7ab2e23f 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -296,6 +296,28 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Document Setup", ["Document Setup", "Document Setup", "Document Setup", "Document Setup", "文档设置"]), ("Drop Shadow", ["Drop Shadow", "Drop Shadow", "Drop Shadow", "Drop Shadow", "投影"]), ("Duplicate Spread", ["Duplicate Spread", "Duplicate Spread", "Duplicate Spread", "Duplicate Spread", "复制跨页"]), + ( + "Duplicate Spread at End of Document", + [ + "Druckbogen am Dokumentende duplizieren", + "Dupliquer la planche à la fin du document", + "Duplicar pliego al final del documento", + "ドキュメントの最後にスプレッドを複製", + "在文档末尾复制跨页", + ], + ), + ("Delete Spread", ["Druckbogen löschen", "Supprimer la planche", "Eliminar pliego", "スプレッドを削除", "删除跨页"]), + ("Parent Pages", ["Übergeordnete Seiten", "Pages parents", "Páginas principales", "親ページ", "父页面"]), + ( + "Apply Parent to Pages", + [ + "Übergeordnete Seite auf Seiten anwenden", + "Appliquer le parent aux pages", + "Aplicar principal a páginas", + "親ページを適用", + "将父页面应用于页面", + ], + ), ("Email", ["Email", "Email", "Email", "Email", "电子邮件"]), ("Embed", ["Embed", "Embed", "Embed", "Embed", "嵌入"]), ("Export PDF", ["PDF exportieren", "Exporter en PDF", "Exportar PDF", "PDF を書き出し", "导出PDF"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 4373051b..75dd0fea 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -864,6 +864,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Grid", "شبكة المستند"), ("Document Recovery Data", "بيانات استرداد المستند"), ("Duplicate Spread", "تكرار الصفحات المتقابلة"), + ("Duplicate Spread at End of Document", "تكرار الانتشار في نهاية المستند"), + ("Delete Spread", "حذف الانتشار"), + ("Parent Pages", "الصفحات الأصل"), + ("Apply Parent to Pages", "تطبيق الأصل على الصفحات"), ("Email", "بريد إلكتروني"), ("Embed", "تضمين"), ("Fast", "سريع"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index f86e1b19..735bd38c 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -177,6 +177,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Setup", "Impostazione documento"), ("Drop Shadow", "Ombra esterna"), ("Duplicate Spread", "Duplica doppia pagina"), + ("Duplicate Spread at End of Document", "Duplica pagine affiancate alla fine del documento"), + ("Delete Spread", "Elimina pagine affiancate"), + ("Parent Pages", "Pagine principali"), + ("Apply Parent to Pages", "Applica pagina principale alle pagine"), ("Email", "E-mail"), ("Embed", "Incorpora"), ("Export XML…", "Esporta XML…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 53833dd0..87c0aea6 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -179,6 +179,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Setup", "ドキュメント設定"), ("Drop Shadow", "ドロップシャドウ"), ("Duplicate Spread", "スプレッドを複製"), + ("Duplicate Spread at End of Document", "ドキュメントの最後にスプレッドを複製"), + ("Delete Spread", "スプレッドを削除"), + ("Parent Pages", "親ページ"), ("Email", "メール"), ("Embed", "埋め込み"), ("Export XML…", "XML を書き出し…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 25229c80..d4456e5e 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -866,6 +866,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Grid", "Grade do documento"), ("Document Recovery Data", "Dados de recuperação do documento"), ("Duplicate Spread", "Duplicar página dupla"), + ("Insert Pages…", "Inserir páginas…"), + ("Move Pages…", "Mover páginas…"), + ("New Parent…", "Nova página principal…"), + ("Override All Parent Page Items", "Substituir todos os itens da página principal"), + ("Remove All Local Overrides", "Remover todas as substituições locais"), + ("Detach All Objects from Parent", "Desanexar todos os objetos da página principal"), + ("Create Alternate Layout…", "Criar layout alternativo…"), + ("Allow Selected Spread to Shuffle", "Permitir que a página espelhada selecionada seja reorganizada"), + ("Duplicate Spread at End of Document", "Duplicar página espelhada no final do documento"), + ("Delete Spread", "Excluir página espelhada"), + ("Parent Pages", "Páginas principais"), + ("Apply Parent to Pages", "Aplicar página principal às páginas"), ("Email", "E-mail"), ("Embed", "Embutir"), ("Fast", "Rápido"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 7adad164..4cc572cd 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -180,6 +180,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Setup", "Параметри документа"), ("Drop Shadow", "Тінь"), ("Duplicate Spread", "Дублювати розворот"), + ("Duplicate Spread at End of Document", "Дублювати розворот у кінці документа"), + ("Delete Spread", "Видалити розворот"), + ("Parent Pages", "Батьківські сторінки"), + ("Apply Parent to Pages", "Застосувати батьківську до сторінок"), ("Email", "Електронна пошта"), ("Embed", "Вбудувати"), ("Export XML…", "Експортувати XML…"), diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index de08e3cf..56b90621 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -617,7 +617,7 @@ fn legacy_dock_menu_button_opens_the_active_panel_menu() { } h.run_steps(2); assert!(egui::Popup::is_any_open(&h.ctx), "the three-line button must open a menu"); - h.get_by_label("Insert Page After").click(); + h.get_by_label("Duplicate Spread").click(); h.run_steps(3); assert_eq!(h.state().session.active().unwrap().doc.page_count(), pages + 1); assert!(!egui::Popup::is_any_open(&h.ctx)); diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index d70b3427..dfc69dff 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -271,47 +271,39 @@ pub fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui) { } fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, abs: usize, si: usize) { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Insert Page After"))).clicked() { - let _ = app.run("layout.pages.insert", json!({"after": abs, "count": 1})); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_owned(); + let pages_in_spread = doc.spreads.get(si).map_or(1, |sp| sp.pages.len()); + let item = |ui: &mut egui::Ui, label: &str| ui.button(crate::rtl::widget(ui, tr(label))).clicked(); + // InDesign's order: pages, parents, layouts and numbering, shuffling. + if item(ui, "Insert Pages…") { + app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.insert", json!({"after": abs, "count": 1}))); ui.close(); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Page"))).clicked() { - let _ = app.run("layout.pages.delete", json!({"pages": [abs]})); + if item(ui, "Move Pages…") { + app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.move", json!({"from": abs, "to": abs}))); ui.close(); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Duplicate Spread"))).clicked() { + if item(ui, "Duplicate Spread") { let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si})); ui.close(); } - if si > 0 - && ui - .button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Add to Previous Spread"))) - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Spreads of up to 10 pages")); - }) - .clicked() - { - let _ = app.run("layout.pages.toSpread", json!({"page": abs + 1, "spread": si - 1})); + if item(ui, "Duplicate Spread at End of Document") { + let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "atEnd": true})); ui.close(); } - let mut shuffle = doc.spreads.get(si).is_none_or(|sp| sp.allow_shuffle); - if ui.checkbox(&mut shuffle, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Allow Spread to Shuffle"))).clicked() { - let _ = app.run("layout.spreadShuffle", json!({"spread": si, "allow": shuffle})); + if item(ui, if pages_in_spread > 1 { "Delete Spread" } else { "Delete Page" }) { + let first = doc.first_page_of_spread(si); + let _ = app.run("layout.pages.delete", json!({"pages": (first..first + pages_in_spread).collect::>()})); ui.close(); } ui.separator(); - for (id, label) in [ - ("layout.overrideParentItems", "Override All Parent Page Items"), - ("layout.removeOverrides", "Remove All Local Overrides"), - ("layout.detachAll", "Detach All Objects from Parent"), - ] { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - let _ = app.run(id, json!({"page": abs})); - ui.close(); - } + if item(ui, "New Parent…") { + crate::menus::activate(app, "layout.parents.new", &serde_json::Value::Null); + ui.close(); } - crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply Parent")), |ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "[None]"))).clicked() { + crate::menus::menu_button(ui, crate::rtl::widget(ui, tr("Apply Parent to Pages")), |ui| { + if ui.button(crate::rtl::widget(ui, tr("[None]"))).clicked() { let _ = app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": null})); ui.close(); } @@ -323,6 +315,45 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu } } }); + if item(ui, "Override All Parent Page Items") { + let _ = app.run("layout.overrideParentItems", json!({"page": abs})); + ui.close(); + } + crate::menus::menu_button(ui, crate::rtl::widget(ui, tr("Parent Pages")), |ui| { + for (id, label) in [("layout.removeOverrides", "Remove All Local Overrides"), ("layout.detachAll", "Detach All Objects from Parent")] { + if ui.button(crate::rtl::widget(ui, tr(label))).clicked() { + let _ = app.run(id, json!({"page": abs})); + ui.close(); + } + } + }); + ui.separator(); + if item(ui, "Create Alternate Layout…") { + crate::menus::activate(app, "layout.createAlternate", &serde_json::Value::Null); + ui.close(); + } + if item(ui, "Numbering & Section Options…") { + crate::menus::activate(app, "layout.section", &serde_json::Value::Null); + ui.close(); + } + ui.separator(); + let mut shuffle = doc.spreads.get(si).is_none_or(|sp| sp.allow_shuffle); + if ui.checkbox(&mut shuffle, crate::rtl::widget(ui, tr("Allow Selected Spread to Shuffle"))).clicked() { + let _ = app.run("layout.spreadShuffle", json!({"spread": si, "allow": shuffle})); + ui.close(); + } + // Not in InDesign's menu (it drags pages into spreads): kept for spreads of up to 10 pages. + if si > 0 + && ui + .button(crate::rtl::widget(ui, tr("Add to Previous Spread"))) + .on_hover_ui(|ui| { + crate::rtl::label(ui, tr("Spreads of up to 10 pages")); + }) + .clicked() + { + let _ = app.run("layout.pages.toSpread", json!({"page": abs + 1, "spread": si - 1})); + ui.close(); + } } /// A small label following the pointer while dragging. @@ -370,6 +401,38 @@ mod tests { h.run_steps(3); } + #[test] + fn the_panel_menu_follows_indesigns_order() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + let doc = app.session.doc().unwrap().doc.clone(); + page_menu(app, ui, &doc, 0, 0); + }, + app, + ); + h.run_steps(3); + // InDesign's items, top to bottom. + let ys: Vec = ["Insert Pages…", "Move Pages…", "Duplicate Spread", "Duplicate Spread at End of Document", "Delete Page", "New Parent…"] + .iter() + .map(|l| h.get_by_label(l).rect().center().y) + .collect(); + assert!(ys.windows(2).all(|w| w[0] < w[1]), "{ys:?}"); + h.get_by_label("Duplicate Spread at End of Document").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 3); + h.get_by_label("Insert Pages…").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "cmd:layout.pages.insert")); + } + #[test] fn a_page_dropped_on_create_new_page_is_duplicated_at_the_end() { // Chris Cicchino's Page Tool video (2:06): "click and drag onto my plus sign I can duplicate that". From 87682c36747937501ef8bd8e1e2c19b6f49e2d6f Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 22:33:46 +0000 Subject: [PATCH 26/32] Integration: #836 a grey stop beside a CMYK stop exports as the K ink, keeping the PDF shading in DeviceCMYK (cherry picked from commit f28456d9d33930fcdd48b1c74460d72fe47a6d77) --- crates/color/src/lib.rs | 1 - crates/pdf/src/export.rs | 10 +++++++++- crates/pdf/tests/gradient_color_spaces.rs | 4 +++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 73a8cb26..24c2798a 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -169,7 +169,6 @@ impl Color { let [r, g, b] = self.to_rgb(); Color::rgb(1.0 - r, 1.0 - g, 1.0 - b) } - /// Linear interpolation in display RGB. /// The colour `t` of the way to `other`. Process inks blend as inks (CMYK with CMYK or grey, /// grey with grey), as InDesign blends a gradient's stops; anything else blends in RGB. pub fn lerp(&self, other: &Color, t: f32) -> Color { diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb8..d66ad643 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -981,7 +981,15 @@ impl Exporter<'_> { let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) { let kind = g.kind; - let expanded = g.expanded_stops(); + let mut expanded = g.expanded_stops(); + // Grey and CMYK stops blend as inks, so a grey stop beside a CMYK one is the K ink. + if !self.rgb_only && expanded.iter().any(|(_, c, _)| matches!(c, Color::Cmyk { .. })) { + for (_, c, _) in &mut expanded { + if let Color::Gray { k } = *c { + *c = Color::cmyk(0.0, 0.0, 0.0, k); + } + } + } // A PDF shading needs one colour space. Midpoint expansion can introduce RGB even // when every authored stop is CMYK/Gray; PDF/A can also change the output space. let components = |c: &Color| match c { diff --git a/crates/pdf/tests/gradient_color_spaces.rs b/crates/pdf/tests/gradient_color_spaces.rs index f9892965..05806a12 100644 --- a/crates/pdf/tests/gradient_color_spaces.rs +++ b/crates/pdf/tests/gradient_color_spaces.rs @@ -53,7 +53,7 @@ fn mixed_gradient_export_survives_in_subprocess() { let rgb = Color::rgb(1.0, 0.0, 0.0); let cmyk = Color::cmyk(1.0, 0.0, 0.0, 0.0); let gray = Color::gray(0.4); - for colors in [vec![cmyk, rgb], vec![rgb, cmyk], vec![rgb, gray], vec![gray, cmyk], vec![gray, rgb, cmyk]] { + for colors in [vec![cmyk, rgb], vec![rgb, cmyk], vec![rgb, gray], vec![gray, rgb, cmyk]] { for kind in [GradientKind::Linear, GradientKind::Radial] { for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] { let d = document(&colors, kind, 0.5, 0.7); @@ -107,6 +107,8 @@ fn homogeneous_gradients_keep_device_spaces_and_components() { ([Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)], "DeviceRGB", vec![1.0, 0.0, 0.0], vec![0.0, 0.0, 1.0]), ([Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 1.0, 0.0, 0.0)], "DeviceCMYK", vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]), ([Color::gray(0.2), Color::gray(0.8)], "DeviceGray", vec![0.8], vec![0.2]), + // Grey beside CMYK is the K ink, so the shading stays CMYK. + ([Color::gray(0.4), Color::cmyk(1.0, 0.0, 0.0, 0.0)], "DeviceCMYK", vec![0.0, 0.0, 0.0, 0.4], vec![1.0, 0.0, 0.0, 0.0]), ] { for standard in [Standard::None, Standard::PdfX4] { let report = export_pdf_with_report( From 7d601b5903a59f05204594c47c9543162142d938 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 14 Oct 2026 01:39:08 +0000 Subject: [PATCH 27/32] Integration: #864 drops the Controlled by Parent rule from the menus and the Objects Move with Page checkbox (neither has an effect yet) (cherry picked from commit 47b51d637756a468555feeac60965d3c94ab05d1) --- ROADMAP.md | 2 +- crates/ui-egui/src/chrome.rs | 13 ++++++------- crates/ui-egui/src/i18n.rs | 10 ---------- crates/ui-egui/src/i18n/ar.rs | 1 - crates/ui-egui/src/i18n/it.rs | 1 - crates/ui-egui/src/i18n/ja.rs | 1 - crates/ui-egui/src/i18n/pt_br.rs | 1 - crates/ui-egui/src/i18n/uk.rs | 1 - crates/ui-egui/src/panels/interactive.rs | 3 +-- docs/target-app-parity.md | 2 +- 10 files changed, 9 insertions(+), 26 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 573b0a37..8ac26c2a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,7 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule (now with Controlled by Parent) and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. +- **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. - **2026-10-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index f3a17ebf..44a970af 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -195,7 +195,7 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { } /// The Control panel with the Page tool, as InDesign's: reference point, X/Y, W/H, page-size -/// preset, orientation, Liquid Page Rule, Objects Move with Page and Show Parent Page Overlay, for +/// preset, orientation, Liquid Page Rule and Show Parent Page Overlay, for /// the page the Page tool selected (else the page in view). W/H keep the reference point in place. fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { let lang = app.ui.language.clone(); @@ -274,9 +274,6 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { }); vsep(ui); ui.vertical(|ui| { - // Objects follow a page that moves in its spread; pages don't move yet, so it's always on. - let mut follow = true; - ui.add_enabled(false, egui::Checkbox::new(&mut follow, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Objects Move with Page")))); let mut overlay = pg.show_parent_overlay; if ui.checkbox(&mut overlay, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show Parent Page Overlay"))).changed() { let _ = app.run("layout.pageOptions", json!({"pages": [n], "showParentOverlay": overlay})); @@ -930,9 +927,11 @@ mod tests { app.run("file.new", serde_json::json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); app.session.set_tool("page"); let mut h = test_window::open(app, vec2(1800.0, 600.0)); - for label in ["Liquid Page Rule:", "Objects Move with Page"] { - let _ = h.get_by_label(label); - } + let _ = h.get_by_label("Liquid Page Rule:"); + // Pages don't move within their spread, so there is no "Objects Move with Page" option, and + // no "Controlled by Parent" rule until parent pages have rules of their own. + assert!(h.query_by_label("Objects Move with Page").is_none()); + assert!(!crate::panels::interactive::LIQUID_RULES.iter().any(|(r, _)| *r == designcraft_doc::LiquidRule::ControlledByParent)); assert_eq!(crate::panels::properties::preset_name(252.0, 144.0), "US Business Card"); h.get_by_label("Show Parent Page Overlay").click(); h.run_steps(4); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5dc452a2..1375093d 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2523,16 +2523,6 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Liquid Page Rule:", ["Regel für flüssige Seiten:", "Règle de page liquide :", "Regla de página líquida:", "リキッドページルール:", "自适应页面规则:"], ), - ( - "Objects Move with Page", - [ - "Objekte mit Seite verschieben", - "Déplacer les objets avec la page", - "Los objetos se mueven con la página", - "オブジェクトをページと共に移動", - "对象随页面移动", - ], - ), ( "Show Parent Page Overlay", [ diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 66ca5390..1daa0101 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -722,7 +722,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "استنادًا إلى الأدلة"), ("Resize Page", "تغيير حجم الصفحة"), ("Liquid Page Rule:", "قاعدة الصفحة المرنة:"), - ("Objects Move with Page", "تحريك الكائنات مع الصفحة"), ("Show Parent Page Overlay", "إظهار تراكب الصفحة الأصل"), ("Controlled by Parent", "تتحكم فيه الصفحة الأصل"), ("US Business Card", "بطاقة عمل أمريكية"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index aa630d3c..d9435a31 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1300,7 +1300,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "Basato su guide"), ("Resize Page", "Ridimensiona pagina"), ("Liquid Page Rule:", "Regola pagina liquida:"), - ("Objects Move with Page", "Sposta oggetti con la pagina"), ("Show Parent Page Overlay", "Mostra sovrapposizione pagina principale"), ("Controlled by Parent", "Controllato dalla pagina principale"), ("US Business Card", "Biglietto da visita USA"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 88e93047..f35d4084 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1296,7 +1296,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "ガイド基準"), ("Resize Page", "ページのサイズを変更"), ("Liquid Page Rule:", "リキッドページルール:"), - ("Objects Move with Page", "オブジェクトをページと共に移動"), ("Show Parent Page Overlay", "親ページオーバーレイを表示"), ("Controlled by Parent", "親ページで制御"), ("US Business Card", "US 名刺"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 0005cc07..6a167350 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -713,7 +713,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "Baseado em guias"), ("Resize Page", "Redimensionar página"), ("Liquid Page Rule:", "Regra de página líquida:"), - ("Objects Move with Page", "Objetos se movem com a página"), ("Show Parent Page Overlay", "Mostrar sobreposição da página principal"), ("Controlled by Parent", "Controlado pela página principal"), ("US Business Card", "Cartão de visita EUA"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 32ac4864..5e95517c 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1297,7 +1297,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "На основі напрямних"), ("Resize Page", "Змінити розмір сторінки"), ("Liquid Page Rule:", "Правило гнучкої сторінки:"), - ("Objects Move with Page", "Об’єкти рухаються зі сторінкою"), ("Show Parent Page Overlay", "Показати накладення батьківської сторінки"), ("Controlled by Parent", "Керується батьківською сторінкою"), ("US Business Card", "Візитна картка (США)"), diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index cd36926f..876fc23b 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -487,13 +487,12 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { } /// Liquid page rules in InDesign's order, with their menu labels. -pub const LIQUID_RULES: [(designcraft_doc::LiquidRule, &str); 6] = [ +pub const LIQUID_RULES: [(designcraft_doc::LiquidRule, &str); 5] = [ (designcraft_doc::LiquidRule::Off, "Off"), (designcraft_doc::LiquidRule::Scale, "Scale"), (designcraft_doc::LiquidRule::ReCenter, "Re-center"), (designcraft_doc::LiquidRule::ObjectBased, "Object-based"), (designcraft_doc::LiquidRule::GuideBased, "Guide-based"), - (designcraft_doc::LiquidRule::ControlledByParent, "Controlled by Parent"), ]; /// A liquid page rule's menu label. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 95c9fd0a..5011b345 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -398,7 +398,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. - Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. -- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule including Controlled by Parent, Show Parent Page Overlay); X/Y and Objects Move with Page are shown, but pages don't move within their spread yet. +- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule, Show Parent Page Overlay); X/Y are shown, but pages don't move within their spread yet. - File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document). - Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. - Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. From f4efb7cc5cd363454141d108f184784e526b8cd5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 14 Oct 2026 01:38:39 +0000 Subject: [PATCH 28/32] Integration: #869 Alt-drag copies a page in one command and one undo step (layout.pages.duplicateSpread takes to) (cherry picked from commit 7889b25a379fdf27dd128bc49512bc3c0cbfcbfd) --- crates/engine/src/cmd/layout.rs | 23 +++++++++++++++++++++-- crates/engine/src/tests.rs | 28 ++++++++++++++++++++++++++++ crates/ui-egui/src/panels/pages.rs | 24 ++++++++++++------------ 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 663e1079..7a6b0505 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -140,13 +140,18 @@ pub fn specs() -> Vec { "Duplicate Spread", ["Layout", "Pages"], None, - "{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items", + "{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there), to?: page index the copied pages move to, in the same undo step (the Pages panel's Alt-drag)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items", has_doc, |s, p| { let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; let at_end = p.get("atEnd").and_then(Value::as_bool).unwrap_or(false); + let to = p.get("to").and_then(Value::as_u64).map(|t| t as usize); s.edit(|d, _| { let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; + // The copies land at `to`..`to + n` of the document with them in it. + if to.is_some_and(|t| t > d.page_count()) { + return Err(bad("layout.pages.duplicateSpread", "no such page")); + } let after = if at_end { d.page_count() - 1 } else { d.first_page_of_spread(si) + sp.pages.len() - 1 }; let parent = sp.pages.first().and_then(|p| p.parent); d.insert_pages(Some(after), sp.pages.len(), parent)?; @@ -169,7 +174,21 @@ pub fn specs() -> Vec { let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect(); let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?; super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?; - Ok(json!({"page": after + 1})) + let first = after + 1; + let Some(to) = to else { return Ok(json!({"page": first})) }; + // Moving a page shifts the ones between; order the moves so each copy keeps + // its place among the others. + let n = sp.pages.len(); + if first < to { + for k in (0..n).rev() { + d.move_page(first + k, to + k)?; + } + } else { + for k in 0..n { + d.move_page(first + k, to + k)?; + } + } + Ok(json!({"page": to})) }) } ), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0e7a011f..962a0f69 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -695,3 +695,31 @@ fn type_tool_frames_snap_to_the_margins() { assert!((r.x0 - margin.x0).abs() < 1e-6 && (r.y0 - margin.y0).abs() < 1e-6, "press point {r:?} vs {margin:?}"); assert!((r.x1 - margin.x1).abs() < 1e-6, "right edge {} vs {}", r.x1, margin.x1); } + +#[test] +fn duplicating_a_page_to_an_index_is_one_undo_step() { + let mut s = Session::new(); + s.execute("file.new", &json!({"facingPages": false})).unwrap(); + s.execute("layout.pages.insert", &json!({"count": 2})).unwrap(); + for (page, width) in [(1, 300), (2, 400), (3, 500)] { + s.execute("layout.pageSize", &json!({"pages": [page], "width": width, "height": 600})).unwrap(); + } + let widths = |s: &Session| { + let d = &s.doc().unwrap().doc; + (0..d.page_count()).map(|i| d.page_loc(i).map(|(si, pi)| d.spreads[si].pages[pi].width)).collect::>>().unwrap() + }; + // Backwards: the last page copied to the front. + let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 2, "to": 0})).unwrap(); + assert_eq!(r["page"], 0); + assert_eq!(widths(&s), [500.0, 300.0, 400.0, 500.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(widths(&s), [300.0, 400.0, 500.0]); + // Forwards: the first page copied to the end. + s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "to": 3})).unwrap(); + assert_eq!(widths(&s), [300.0, 400.0, 500.0, 300.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(widths(&s), [300.0, 400.0, 500.0]); + // Past the end: refused, nothing changes. + assert!(s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "to": 4})).is_err()); + assert_eq!(widths(&s), [300.0, 400.0, 500.0]); +} diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index d70b3427..a9e4c65c 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -239,21 +239,21 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }); }); }); - // Alt-drag (InDesign's Option-drag) copies the page to where it's dropped. - let copy = ui.input(|i| i.modifiers.alt); + // Alt-drag copies a page that is a spread of its own to where it's dropped, in one undo + // step; a page from a larger spread moves as usual. + let alt = ui.input(|i| i.modifiers.alt); + let copy = |from: usize| { + let (si, _) = doc.page_loc(from)?; + (alt && doc.spreads.get(si).is_some_and(|s| s.pages.len() == 1)).then_some(si) + }; match drop { - Some((PageDrag::Page(from), to)) if copy => { - if let Some((si, _)) = doc.page_loc(from) - && let Ok(r) = app.run("layout.pages.duplicateSpread", json!({"spread": si})) - && let Some(new) = r["page"].as_u64().map(|p| p as usize) - && new != to - { - let _ = app.run("layout.pages.move", json!({"from": new, "to": to})); + Some((PageDrag::Page(from), to)) => { + if let Some(si) = copy(from) { + let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "to": to})); + } else if from != to { + let _ = app.run("layout.pages.move", json!({"from": from, "to": to})); } } - Some((PageDrag::Page(from), to)) if from != to => { - let _ = app.run("layout.pages.move", json!({"from": from, "to": to})); - } Some((PageDrag::Parent(prefix), to)) => { let _ = app.run("layout.pages.applyParent", json!({"pages": [to], "parent": prefix})); } From 1758642a96da8d0db6cf00b6126511ab3224c189 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 14 Oct 2026 01:39:48 +0000 Subject: [PATCH 29/32] Integration: #870 the page menu always offers Delete Page for the clicked page, Delete Spread separately for multi-page spreads (cherry picked from commit 9912a386a84baaf91f426482d7b9d88d3cd9994a) --- crates/ui-egui/src/panels/pages.rs | 41 ++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index d708bfde..ad8a8ab8 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -292,9 +292,13 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "atEnd": true})); ui.close(); } - if item(ui, if pages_in_spread > 1 { "Delete Spread" } else { "Delete Page" }) { + if item(ui, "Delete Page") { + let _ = app.run("layout.pages.delete", json!({"pages": [abs]})); + ui.close(); + } + if pages_in_spread > 1 && item(ui, "Delete Spread") { let first = doc.first_page_of_spread(si); - let _ = app.run("layout.pages.delete", json!({"pages": (first..first + pages_in_spread).collect::>()})); + let _ = app.run("layout.pages.delete", json!({"pages": (first..first.saturating_add(pages_in_spread)).collect::>()})); ui.close(); } ui.separator(); @@ -401,12 +405,10 @@ mod tests { h.run_steps(3); } - #[test] - fn the_panel_menu_follows_indesigns_order() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + /// The page menu for page `abs` of spread `si`. + fn menu_harness(app: DesignApp, abs: usize, si: usize) -> Harness<'static, DesignApp> { let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( - |ui, app: &mut DesignApp| { + move |ui, app: &mut DesignApp| { let key = egui::Id::new("test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); @@ -414,11 +416,34 @@ mod tests { return; } let doc = app.session.doc().unwrap().doc.clone(); - page_menu(app, ui, &doc, 0, 0); + page_menu(app, ui, &doc, abs, si); }, app, ); h.run_steps(3); + h + } + + #[test] + fn a_page_in_a_spread_offers_delete_page_and_delete_spread() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 5, "facingPages": true})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let (si, _) = doc.page_loc(2).unwrap(); + assert_eq!(doc.spreads[si].pages.len(), 2); + let mut h = menu_harness(app, 2, si); + let _ = h.get_by_label("Delete Spread"); + h.get_by_label("Delete Page").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 4, "only the clicked page"); + } + + #[test] + fn the_panel_menu_follows_indesigns_order() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + let mut h = menu_harness(app, 0, 0); + assert!(h.query_by_label("Delete Spread").is_none(), "a single-page spread has no Delete Spread"); // InDesign's items, top to bottom. let ys: Vec = ["Insert Pages…", "Move Pages…", "Duplicate Spread", "Duplicate Spread at End of Document", "Delete Page", "New Parent…"] .iter() From 23e1680e7d6c23d120ca347bf84c51eb7e682a18 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 10:27:38 +0200 Subject: [PATCH 30/32] feat(ui): Swatches panel header like InDesign's; swatches go to the fill or stroke in front LYH Studio episode 6 colours the "1969" headline (8:28) and the loop's stroke (9:15) from the Swatches panel, whose header holds InDesign's Fill/Stroke proxy, Formatting Affects Container/Text and the Tint field. - Header: a Fill/Stroke proxy showing the colours of what colour edits apply to (panels::color_target; a click brings the other to the front, as X does), the container/text buttons (app.formattingAffectsText) and the tint of the colour in front. - The Tint field sends only the tint, so each selected object (or run of text) keeps its own swatch: object.fill takes a tint without a swatch, and a stroke tint alone doesn't give a stroke-less object a stroke. - Clicking a swatch applies it to the fill or stroke in front (panels::apply_swatch, to the text when colours affect text); that row is highlighted. - Bottom row: New Color Group, New Swatch, Delete Swatch (the swatch last clicked). Rows and the proxy are named for accessibility (and tests). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/object.rs | 73 ++++++++- crates/ui-egui/src/panels/swatches.rs | 220 ++++++++++++++++++++++++-- docs/target-app-parity.md | 2 +- 4 files changed, 272 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c60a0410..8cfb4a1b 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-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 5e9ed391..82c58c50 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -179,12 +179,35 @@ pub fn specs() -> Vec { ) } ), - cmd!("object.fill", "Fill", [], None, "{swatch (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?}", has_selection, |s, p| { - let sw = str_param(p, "swatch").ok_or_else(|| bad("object.fill", "missing swatch"))?.to_string(); - known_swatch(&s.doc()?.doc, &sw, "object.fill", "swatch")?; - let tint = f64_or(p, "tint", 1.0) as f32; - set_flag(s, p, move |i| i.fill = Fill { swatch: sw.clone(), tint, ..i.fill.clone() }, false) - }), + cmd!( + "object.fill", + "Fill", + [], + None, + "{swatch? (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?} — at least one; a tint alone keeps each object's own swatch (the Swatches panel's Tint field)", + has_selection, + |s, p| { + let sw = str_param(p, "swatch").map(str::to_string); + let tint = match p.get("tint") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_f64().filter(|t| (0.0..=1.0).contains(t)).ok_or_else(|| bad("object.fill", "`tint` is 0..1"))? as f32), + }; + match &sw { + Some(sw) => known_swatch(&s.doc()?.doc, sw, "object.fill", "swatch")?, + None if tint.is_none() => return Err(bad("object.fill", "missing swatch")), + None => {} + } + set_flag( + s, + p, + move |i| match &sw { + Some(sw) => i.fill = Fill { swatch: sw.clone(), tint: tint.unwrap_or(1.0), ..i.fill.clone() }, + None => i.fill.tint = tint.unwrap_or(i.fill.tint), + }, + false, + ) + } + ), cmd!( "object.swapFillStroke", "Swap Fill and Stroke", @@ -217,10 +240,15 @@ pub fn specs() -> Vec { } } let p2 = p.clone(); + // A tint alone (the Swatches panel's Tint field) recolours strokes, it doesn't add one. + let tint_only = p.as_object().is_some_and(|o| o.contains_key("tint") && o.keys().all(|k| k == "tint" || k == "ids")); set_flag( s, p, move |i| { + if tint_only && i.stroke.is_none() { + return; + } let mut st = if i.stroke.is_none() && p2.get("swatch").is_none() { Stroke::default() } else { i.stroke.clone() }; if let Some(v) = p2.get("swatch").and_then(Value::as_str) { st.swatch = v.into(); @@ -3327,3 +3355,36 @@ mod spread_target_tests { assert!(s.doc().unwrap().doc.spreads[0].items.is_empty()); } } + +#[cfg(test)] +mod fill_tint_tests { + use super::*; + + #[test] + fn a_tint_alone_keeps_each_objects_own_swatch() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [136, 36, 200, 100]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [a], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); + s.execute("object.fill", &json!({"ids": [b], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap(); + s.execute("object.fill", &json!({"ids": [a, b], "tint": 0.4})).unwrap(); + let fill_of = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().fill.clone(); + assert_eq!((fill_of(&s, a).swatch.as_str(), fill_of(&s, a).tint), ("C=100 M=0 Y=0 K=0", 0.4)); + assert_eq!((fill_of(&s, b).swatch.as_str(), fill_of(&s, b).tint), ("C=0 M=100 Y=0 K=0", 0.4)); + // A swatch without a tint is at 100 %, as before. + s.execute("object.fill", &json!({"ids": [a], "swatch": "[Black]"})).unwrap(); + assert_eq!(fill_of(&s, a).tint, 1.0); + // Neither, or a tint out of range: refused. + assert!(s.execute("object.fill", &json!({"ids": [a]})).is_err()); + assert!(s.execute("object.fill", &json!({"ids": [a], "tint": 2})).is_err()); + assert!(s.execute("object.fill", &json!({"ids": [a], "tint": "x"})).is_err()); + // A stroke tint alone: stroked objects take it, an object without a stroke keeps none. + s.execute("object.stroke", &json!({"ids": [a], "swatch": "C=100 M=0 Y=0 K=0", "weight": 2})).unwrap(); + s.execute("object.stroke", &json!({"ids": [b], "swatch": "[None]"})).unwrap(); + s.execute("object.stroke", &json!({"ids": [a, b], "tint": 0.5})).unwrap(); + let stroke_of = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().stroke.clone(); + assert_eq!((stroke_of(&s, a).swatch.as_str(), stroke_of(&s, a).tint, stroke_of(&s, a).weight), ("C=100 M=0 Y=0 K=0", 0.5, 2.0)); + assert!(stroke_of(&s, b).is_none()); + } +} diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 967ff38f..aea4e4ac 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -11,16 +11,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); - let text = super::colors_affect_text(app); - let fill_cur = super::color_target(app).map(|t| t.0); - ui.horizontal(|ui| { - crate::rtl::label( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, if text { "Applies to text" } else { "Applies to fill" })) - .size(11.0) - .color(t.text_dim), - ); - }); + let target = super::color_target(app); + header(app, ui, &doc, target.as_ref()); + // The row of the colour in front of the proxy is highlighted. + let fill_cur = target.map(|(fill, _, stroke, _)| if app.ui.stroke_front { stroke } else { fill }); // Unnamed colours (mixed in the Color panel) aren't listed until Add to Swatches; swatches in // colour groups are listed under their folder. let grouped: Vec<&str> = doc.color_groups.iter().flat_map(|g| g.swatches.iter().map(String::as_str)).collect(); @@ -64,15 +58,25 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } ui.add_space(6.0); + // InDesign's bottom row: New Color Group, New Swatch, Delete Swatch (the one last clicked). + let picked: Option = ui.data(|d| d.get_temp(egui::Id::new(PICKED))).filter(|n: &String| doc.swatch(n).is_some_and(|w| !w.locked)); ui.horizontal(|ui| { - ui.label(crate::rtl::widget( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "Right-click: stroke, groups")).size(10.5).color(t.text_disabled), - )); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let del = ui + .add_enabled_ui(picked.is_some(), |ui| icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete Swatch"))) + .inner; + if del.clicked() + && let Some(name) = &picked + && app.run("swatch.delete", json!({"name": name})).is_ok() + { + ui.data_mut(|d| d.remove::(egui::Id::new(PICKED))); + } if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "New Swatch")).clicked() { let _ = app.run("swatch.create", json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}})); } + if folder_button(ui, crate::i18n::tr(&app.ui.language, "New Color Group")).clicked() { + let _ = app.run("swatch.newColorGroup", json!({})); + } crate::menus::menu_button(ui, "☰", |ui| { crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ink Manager")), |ui| { let Ok(l) = app.session.execute("ink.list", &json!({})) else { return }; @@ -114,8 +118,99 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }); } -/// One Swatches panel row: chip, name, type; click applies to the fill (of the frames or their -/// text, see [`super::colors_affect_text`]), the context menu to the stroke, colour groups and delete. +/// Temp-data key of the swatch last clicked (Delete Swatch removes it). +const PICKED: &str = "swatches_picked"; + +/// The Swatches panel's top row, as InDesign's: a Fill/Stroke proxy (click: the other in front), +/// Formatting Affects Container / Text, and the tint of the colour in front. `target` is +/// [`super::color_target`]: fill and stroke swatches and tints of the text or the first object. +fn header(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, target: Option<&(String, f32, String, f32)>) { + let t = Tokens::get(ui.ctx()); + let (fill_sw, stroke_sw) = target.map_or(("[None]", "[Black]"), |(f, _, s, _)| (f.as_str(), s.as_str())); + let text = super::colors_affect_text(app) || app.ui.formatting_affects_text; + let stroke_front = app.ui.stroke_front; + ui.add_space(3.0); + ui.horizontal(|ui| { + let (r, resp) = ui.allocate_exact_size(vec2(27.0, 27.0), Sense::click()); + let tip = crate::i18n::tr(&app.ui.language, if stroke_front { "Stroke" } else { "Fill" }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + let fill_r = egui::Rect::from_min_size(r.min + vec2(1.0, 2.0), vec2(16.0, 16.0)); + let stroke_r = egui::Rect::from_min_size(r.min + vec2(10.0, 10.0), vec2(16.0, 16.0)); + let paint = |stroke: bool| { + let p = ui.painter(); + if stroke { + // The stroke: a frame of its colour. + let (c, g) = crate::widgets::swatch_colors(doc, stroke_sw, 1.0); + crate::widgets::paint_chip(p, stroke_r, c, g); + p.rect_filled(stroke_r.shrink(4.0), 0.0, t.panel); + p.rect_stroke(stroke_r.shrink(4.0), 0.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); + p.rect_stroke(stroke_r, 0.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); + } else { + let (c, g) = crate::widgets::swatch_colors(doc, fill_sw, 1.0); + crate::widgets::paint_chip(p, fill_r, c, g); + p.rect_stroke(fill_r, 0.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); + } + }; + // The one in front is drawn last. + paint(!stroke_front); + paint(stroke_front); + if resp + .on_hover_ui(|ui| { + crate::rtl::label(ui, tip); + }) + .clicked() + { + let _ = app.run("color.toggleFillStroke", json!({})); + } + ui.add_space(4.0); + for (k, (icon, tip)) in + [("format-container", "Formatting affects container (J)"), ("format-text", "Formatting affects text (J)")].into_iter().enumerate() + { + if icons::button(ui, icon, 20.0, text == (k == 1), crate::i18n::tr(&app.ui.language, tip)).clicked() { + let _ = app.run("app.formattingAffectsText", json!({"on": k == 1})); + } + } + ui.add_space(6.0); + crate::widgets::caption(ui, crate::i18n::tr(&app.ui.language, "Tint:")); + let tint = target.map(|(_, ft, _, st)| f64::from(if stroke_front { *st } else { *ft }) * 100.0); + if let Some(v) = crate::widgets::number(ui, "swatches_tint", tint, "", 44.0, 0) { + let _ = set_tint(app, v); + } + crate::widgets::caption(ui, "%"); + }); +} + +/// The Tint field: `percent` of the colour in front of the proxy, for the text or each selected +/// object, which keep their own swatches. +fn set_tint(app: &mut DesignApp, percent: f64) -> Result { + let v = percent.clamp(0.0, 100.0) / 100.0; + let stroke = app.ui.stroke_front; + if super::colors_affect_text(app) { + app.run("type.char", json!({"attrs": {if stroke { "strokeTint" } else { "fillTint" }: v}})) + } else { + app.run(if stroke { "object.stroke" } else { "object.fill" }, json!({"tint": v})) + } +} + +/// A folder button (New Color Group), drawn like the colour groups' folders. +fn folder_button(ui: &mut egui::Ui, tip: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + let f = egui::Rect::from_center_size(r.center(), vec2(14.0, 10.0)); + ui.painter().rect_stroke(f, 1.5, egui::Stroke::new(1.2, t.icon), egui::StrokeKind::Inside); + ui.painter().line_segment([f.left_top() + vec2(1.0, 3.0), f.right_top() + vec2(-1.0, 3.0)], egui::Stroke::new(1.0, t.icon)); + resp.on_hover_ui(|ui| { + crate::rtl::label(ui, tip); + }) +} + +/// One Swatches panel row: chip, name, type; click applies it to the fill or stroke in front of +/// the proxy (of the frames or their text, see [`super::colors_affect_text`]), the context menu +/// to the stroke, colour groups and delete. fn swatch_row( app: &mut DesignApp, ui: &mut egui::Ui, @@ -126,6 +221,7 @@ fn swatch_row( ) { let t = Tokens::get(ui.ctx()); let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &sw.name)); if fill_cur == Some(sw.name.as_str()) { ui.painter().rect_filled(row, 0.0, t.row_selected); } else if resp.hovered() { @@ -154,7 +250,8 @@ fn swatch_row( } ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim); if resp.clicked() { - let _ = super::apply_swatch(app, false, &sw.name, None); + ui.data_mut(|d| d.insert_temp(egui::Id::new(PICKED), sw.name.clone())); + let _ = super::apply_swatch(app, app.ui.stroke_front, &sw.name, None); } resp.context_menu(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply to Stroke"))).clicked() { @@ -573,3 +670,92 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); } } + +#[cfg(test)] +mod swatches_header_tests { + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + Harness::builder().with_size(vec2(300.0, 520.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ) + } + + fn items(app: &DesignApp) -> Vec { + let st = app.session.doc().unwrap(); + st.selection.items.iter().map(|id| st.doc.item(*id).unwrap().clone()).collect() + } + + #[test] + fn swatches_go_to_the_fill_or_stroke_in_front_and_the_bottom_row_deletes_the_one_clicked() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "unassigned"})).unwrap(); + app.run("swatch.create", json!({"name": "Brand", "color": "#108080"})).unwrap(); + let mut h = harness(app); + h.run_steps(3); + // Fill in front: a swatch is the fill. + h.get_by_label("C=0 M=100 Y=0 K=0").click(); + h.run_steps(2); + assert_eq!(items(h.state())[0].fill.swatch, "C=0 M=100 Y=0 K=0"); + // The proxy brings the stroke to the front; the next swatch is the stroke. + h.get_by_label("Fill").click(); + h.run_steps(2); + assert!(h.state().ui.stroke_front); + h.get_by_label("C=100 M=0 Y=0 K=0").click(); + h.run_steps(2); + let it = &items(h.state())[0]; + assert_eq!((it.fill.swatch.as_str(), it.stroke.swatch.as_str()), ("C=0 M=100 Y=0 K=0", "C=100 M=0 Y=0 K=0")); + // Formatting affects text, from the panel. + h.get_by_label("Formatting affects text (J)").click(); + h.run_steps(2); + assert!(h.state().ui.formatting_affects_text); + h.get_by_label("Formatting affects container (J)").click(); + h.run_steps(2); + assert!(!h.state().ui.formatting_affects_text); + // Delete Swatch removes the swatch last clicked. + h.get_by_label("Brand").click(); + h.run_steps(2); + h.get_by_label("Delete Swatch").click(); + h.run_steps(2); + assert!(h.state().session.doc().unwrap().doc.swatch("Brand").is_none()); + } + + #[test] + fn the_tint_field_keeps_each_objects_own_swatch() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [36, 36, 100, 100], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("frame.create", json!({"rect": [136, 36, 200, 100], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("object.fill", json!({"ids": [a], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); + app.run("object.fill", json!({"ids": [b], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap(); + app.run("object.stroke", json!({"ids": [a, b], "swatch": "[Black]"})).unwrap(); + app.run("selection.set", json!({"ids": [a, b]})).unwrap(); + let mut h = harness(app); + h.run_steps(3); + // The field shows the first selected object's fill tint. + assert!(h.query_all_by_value("100").next().is_some()); + set_tint(h.state_mut(), 40.0).unwrap(); + h.run_steps(2); + assert!(h.query_all_by_value("40").next().is_some(), "the field follows"); + // The stroke in front: the strokes' tints. + h.state_mut().ui.stroke_front = true; + set_tint(h.state_mut(), 70.0).unwrap(); + assert!(items(h.state()).iter().all(|i| i.stroke.tint == 0.7)); + let it = items(h.state()); + assert_eq!((it[0].fill.swatch.as_str(), it[0].fill.tint), ("C=100 M=0 Y=0 K=0", 0.4)); + assert_eq!((it[1].fill.swatch.as_str(), it[1].fill.tint), ("C=0 M=100 Y=0 K=0", 0.4)); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index daaa3f44..04499ff5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -268,7 +268,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together — at start/end of paragraph by default, as in InDesign — widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. -- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. +- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint; a swatch goes to the fill or stroke in front; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. From 11a51d5b665aec8d6e675bd107a8a01514f10d00 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 09:06:57 -0400 Subject: [PATCH 31/32] Release 0.8.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 40 ++++++++++++++++++++-------------------- Cargo.toml | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 81e7a5c6..e77a81be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -887,7 +887,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.7.1" +version = "0.8.0" dependencies = [ "arboard", "designcraft-engine", @@ -905,7 +905,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -921,7 +921,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.7.1" +version = "0.8.0" dependencies = [ "kurbo", "moxcms", @@ -933,7 +933,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -951,7 +951,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-geom", @@ -963,7 +963,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.7.1" +version = "0.8.0" dependencies = [ "caseless", "craft-print", @@ -998,7 +998,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1007,7 +1007,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.7.1" +version = "0.8.0" dependencies = [ "harfrust 0.13.3", "kurbo", @@ -1019,7 +1019,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-doc", "serde", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.7.1" +version = "0.8.0" dependencies = [ "flo_curves", "kurbo", @@ -1043,7 +1043,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1056,7 +1056,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-fonts", @@ -1067,7 +1067,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1080,7 +1080,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1102,7 +1102,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1120,7 +1120,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-doc", "quick-xml 0.38.4", @@ -1129,7 +1129,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1141,7 +1141,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.7.1" +version = "0.8.0" dependencies = [ "craft-ui", "designcraft-color", @@ -1169,7 +1169,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-engine", "designcraft-format", @@ -5697,7 +5697,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.7.1" +version = "0.8.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 9ce011b5..e76609e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.7.1" +version = "0.8.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 23ac4ce3f15c5e4ee2b7f474c9375e0c20da6224 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 12:51:12 -0400 Subject: [PATCH 32/32] fix: integrate batched community PRs (#845's Find scope test uses the Page tool's double-click from #862) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b40a0504..bf096ce3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4429,8 +4429,9 @@ mod tests { let off = app.session.layout().xf(designcraft_engine::doc::SpreadRef::Doc(0)).translation(); let x = off.x + before.doc.spreads[0].pages[0].x + 200.0; let y = off.y + 200.0; + // The Page tool opens Page Size on a double-click (a click selects the page). let events = if tool == "page" { - json!([{"kind": "up", "x": x, "y": y}]) + json!([{"kind": "doubleclick", "x": x, "y": y}]) } else { json!([{"kind": "down", "x": x, "y": y}, {"kind": "up", "x": x, "y": y}]) };