diff --git a/ROADMAP.md b/ROADMAP.md index d595448ed..65ccf7610 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -102,6 +102,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Preferences: Dictionary (user dictionary; `spelling.setWords/words`), Spelling (Dynamic Spelling: red squiggles on the canvas, also Edit ▸ Spelling), Autocorrect (word list, applied as you type), Notes, Track Changes, Story Editor Display and File Handling (recovery interval) sections. - Colour management exposed: Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, intent, black-point compensation; `color.loadProfile` for ICC files; `color.convert` through the working spaces, with gamut checks) and View ▸ Proof Setup / Proof Colors (press, sRGB, legacy Mac RGB, colour-blindness simulations, simulate paper) on the canvas. - Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout. +- View ▸ Rotate Spread (`view.rotateSpread`: 90° CW / CCW, 180°, clear): the view turns in quarter turns about its centre; tools, hit testing, the render texture and overlays follow; rulers hide while rotated. InDesign turns just the one spread. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1c1daec2f..4dacfc9e2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -16,19 +16,51 @@ pub const RULER: f32 = 15.0; /// Canvas ↔ screen transform. #[derive(Clone, Copy, Debug)] pub struct Xf { + /// Top left of the (unrotated) view rectangle. pub min: Pos2, pub origin: Point, pub zoom: f64, + /// Quarter turns clockwise about `pivot` (the view centre). + pub rot: u8, + pub pivot: Pos2, +} + +/// Rotate `p` by `q` quarter turns clockwise about `c` (screen space, y down). +fn quarter(p: Pos2, c: Pos2, q: u8) -> Pos2 { + let (dx, dy) = (p.x - c.x, p.y - c.y); + let (x, y) = match q % 4 { + 0 => (dx, dy), + 1 => (-dy, dx), + 2 => (-dx, -dy), + _ => (dy, -dx), + }; + pos2(c.x + x, c.y + y) +} + +/// The unrotated view rectangle for a screen rectangle and a rotation (width and height swap +/// for quarter turns). +pub fn view_rect(screen: Rect, rot: u8) -> Rect { + if rot % 2 == 1 { Rect::from_center_size(screen.center(), vec2(screen.height(), screen.width())) } else { screen } } impl Xf { pub fn new(rect: Rect, v: &View) -> Self { - Xf { min: rect.min, origin: v.origin, zoom: v.zoom } + Xf { min: rect.min, origin: v.origin, zoom: v.zoom, rot: v.rotation % 4, pivot: rect.center() } } pub fn to_screen(&self, p: Point) -> Pos2 { - pos2(self.min.x + ((p.x - self.origin.x) * self.zoom) as f32, self.min.y + ((p.y - self.origin.y) * self.zoom) as f32) + let s = pos2(self.min.x + ((p.x - self.origin.x) * self.zoom) as f32, self.min.y + ((p.y - self.origin.y) * self.zoom) as f32); + if self.rot == 0 { s } else { quarter(s, self.pivot, self.rot) } + } + /// A screen position in the unrotated view. + pub fn unrotate(&self, p: Pos2) -> Pos2 { + if self.rot == 0 { p } else { quarter(p, self.pivot, 4 - self.rot) } + } + /// A screen-space movement in the unrotated view. + pub fn unrotate_delta(&self, d: egui::Vec2) -> egui::Vec2 { + self.unrotate(self.pivot + d) - self.pivot } pub fn to_canvas(&self, p: Pos2) -> Point { + let p = self.unrotate(p); Point::new(self.origin.x + (p.x - self.min.x) as f64 / self.zoom, self.origin.y + (p.y - self.min.y) as f64 / self.zoom) } pub fn rect(&self, r: DRect) -> Rect { @@ -36,9 +68,12 @@ impl Xf { } /// Canvas → screen affine (for vello rendering: pixels = points × ppp). pub fn affine(&self, ppp: f64, area_min: Pos2) -> Affine { - let dx = (self.min.x - area_min.x) as f64; - let dy = (self.min.y - area_min.y) as f64; - Affine::scale(ppp) * Affine::translate((dx, dy)) * Affine::scale(self.zoom) * Affine::translate((-self.origin.x, -self.origin.y)) + let unrotated = Affine::translate((self.min.x as f64, self.min.y as f64)) + * Affine::scale(self.zoom) + * Affine::translate((-self.origin.x, -self.origin.y)); + let (cx, cy) = (self.pivot.x as f64, self.pivot.y as f64); + let rot = Affine::translate((cx, cy)) * Affine::rotate(self.rot as f64 * std::f64::consts::FRAC_PI_2) * Affine::translate((-cx, -cy)); + Affine::scale(ppp) * Affine::translate((-(area_min.x as f64), -(area_min.y as f64))) * rot * unrotated } } @@ -82,7 +117,7 @@ pub fn fit(app: &mut DesignApp, rect: Rect, what: &str) { let zoom = ((rect.width() as f64 - 2.0 * pad) / b.width()).min((rect.height() as f64 - 2.0 * pad) / b.height()).clamp(0.05, 40.0); let origin = Point::new(b.center().x - rect.width() as f64 / 2.0 / zoom, b.center().y - rect.height() as f64 / 2.0 / zoom); if let Some(v) = app.view_mut() { - *v = View { zoom, origin, fitted: true }; + *v = View { zoom, origin, fitted: true, rotation: v.rotation }; } } @@ -133,6 +168,7 @@ pub fn zoom_at(app: &mut DesignApp, screen: Pos2, factor: f64) { let Some(v) = app.view_mut() else { return }; let xf = Xf::new(rect, v); let c = xf.to_canvas(screen); + let screen = xf.unrotate(screen); let nz = (v.zoom * factor).clamp(0.05, 40.0); v.zoom = nz; v.origin = Point::new(c.x - (screen.x - rect.min.x) as f64 / nz, c.y - (screen.y - rect.min.y) as f64 / nz); @@ -196,8 +232,11 @@ fn mods(i: &egui::InputState, space: bool) -> Mods { pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let full = ui.available_rect_before_wrap(); - let rulers = app.ui.rulers && app.ui.screen_mode != ScreenMode::Presentation; - let rect = if rulers { Rect::from_min_max(full.min + vec2(RULER, RULER), full.max) } else { full }; + let rot = app.view().map_or(0, |v| v.rotation % 4); + // Rulers measure the unrotated layout, so they hide while the view is rotated. + let rulers = app.ui.rulers && app.ui.screen_mode != ScreenMode::Presentation && rot == 0; + let screen = if rulers { Rect::from_min_max(full.min + vec2(RULER, RULER), full.max) } else { full }; + let rect = view_rect(screen, rot); app.canvas_rect = Some(rect); let resp = ui.allocate_rect(full, Sense::click_and_drag()); if !app.view().is_some_and(|v| v.fitted) { @@ -209,10 +248,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let xf = Xf::new(rect, &v); let doc = st.doc.clone(); let layout = CanvasLayout::new(&doc, st.editing_parents); - let painter = ui.painter_at(rect); + let painter = ui.painter_at(screen); let preview = matches!(app.ui.screen_mode, ScreenMode::Preview | ScreenMode::Presentation); let bg = if app.ui.screen_mode == ScreenMode::Presentation { Color32::BLACK } else { t.pasteboard }; - painter.rect_filled(rect, 0.0, bg); + painter.rect_filled(screen, 0.0, bg); // Page shadow: a hard 1.5 pt black offset on the right and bottom (InDesign 2026). for slot in &layout.slots { let r = xf.rect(slot.bounds); @@ -223,18 +262,28 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { if let (Some(tex), Some(sh)) = (&app.canvas.texture, app.canvas.shown) { // Place the texture where its region is in the current view (shifted while panning, // scaled while a zoom re-render is pending). - let k = (xf.zoom / sh.zoom) as f32; - let tl = xf.to_screen(sh.origin); - let tex_rect = Rect::from_min_size(tl, vec2(sh.size.0 * k, sh.size.1 * k)); - let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + // The texture's corners in canvas space, placed through the view (rotation included). + let (w, h) = (sh.size.0 as f64 / sh.zoom, sh.size.1 as f64 / sh.zoom); + let o = sh.origin; + let corners = [ + (o, pos2(0.0, 0.0)), + (Point::new(o.x + w, o.y), pos2(1.0, 0.0)), + (Point::new(o.x + w, o.y + h), pos2(1.0, 1.0)), + (Point::new(o.x, o.y + h), pos2(0.0, 1.0)), + ]; + let mut mesh = egui::Mesh::with_texture(tex.id()); + for (c, uv) in corners { + mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(c), uv, color: Color32::WHITE }); + } + mesh.indices.extend([0, 1, 2, 0, 2, 3]); if preview { // Preview: only page areas (trim) show content. for slot in &layout.slots { let r = xf.rect(slot.bounds); - painter.with_clip_rect(r.intersect(rect)).image(tex.id(), tex_rect, uv, Color32::WHITE); + painter.with_clip_rect(r.intersect(screen)).add(egui::Shape::mesh(mesh.clone())); } } else { - painter.image(tex.id(), tex_rect, uv, Color32::WHITE); + painter.add(egui::Shape::mesh(mesh)); } } let hair = 1.0 / ui.ctx().pixels_per_point(); @@ -268,12 +317,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } if let Some(r) = sel_rect && !preview - && r.intersects(rect) + && r.intersects(screen) { - crate::taskbar::show(app, &ui.ctx().clone(), r, rect); + crate::taskbar::show(app, &ui.ctx().clone(), r, screen); } // Cursor. - if let Some(p) = resp.hover_pos().filter(|p| rect.contains(*p)) { + if let Some(p) = resp.hover_pos().filter(|p| screen.contains(*p)) { let space = ui.input(|i| i.key_down(egui::Key::Space)) && !app.session.wants_text(); let c = if space { Cursor::Hand } else { app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) }; ui.ctx().set_cursor_icon(cursor_icon(c)); @@ -1262,6 +1311,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r } else if scroll != egui::Vec2::ZERO && let Some(v) = app.view_mut() { + let scroll = xf.unrotate_delta(scroll); v.origin = Point::new(v.origin.x - scroll.x as f64 / v.zoom, v.origin.y - scroll.y as f64 / v.zoom); } } @@ -1270,13 +1320,14 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r // Distribute while resizing). let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::new(("canvas_pointer_down", app.pane)))).unwrap_or(false); if space && !tool_drag && resp.dragged() { - let d = resp.drag_delta(); + let d = xf.unrotate_delta(resp.drag_delta()); if let Some(v) = app.view_mut() { v.origin = Point::new(v.origin.x - d.x as f64 / v.zoom, v.origin.y - d.y as f64 / v.zoom); } return; } - if !space && guide_drag(app, ui, resp, rect, &xf) { + // Ruler guides need the rulers (hidden while the view is rotated). + if !space && xf.rot == 0 && guide_drag(app, ui, resp, rect, &xf) { return; } let m = ui.input(|i| mods(i, space)); @@ -1296,7 +1347,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r }); if pressed && !space - && let Some(o) = origin.filter(|o| rect.contains(*o) && resp.hovered()) + && let Some(o) = origin.filter(|o| (if xf.rot == 0 { rect.contains(*o) } else { resp.rect.contains(*o) }) && resp.hovered()) { events.push(PointerEvent { kind: if dbl { PointerKind::DoubleClick } else { PointerKind::Down }, pos: pos(o), mods: m }); down = true; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 95139816e..fe490badc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -232,11 +232,13 @@ pub struct View { pub zoom: f64, pub origin: Point, pub fitted: bool, + /// View › Rotate Spread: quarter turns clockwise (0–3). + pub rotation: u8, } impl Default for View { fn default() -> Self { - View { zoom: 0.5, origin: Point::new(-100.0, -100.0), fitted: false } + View { zoom: 0.5, origin: Point::new(-100.0, -100.0), fitted: false, rotation: 0 } } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b3d033bb9..e0cfba3f5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -77,6 +77,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("window.hidePanelsExceptTools", "Show/Hide Panels Except Tools", Some("Shift+Tab"), "{}"), ("window.nextDocument", "Next Document", Some("Cmd+F6"), "{}"), ("window.previousDocument", "Previous Document", Some("Cmd+Shift+F6"), "{}"), + ("view.rotateSpread", "Rotate Spread", None, "{angle: 90 (clockwise) | -90 | 180 | 0 (clear)} — turn the view in quarter turns"), ("view.proofColors", "Proof Colors", None, "{on?: bool} — simulate the proof target on screen"), ( "view.proofSetup", @@ -333,6 +334,12 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", "ui:view.hiddenCharacters", "ui:view.taggedFrames", + ">Rotate Spread", + "ui:view.rotateSpread|90° CW|{\"angle\": 90}", + "ui:view.rotateSpread|90° CCW|{\"angle\": -90}", + "ui:view.rotateSpread|180°|{\"angle\": 180}", + "ui:view.rotateSpread|Clear Rotation|{\"angle\": 0}", + "<", ">Proof Setup", "ui:view.proofSetup|Working CMYK|{\"target\": \"workingCmyk\"}", "ui:view.proofSetup|Internet Standard RGB (sRGB)|{\"target\": \"srgb\"}", @@ -1000,6 +1007,28 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + let angle = p.get("angle").and_then(Value::as_i64).unwrap_or(90); + let (Some(rect), Some(v)) = (app.canvas_rect, app.view().copied()) else { return Some(Err("no view".into())) }; + let xf = crate::canvas::Xf::new(rect, &v); + let centre = xf.to_canvas(rect.center()); + let screen = crate::canvas::view_rect(rect, v.rotation); + let rot = match angle { + 0 => 0, + a => ((v.rotation as i64 + a.div_euclid(90)).rem_euclid(4)) as u8, + }; + let new_rect = crate::canvas::view_rect(screen, rot); + app.canvas_rect = Some(new_rect); + if let Some(v) = app.view_mut() { + v.rotation = rot; + v.origin = designcraft_geom::Point::new( + centre.x - new_rect.width() as f64 / 2.0 / v.zoom, + centre.y - new_rect.height() as f64 / 2.0 / v.zoom, + ); + } + app.canvas.shown = None; + Ok(json!(rot as u32 * 90)) + } "view.proofColors" => { app.ui.proof_colors = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.proof_colors); app.canvas.shown = None; @@ -1513,6 +1542,10 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "window.controlBar" => app.ui.control_bar, "window.split" => app.split, "view.proofColors" => app.ui.proof_colors, + "view.rotateSpread" => match params.get("angle").and_then(Value::as_i64) { + Some(0) => app.view().is_some_and(|v| v.rotation == 0), + _ => return None, + }, "view.proofSetup" => match (params.get("target").and_then(Value::as_str), params.get("simulatePaper")) { (Some(t), _) => app.ui.proof_setup.target.id() == t, (None, Some(_)) => app.ui.proof_setup.simulate_paper, @@ -1935,6 +1968,47 @@ mod tests { assert_eq!(app.session.active_index(), Some(2)); } + #[test] + fn rotate_spread_view() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp| { + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + frame(&mut app); + frame(&mut app); + let r0 = app.canvas_rect.unwrap(); + let xf0 = crate::canvas::Xf::new(r0, app.view().unwrap()); + let centre = xf0.to_canvas(r0.center()); + assert_eq!(run_ui(&mut app, "view.rotateSpread", &json!({"angle": 90})).unwrap().unwrap(), json!(90)); + frame(&mut app); + let r1 = app.canvas_rect.unwrap(); + // Width and height swap (the rulers hide while rotated, so it grows by their width). + assert!(r0.width() > r0.height() && r1.height() > r1.width(), "{r0:?} {r1:?}"); + let xf = crate::canvas::Xf::new(r1, app.view().unwrap()); + let c = xf.to_canvas(r1.center()); + let tol = 30.0 / xf.zoom; + assert!((c.x - centre.x).abs() < tol && (c.y - centre.y).abs() < tol, "the centre stays put"); + // Screen ↔ canvas round trip, and canvas +x points down the screen after a clockwise turn. + let p = designcraft_geom::Point::new(c.x + 50.0, c.y); + let sp = xf.to_screen(p); + assert!(sp.y > r1.center().y + 1.0 && (sp.x - r1.center().x).abs() < 1.0, "{sp:?}"); + let back = xf.to_canvas(sp); + assert!((back.x - p.x).abs() < 1e-3 && (back.y - p.y).abs() < 1e-3); + run_ui(&mut app, "view.rotateSpread", &json!({"angle": -90})).unwrap().unwrap(); + assert_eq!(app.view().unwrap().rotation, 0); + run_ui(&mut app, "view.rotateSpread", &json!({"angle": 180})).unwrap().unwrap(); + frame(&mut app); + assert_eq!(run_ui(&mut app, "view.rotateSpread", &json!({"angle": 0})).unwrap().unwrap(), json!(0)); + } + #[test] fn panels_float_and_dock() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/parity.md b/docs/parity.md index 7b3f4471d..645060193 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,12 +11,12 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia |---|---|---|---|---|---| | P0 | 102 | 100 | 2 | 0 | 99% | | P1 | 97 | 94 | 3 | 0 | 98% | -| P2 | 76 | 43 | 27 | 6 | 74% | +| P2 | 76 | 43 | 30 | 3 | 76% | | Area | Parity | |---|---| | Application shell & workspace | 96% | -| Documents, pages, spreads | 96% | +| Documents, pages, spreads | 98% | | Layers | 95% | | Frames, shapes & paths | 100% | | Transform | 100% | @@ -31,7 +31,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Interactivity & digital | 90% | | Output & production | 80% | | XML & automation | 83% | -| View & navigation | 93% | +| View & navigation | 95% | | Undo, history, saving | 100% | | Accessibility | 90% | @@ -66,7 +66,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print) | Documents, pages, spreads | Insert/move/duplicate/delete pages | P0 | D | | Documents, pages, spreads | Per-page size (Page tool) | P1 | D | | Documents, pages, spreads | Margins and Columns per page | P0 | D | -| Documents, pages, spreads | Spread rotation (view) | P2 | M | +| Documents, pages, spreads | Spread rotation (view) | P2 | P | | Documents, pages, spreads | Pasteboard | P0 | D | | Documents, pages, spreads | Sections & numbering | P0 | D | | Documents, pages, spreads | Page numbering display | P1 | D | @@ -75,7 +75,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print) | Documents, pages, spreads | Parent pages | P0 | D | | Documents, pages, spreads | Parent item overrides | P0 | D | | Documents, pages, spreads | Page transitions | P2 | D | -| Documents, pages, spreads | Spread view rotation | P2 | M | +| Documents, pages, spreads | Spread view rotation | P2 | P | | Documents, pages, spreads | Layout Adjustment | P2 | D | | Documents, pages, spreads | Adjust Layout | P2 | D | | Documents, pages, spreads | Liquid layout | P2 | D | @@ -307,7 +307,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print) | View & navigation | Display performance | P0 | D | | View & navigation | Navigate pages | P0 | D | | View & navigation | Split window | P2 | D | -| View & navigation | Rotate spread view | P2 | M | +| View & navigation | Rotate spread view | P2 | P | | View & navigation | Find/zoom to object | P1 | D | | Undo, history, saving | Unlimited undo/redo | P0 | D | | Undo, history, saving | Revert | P0 | D |