From 61504dae488c9bbc0c9b876e37174cf003c6cd2f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 11 Oct 2026 20:46:38 +0000 Subject: [PATCH] View: draw the document grid (#530) Show/Hide Document Grid (View > Grids & Guides, Cmd+') draws the grid on the positions Snap to Document Grid uses: subdivision and major lines from each page's top-left corner, across the spread's band of the pasteboard, behind or in front of page items as the grid settings say. Dense lines are left out at low zoom. The Properties toggle runs the command. --- ROADMAP.md | 2 + crates/render/src/grid.rs | 139 ++++++++++++++++++++++++ crates/render/src/lib.rs | 14 +++ crates/ui-egui/src/canvas.rs | 126 +++++++++++++++------ crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/i18n.rs | 4 + 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/uk.rs | 1 + crates/ui-egui/src/menus.rs | 26 +++++ crates/ui-egui/src/panels/properties.rs | 4 +- docs/control-protocol.md | 2 +- docs/target-app-parity.md | 2 +- 14 files changed, 286 insertions(+), 39 deletions(-) create mode 100644 crates/render/src/grid.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9a0f71e70..ba91c3337 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-11** · The document grid is drawn (View ▸ Grids & Guides ▸ Show/Hide Document Grid, + ⌘'), on the lines Snap to Document Grid uses, behind or in front of page items (#530). - **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change. - **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI smoke-tested under QEMU). diff --git a/crates/render/src/grid.rs b/crates/render/src/grid.rs new file mode 100644 index 000000000..6d3f201b8 --- /dev/null +++ b/crates/render/src/grid.rs @@ -0,0 +1,139 @@ +//! The document grid on screen (View › Grids & Guides › Show/Hide Document Grid). +//! +//! The lines are the positions Snap to Document Grid snaps to: every `vertical / subdivisions` +//! on x and `horizontal / subdivisions` on y, counted from the top-left corner of the page that +//! owns that x (`Spread::page_at_x`: a page owns its own width, the outer pages also own the +//! pasteboard beside them). + +use designcraft_doc::{DocumentGrid, Spread}; +use designcraft_geom::Rect; +use vello_cpu::{RenderContext, peniko}; + +use crate::Frame; + +/// Lines closer together than this many device pixels are left out: subdivisions first, then +/// major lines. +const MIN_GAP_PX: f64 = 6.0; +/// Most lines drawn on one axis of one page column. A denser grid draws nothing on that axis. +const MAX_LINES: f64 = 4096.0; + +/// The step between drawn lines on one axis and how many steps make a major line, or `None` +/// when the spacing is unusable or every line would be closer than [`MIN_GAP_PX`]. +fn axis_step(major: f64, subdivisions: u32, px: f64) -> Option<(f64, i64)> { + if !major.is_finite() || major <= 0.0 || !px.is_finite() || px <= 0.0 { + return None; + } + let sub = subdivisions.max(1); + let minor = major / f64::from(sub); + if minor.is_finite() && minor / px >= MIN_GAP_PX { + return Some((minor, i64::from(sub))); + } + (major / px >= MIN_GAP_PX).then_some((major, 1)) +} + +/// Lines `origin + n × step` in `[lo, hi)`, each with whether it is a major line. +fn lines(origin: f64, (step, every): (f64, i64), lo: f64, hi: f64) -> Vec<(f64, bool)> { + let n0 = ((lo - origin) / step).ceil(); + let n1 = ((hi - origin) / step).ceil() - 1.0; + if !n0.is_finite() || !n1.is_finite() || n1 < n0 || n1 - n0 >= MAX_LINES { + return vec![]; + } + let (n0, n1) = (n0 as i64, n1 as i64); + (n0..=n1).map(|n| (n as f64 * step + origin, n.rem_euclid(every.max(1)) == 0)).collect() +} + +/// Page columns of `sp` across `[x0, x1)`: each x range with the bounds origin of its page. +fn columns(sp: &Spread, x0: f64, x1: f64) -> Vec<(f64, f64, f64, f64)> { + let mut cuts: Vec = vec![x0, x1]; + for p in &sp.pages { + for x in [p.x, p.x + p.width] { + if x > x0 && x < x1 { + cuts.push(x); + } + } + } + cuts.retain(|x| x.is_finite()); + cuts.sort_by(f64::total_cmp); + cuts.dedup(); + cuts.windows(2) + .filter_map(|w| { + let (a, b) = (*w.first()?, *w.get(1)?); + let page = sp.pages.get(sp.page_at_x((a + b) / 2.0)?)?; + let o = page.bounds(); + Some((a, b, o.x0, o.y0)) + }) + .collect() +} + +/// Draws the grid over `area` (spread coordinates) of `sp`, clipped to the visible area. +pub(crate) fn draw(ctx: &mut RenderContext, f: &Frame, sp: &Spread, grid: &DocumentGrid, area: Rect) { + let area = area.intersect(f.visible); + if area.width() <= 0.0 || area.height() <= 0.0 { + return; + } + if let Some(xs) = axis_step(grid.vertical, grid.subdivisions, f.px) { + draw_columns(ctx, f, sp, grid, area, xs); + } + if let Some(ys) = axis_step(grid.horizontal, grid.subdivisions, f.px) { + draw_rows(ctx, f, sp, grid, area, ys); + } +} + +fn paints(grid: &DocumentGrid) -> [peniko::Color; 2] { + let [r, g, b] = grid.color; + [peniko::Color::from_rgba8(r, g, b, 110), peniko::Color::from_rgba8(r, g, b, 255)] +} + +/// Vertical lines. +fn draw_columns(ctx: &mut RenderContext, f: &Frame, sp: &Spread, grid: &DocumentGrid, area: Rect, xs: (f64, i64)) { + let half = f.px / 2.0; + ctx.set_transform(f.view); + let cols = columns(sp, area.x0, area.x1); + for (major, paint) in [false, true].into_iter().zip(paints(grid)) { + ctx.set_paint(paint); + for &(a, b, ox, _) in &cols { + for (x, m) in lines(ox, xs, a, b) { + if m == major { + ctx.fill_rect(&Rect::new(x - half, area.y0, x + half, area.y1)); + } + } + } + } +} + +/// Horizontal lines, each page column from its own page's top. +fn draw_rows(ctx: &mut RenderContext, f: &Frame, sp: &Spread, grid: &DocumentGrid, area: Rect, ys: (f64, i64)) { + let half = f.px / 2.0; + ctx.set_transform(f.view); + let cols = columns(sp, area.x0, area.x1); + for (major, paint) in [false, true].into_iter().zip(paints(grid)) { + ctx.set_paint(paint); + for &(a, b, _, oy) in &cols { + for (y, m) in lines(oy, ys, area.y0, area.y1) { + if m == major { + ctx.fill_rect(&Rect::new(a, y - half, b, y + half)); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unusable_spacing_draws_nothing_and_dense_grids_thin_out() { + for bad in [0.0, -9.0, f64::NAN, f64::INFINITY] { + assert_eq!(axis_step(bad, 8, 1.0), None); + } + assert_eq!(axis_step(72.0, 8, 1.0), Some((9.0, 8))); + // 9 pt subdivisions at 2 pt per pixel are 4.5 px apart: only major lines. + assert_eq!(axis_step(72.0, 8, 2.0), Some((72.0, 1))); + assert_eq!(axis_step(72.0, 0, 1.0), Some((72.0, 1))); + assert_eq!(axis_step(72.0, 8, 100.0), None); + // A span too wide for the cap draws no lines instead of looping. + assert!(lines(0.0, (1e-9, 1), 0.0, 1e9).is_empty()); + assert_eq!(lines(10.0, (9.0, 8), 0.0, 30.0), vec![(1.0, false), (10.0, true), (19.0, false), (28.0, false)]); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c1b288def..5ff6179b2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -25,6 +25,7 @@ pub use vello_cpu; pub mod damage; mod fx; +mod grid; /// How far (points) an object's soft effects reach outside its geometry. pub fn effect_outset(it: &Item) -> f64 { @@ -154,6 +155,10 @@ pub struct RenderOptions { /// Screen: spreads with transparency are shown as blended in the document's blend space (a /// CMYK blend space keeps their colours in the working CMYK gamut). pub blend_space_view: bool, + /// View › Show Document Grid: the area (spread coordinates) of each listed spread that the + /// document grid covers, drawn behind or in front of the page items as the grid settings say + /// (screen view). Empty draws no grid. + pub document_grid: Vec<(SpreadRef, Rect)>, } /// View › Display Performance: how placed graphics and effects are drawn on screen. @@ -191,6 +196,7 @@ impl Default for RenderOptions { rich_black: false, overprint_preview: false, blend_space_view: false, + document_grid: vec![], } } } @@ -394,6 +400,11 @@ impl Renderer { } } } + let grid_area = f.opts.document_grid.iter().find(|(r, _)| *r == sr).map(|(_, a)| *a); + let grid = &doc.settings.grid; + if let Some(area) = grid_area.filter(|_| grid.in_back) { + grid::draw(ctx, f, sp, grid, area); + } // Layers back to front; within each layer parent items first (document spreads only). for layer in doc.layers.iter().rev() { if !layer.visible || f.opts.printing_only && !layer.printable { @@ -429,6 +440,9 @@ impl Renderer { } } } + if let Some(area) = grid_area.filter(|_| !grid.in_back) { + grid::draw(ctx, f, sp, grid, area); + } } fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page_name: Option<&str>) { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4c7313e61..970ee8a7c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -364,6 +364,46 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { } } +/// Render options for the canvas texture. +fn screen_options(app: &DesignApp, layout: &CanvasLayout, preview: bool, ppp: f64) -> designcraft_render::RenderOptions { + designcraft_render::RenderOptions { + printing_only: preview, + greek_below_px: 3.0 * ppp, + highlight_missing_fonts: !preview && app.session.prefs.highlight_substituted_fonts, + highlight_hj: !preview && app.session.prefs.highlight_hj, + condition_indicators: !preview, + note_indicators: !preview && app.session.prefs.show_note_anchors, + change_markup: !preview && app.session.prefs.show_added_text, + tag_markers: !preview && app.ui.tag_markers, + plate: app.ui.separation, + highlight_keeps: !preview && app.session.prefs.highlight_keeps, + highlight_custom_tracking: !preview && app.session.prefs.highlight_custom_tracking, + quality: app.ui.display_quality, + rich_black: app.ui.rich_black, + overprint_preview: app.ui.overprint_preview, + blend_space_view: !app.ui.proof_colors, + document_grid: if app.ui.document_grid && !preview { grid_areas(layout) } else { vec![] }, + ..Default::default() + } +} + +/// The area each spread's document grid covers, in spread coordinates: the full-width band of +/// the pasteboard nearest that spread, the band in which `CanvasLayout::spread_at` (and so +/// snapping) picks it. +fn grid_areas(layout: &CanvasLayout) -> Vec<(designcraft_doc::SpreadRef, DRect)> { + let pb = layout.pasteboard; + let slots = &layout.slots; + slots + .iter() + .enumerate() + .map(|(i, slot)| { + let lo = i.checked_sub(1).and_then(|j| slots.get(j)).map_or(pb.y0, |prev| (prev.bounds.y1 + slot.bounds.y0) / 2.0); + let hi = slots.get(i + 1).map_or(pb.y1, |next| (slot.bounds.y1 + next.bounds.y0) / 2.0); + (slot.spread, slot.xf.inverse().transform_rect_bbox(DRect::new(pb.x0, lo, pb.x1, hi))) + }) + .collect() +} + /// Keep the canvas texture current. Panning inside the overscanned region just shifts the /// texture; zooming shows the old texture scaled until the background render arrives; document /// edits render synchronously so editing never lags. @@ -414,24 +454,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, let w = (target.size.0 as f64 * ppp).round().max(1.0) as u32; let h = (target.size.1 as f64 * ppp).round().max(1.0) as u32; let view = Affine::scale(ppp) * Affine::scale(xf.zoom) * Affine::translate((-target.origin.x, -target.origin.y)); - let opts = designcraft_render::RenderOptions { - printing_only: preview, - greek_below_px: 3.0 * ppp, - highlight_missing_fonts: !preview && app.session.prefs.highlight_substituted_fonts, - highlight_hj: !preview && app.session.prefs.highlight_hj, - condition_indicators: !preview, - note_indicators: !preview && app.session.prefs.show_note_anchors, - change_markup: !preview && app.session.prefs.show_added_text, - tag_markers: !preview && app.ui.tag_markers, - plate: app.ui.separation, - highlight_keeps: !preview && app.session.prefs.highlight_keeps, - highlight_custom_tracking: !preview && app.session.prefs.highlight_custom_tracking, - quality: app.ui.display_quality, - rich_black: app.ui.rich_black, - overprint_preview: app.ui.overprint_preview, - blend_space_view: !app.ui.proof_colors, - ..Default::default() - }; + let opts = screen_options(app, layout, preview, ppp); Some((st.doc.clone(), placed, w, h, view, opts)) }; // Synchronous path: document edits (keep editing crisp), first frame, or no worker. @@ -527,24 +550,7 @@ fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key: * Affine::scale(ppp) * Affine::scale(sh.zoom) * Affine::translate((-sh.origin.x, -sh.origin.y)); - let opts = designcraft_render::RenderOptions { - printing_only: preview, - greek_below_px: 3.0 * ppp, - highlight_missing_fonts: !preview && app.session.prefs.highlight_substituted_fonts, - highlight_hj: !preview && app.session.prefs.highlight_hj, - condition_indicators: !preview, - note_indicators: !preview && app.session.prefs.show_note_anchors, - change_markup: !preview && app.session.prefs.show_added_text, - tag_markers: !preview && app.ui.tag_markers, - plate: app.ui.separation, - highlight_keeps: !preview && app.session.prefs.highlight_keeps, - highlight_custom_tracking: !preview && app.session.prefs.highlight_custom_tracking, - quality: app.ui.display_quality, - rich_black: app.ui.rich_black, - overprint_preview: app.ui.overprint_preview, - blend_space_view: !app.ui.proof_colors, - ..Default::default() - }; + let opts = screen_options(app, layout, preview, ppp); let img = app.canvas.patcher.render(&new, &app.session.cache, &placed, (x1 - x0) as u32, (y1 - y0) as u32, view, &opts); let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); if let Some(tex) = app.canvas.texture.as_mut() { @@ -1798,6 +1804,54 @@ mod tests { } } + #[test] + fn document_grid_is_drawn_where_points_snap_to_it() { + use designcraft_tools::{SnapView, ToolContext}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let layout = CanvasLayout::new(&doc, false); + let sr = SpreadRef::Doc(0); + // Where the grid snaps a point in the first page (72 pt / 8 subdivisions = 9 pt). + let page = doc.spreads[0].pages[0].bounds(); + let sel = Selection::default(); + let cx = ToolContext { + doc: &doc, + selection: &sel, + cache: &app.session.cache, + layout: &layout, + zoom: 1.0, + layer: doc.layers[0].id, + snap: SnapView { snap_to_document_grid: true, zone_px: 20.0, ..SnapView::OFF }, + unit: Unit::Points, + }; + let (snapped, _) = designcraft_tools::snap::snap_point(&cx, sr, Point::new(page.x0 + 31.3, page.y0 + 47.9)); + assert_eq!((snapped.x - page.x0, snapped.y - page.y0), (27.0, 45.0)); + // Render 40 × 40 pt around it at 4 px/pt. + let scale = 4.0; + let corner = layout.xf(sr) * Point::new(snapped.x - 20.0, snapped.y - 20.0); + let view = Affine::scale(scale) * Affine::translate((-corner.x, -corner.y)); + let placed: Vec<_> = layout.slots.iter().map(|s| designcraft_render::Placed { spread: s.spread, xf: s.xf }).collect(); + let render = |app: &DesignApp| { + let opts = screen_options(app, &layout, false, 1.0); + designcraft_render::Renderer::new().render(&doc, &app.session.cache, &placed, 160, 160, view, &opts) + }; + // The snapped point is at pixel (80, 80); halfway to the next line is 18 px further. + let darkest = |img: &designcraft_render::Rendered, xs: [u32; 2], ys: [u32; 2]| { + (xs[0]..xs[1]).flat_map(|x| (ys[0]..ys[1]).map(move |y| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap() + }; + let off = render(&app); + assert_eq!(darkest(&off, [78, 82], [60, 64]), 255, "no grid while it is hidden"); + app.run("view.documentGrid", json!({})).unwrap(); + let on = render(&app); + assert!(darkest(&on, [79, 81], [60, 64]) < 250, "a vertical line at the snapped x"); + assert!(darkest(&on, [60, 64], [79, 81]) < 250, "a horizontal line at the snapped y"); + assert_eq!(darkest(&on, [96, 100], [96, 100]), 255, "nothing between lines"); + // Preview hides it. + app.ui.screen_mode = ScreenMode::Preview; + assert!(screen_options(&app, &layout, true, 1.0).document_grid.is_empty()); + } + #[test] fn rulers_open_no_units_menu_without_a_document() { let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default())); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f0d34a7ba..b3624f315 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -247,6 +247,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> ("frameEdges", "view.frameEdges"), ("guides", "view.guides"), ("baselineGrid", "view.baselineGrid"), + ("documentGrid", "view.documentGrid"), ("textThreads", "view.textThreads"), ] { if let Some(want) = p.get(k).and_then(Value::as_bool) { @@ -255,6 +256,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> "frameEdges" => app.ui.frame_edges, "guides" => app.ui.guides, "baselineGrid" => app.ui.baseline_grid, + "documentGrid" => app.ui.document_grid, _ => app.ui.text_threads, }; if cur != want { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e5e8a8d08..d9553b0df 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1174,6 +1174,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Show/Hide Baseline Grid", ["Show/Hide Baseline Grid", "Show/Hide Baseline Grid", "Show/Hide Baseline Grid", "Show/Hide Baseline Grid", "显示/隐藏基线网格"], ), + ( + "Show/Hide Document Grid", + ["Show/Hide Document Grid", "Show/Hide Document Grid", "Show/Hide Document Grid", "Show/Hide Document Grid", "显示/隐藏文档网格"], + ), ( "Show/Hide Frame Edges", ["Show/Hide Frame Edges", "Show/Hide Frame Edges", "Show/Hide Frame Edges", "Show/Hide Frame Edges", "显示/隐藏框架边缘"], diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 10e393cd2..d6a025b1c 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1310,6 +1310,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set Visibility / Lock", "تعيين الظهور / القفل"), ("Show Panel", "إظهار اللوحة"), ("Show/Hide Baseline Grid", "إظهار/إخفاء شبكة خط الأساس"), + ("Show/Hide Document Grid", "إظهار/إخفاء شبكة المستند"), ("Show/Hide Frame Edges", "إظهار/إخفاء حواف الإطارات"), ("Show/Hide Guides", "إظهار/إخفاء الأدلة"), ("Show/Hide Hidden Characters", "إظهار/إخفاء الأحرف المخفية"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index c91afd6ba..34b9a7cd5 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -647,6 +647,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set Visibility / Lock", "Imposta visibilità / blocco"), ("Show Panel", "Mostra pannello"), ("Show/Hide Baseline Grid", "Mostra/nascondi griglia linee di base"), + ("Show/Hide Document Grid", "Mostra/nascondi griglia documento"), ("Show/Hide Frame Edges", "Mostra/nascondi bordi cornice"), ("Show/Hide Guides", "Mostra/nascondi guide"), ("Show/Hide Hidden Characters", "Mostra/nascondi caratteri nascosti"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index be6e2c4da..36e36e240 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -649,6 +649,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set Visibility / Lock", "表示・ロックを設定"), ("Show Panel", "パネルを表示"), ("Show/Hide Baseline Grid", "ベースライングリッドを表示・非表示"), + ("Show/Hide Document Grid", "ドキュメントグリッドを表示・非表示"), ("Show/Hide Frame Edges", "フレームの枠を表示・非表示"), ("Show/Hide Guides", "ガイドを表示・非表示"), ("Show/Hide Hidden Characters", "制御文字を表示・非表示"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9a9fb89ba..7b586bd7d 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -650,6 +650,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set Visibility / Lock", "Установити видимість / блокування"), ("Show Panel", "Показати панель"), ("Show/Hide Baseline Grid", "Показати/приховати сітку базових ліній"), + ("Show/Hide Document Grid", "Показати/приховати сітку документа"), ("Show/Hide Frame Edges", "Показати/приховати межі фреймів"), ("Show/Hide Guides", "Показати/приховати напрямні"), ("Show/Hide Hidden Characters", "Показати/приховати приховані символи"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d123ec4eb..7e9507edf 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -98,6 +98,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "{alignEdges?, alignCenters?, smartDimensions?, smartSpacing?: bool, zone?: px} — what smart guides snap to and how close; returns the current values", ), ("view.baselineGrid", "Show/Hide Baseline Grid", Some("Cmd+Alt+'"), "{}"), + ("view.documentGrid", "Show/Hide Document Grid", Some("Cmd+'"), "{}"), ("view.textThreads", "Show/Hide Text Threads", Some("Cmd+Alt+Y"), "{}"), ("view.hiddenCharacters", "Show/Hide Hidden Characters", Some("Cmd+Alt+I"), "{}"), ("view.taggedFrames", "Show/Hide Tagged Frames", None, "{} — XML-tagged frames outlined in their tag colour"), @@ -646,6 +647,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:view.snapToDocumentGrid", "ui:view.smartGuides", "ui:view.baselineGrid", + "ui:view.documentGrid", "-", "ui:app.deleteAllGuides", "<", @@ -1131,6 +1133,11 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.snap_to_document_grid), "view.smartGuides" => flag(&mut app.ui.smart_guides), "view.baselineGrid" => flag(&mut app.ui.baseline_grid), + "view.documentGrid" => { + // The grid is drawn into the canvas texture. + app.canvas.shown = None; + flag(&mut app.ui.document_grid) + } "view.textThreads" => flag(&mut app.ui.text_threads), "view.hiddenCharacters" => flag(&mut app.ui.hidden_characters), "view.taggedFrames" => flag(&mut app.ui.tagged_frames), @@ -1766,6 +1773,7 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "view.snapToDocumentGrid" => app.ui.snap_to_document_grid, "view.smartGuides" => app.ui.smart_guides, "view.baselineGrid" => app.ui.baseline_grid, + "view.documentGrid" => app.ui.document_grid, "view.textThreads" => app.ui.text_threads, "view.hiddenCharacters" => app.ui.hidden_characters, "view.taggedFrames" => app.ui.tagged_frames, @@ -2635,6 +2643,24 @@ mod tests { assert!(!bars[0].intersects(bars[1]), "each pane keeps its own bar: {bars:?}"); } + #[test] + fn show_document_grid_toggles_from_the_grids_and_guides_menu() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let tree = menu_tree(); + let mut items = vec![]; + for (_, entries) in &tree { + walk(entries, &mut items); + } + assert!(items.iter().any(|(label, id, _)| id == "view.documentGrid" && label == "Show/Hide Document Grid")); + assert_eq!(shortcut_of(&app, "view.documentGrid").as_deref(), Some("Cmd+'")); + assert_eq!(checked(&app, "view.documentGrid", &json!({})), Some(false)); + assert_eq!(run_ui(&mut app, "view.documentGrid", &json!({})).unwrap().unwrap(), json!(true)); + assert!(app.ui.document_grid); + assert_eq!(checked(&app, "view.documentGrid", &json!({})), Some(true)); + run_ui(&mut app, "view.documentGrid", &json!({})).unwrap().unwrap(); + assert!(!app.ui.document_grid); + } + #[test] fn interface_language_switches_and_validates() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..18d3cf241 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -345,7 +345,9 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { if bl != app.ui.baseline_grid { let _ = app.run("view.baselineGrid", json!({})); } - app.ui.document_grid = dg; + if dg != app.ui.document_grid { + let _ = app.run("view.documentGrid", json!({})); + } rule(ui); let (mut guides, mut locked, mut smart) = (app.ui.guides, app.ui.guides_locked, app.ui.smart_guides); section_row(ui, crate::i18n::tr(&app.ui.language, "Guides"), |ui| { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d97c2bad8..54e6d75e4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -40,7 +40,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `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.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state | +| `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, documentGrid?, 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 | | `ui.resize`, `ui.focus` | | Window control | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8044ed51b..0fb26c74a 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, 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, 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, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), document grid (View ▸ Grids & Guides, ⌘', on the snap positions, behind or in front of items), 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. - 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.