V: rotate the spread view in quarter turns

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 08:00:28 -04:00
1 parent 5bee68cf88
commit 7cd4657261
5 files changed
+157 -29

No files matched your search

+1
View File
@@ -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.
+73 -22
View File
@@ -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;
+3 -1
View File
@@ -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 }
}
}
+74
View File
@@ -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<Result<Value,
}
Ok(json!(app.session.active_index()))
}
"view.rotateSpread" => {
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<bool> {
"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());
+6 -6
View File
@@ -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 |