UI: spring-loaded zoom (Cmd+Space / Cmd+Alt+Space)

This commit is contained in:
rasnoland
2026-10-12 16:09:24 +01:00
parent ebd1d78194
commit 99ced6cc0a
7 changed files with 315 additions and 15 deletions
+2 -1
View File
@@ -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-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (shared print spooler; Windows CI passes; spring-loaded Zoom navigation) · **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.
@@ -133,6 +133,7 @@ Newest first.
- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change.
- **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change.
- **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change.
- **2026-10-11 (DC-1):** Hold Cmd+Space for the Zoom cursor and click/scrubby zoom, with Alt-click to zoom out. Releasing restores the selected tool; text editing and existing tool drags retain their input. Space remains Hand, and navigation changes neither the document nor undo history. No readiness percentage change.
- **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
+14 -7
View File
@@ -77,7 +77,7 @@ impl Session {
}
}
fn with_ctx<T>(&mut self, view: ViewInfo, f: impl FnOnce(&mut dyn designcraft_tools::Tool, &ToolContext) -> T) -> Option<T> {
fn with_ctx<T>(&mut self, view: ViewInfo, f: impl FnOnce(&mut Self, &ToolContext) -> T) -> Option<T> {
let st = self.active()?;
let doc = st.doc.clone();
let sel = st.selection.clone();
@@ -94,27 +94,34 @@ impl Session {
snap: view.snap,
unit: view.unit,
};
Some(f(self.tool.as_mut(), &cx))
Some(f(self, &cx))
}
pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<()> {
let actions = self.with_ctx(view, |t, cx| t.pointer(cx, ev)).unwrap_or_default();
let actions = self.with_ctx(view, |s, cx| s.tool.pointer(cx, ev)).unwrap_or_default();
self.run_actions(actions)
}
/// Dispatch a temporary view tool without replacing the active tool or its state.
/// Its actions use the same execution path as the selected tool's gestures.
pub fn pointer_with_tool(&mut self, tool: &mut dyn designcraft_tools::Tool, ev: &PointerEvent, view: ViewInfo) -> Result<()> {
let actions = self.with_ctx(view, |_, cx| tool.pointer(cx, ev)).unwrap_or_default();
self.run_actions(actions)
}
pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<bool> {
let actions = self.with_ctx(view, |t, cx| t.key(cx, key, mods)).unwrap_or_default();
let actions = self.with_ctx(view, |s, cx| s.tool.key(cx, key, mods)).unwrap_or_default();
let handled = !actions.is_empty();
self.run_actions(actions)?;
Ok(handled)
}
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
self.with_ctx(view, |t, cx| t.overlays(cx)).unwrap_or_default()
self.with_ctx(view, |s, cx| s.tool.overlays(cx)).unwrap_or_default()
}
pub fn cursor(&mut self, p: designcraft_geom::Point, mods: Mods, view: ViewInfo) -> Cursor {
self.with_ctx(view, |t, cx| t.cursor(cx, p, mods)).unwrap_or_default()
self.with_ctx(view, |s, cx| s.tool.cursor(cx, p, mods)).unwrap_or_default()
}
pub fn tool_busy(&self) -> bool {
@@ -122,7 +129,7 @@ impl Session {
}
pub fn wants_text(&mut self) -> bool {
self.with_ctx(ViewInfo::at_zoom(1.0), |t, cx| t.wants_text(cx)).unwrap_or(false)
self.with_ctx(ViewInfo::at_zoom(1.0), |s, cx| s.tool.wants_text(cx)).unwrap_or(false)
}
pub fn run_actions(&mut self, actions: Vec<Action>) -> Result<()> {
+1
View File
@@ -30,6 +30,7 @@ use serde_json::Value;
pub use catalog::{TOOL_GROUPS, ToolInfo, tool_for_shortcut, tool_info};
pub use layout::CanvasLayout;
pub use nav::ZoomTool;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Mods {
+1 -1
View File
@@ -57,7 +57,7 @@ impl Tool for HandTool {
/// Zoom tool: click zooms in (Alt: out); dragging left or right zooms out or in continuously
/// around the press point (scrubby zoom).
#[derive(Default)]
#[derive(Clone, Default)]
pub struct ZoomTool {
/// Press point (canvas), zoom at the press, whether it has been dragged.
press: Option<(Point, f64, bool)>,
+284 -4
View File
@@ -230,6 +230,68 @@ fn mods(i: &egui::InputState, space: bool) -> Mods {
Mods { shift: i.modifiers.shift, alt: i.modifiers.alt, cmd: i.modifiers.command, ctrl: i.modifiers.ctrl, space }
}
/// Held Space belongs to navigation only when neither the document nor a text field is typing.
fn navigation_space(app: &mut DesignApp, ctx: &egui::Context) -> bool {
ctx.input(|i| i.focused && i.key_down(egui::Key::Space)) && !app.session.wants_text() && !ctx.text_edit_focused()
}
fn spring_zoom_mode(app: &DesignApp, ctx: &egui::Context, space: bool) -> bool {
let down = ctx.data(|d| d.get_temp::<bool>(egui::Id::new(("canvas_pointer_down", app.pane)))).unwrap_or(false);
space && ctx.input(|i| i.modifiers.command) && !down && !app.session.tool_busy()
}
#[derive(Clone, Default)]
struct SpringZoom {
tool: designcraft_tools::ZoomTool,
down: bool,
}
/// Lend the Zoom tool the gesture; the selected tool and document selection stay intact.
fn spring_zoom_input(app: &mut DesignApp, ui: &egui::Ui, resp: &egui::Response, xf: &Xf, active: bool) -> bool {
let id = egui::Id::new(("canvas_spring_zoom", app.pane));
let mut zoom: SpringZoom = ui.data(|d| d.get_temp(id)).unwrap_or_default();
if !ui.input(|i| i.focused) {
ui.data_mut(|d| d.remove::<SpringZoom>(id));
return false;
}
if !zoom.down && (!active || ui.input(|i| i.pointer.middle_down())) {
return false;
}
let (pointer, m) = ui.input(|i| (i.pointer.clone(), mods(i, true)));
let vi = app.view_info();
let mut events = Vec::new();
let canvas = if xf.rot == 0 { app.canvas_rect } else { Some(resp.rect) };
if pointer.primary_pressed()
&& resp.hovered()
&& let Some(p) = pointer.press_origin().filter(|p| canvas.is_some_and(|r| r.contains(*p)))
{
events.push(PointerEvent { kind: PointerKind::Down, pos: xf.to_canvas(p), mods: m });
zoom.down = true;
resp.request_focus();
}
if zoom.down
&& let Some(p) = pointer.interact_pos()
{
if resp.dragged() && resp.drag_delta() != egui::Vec2::ZERO {
events.push(PointerEvent { kind: PointerKind::Drag, pos: xf.to_canvas(p), mods: m });
}
if pointer.primary_released() {
events.push(PointerEvent { kind: PointerKind::Up, pos: xf.to_canvas(p), mods: m });
zoom.down = false;
}
}
for e in events {
if let Err(err) = app.session.pointer_with_tool(&mut zoom.tool, &e, vi) {
app.status(err.to_string());
}
}
app.after_engine();
// Keep ownership until pointer release even if the shortcut was released first, so an Up
// cannot leak into the previous tool and edit the document.
ui.data_mut(|d| d.insert_temp(id, zoom));
true
}
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let full = ui.available_rect_before_wrap();
@@ -328,11 +390,14 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
}
// Cursor.
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 space = navigation_space(app, ui.ctx());
let zoom = spring_zoom_mode(app, ui.ctx(), space);
let middle = ui.input(|i| i.pointer.middle_down());
let c = if middle {
Cursor::HandGrab
} else if space {
} else if zoom {
if ui.input(|i| i.modifiers.alt) { Cursor::ZoomOut } else { Cursor::ZoomIn }
} else if space && !ui.input(|i| i.modifiers.command) {
Cursor::Hand
} else {
app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info())
@@ -1345,7 +1410,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
let Some(v) = app.view().copied() else { return };
let xf = Xf::new(rect, &v);
let wants_text = app.session.wants_text();
let space = ui.input(|i| i.key_down(egui::Key::Space)) && !wants_text && !ui.ctx().text_edit_focused();
let space = navigation_space(app, ui.ctx());
// Scroll / zoom.
if resp.hovered() {
let (scroll, zoom_delta, m, hover) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.pointer.hover_pos()));
@@ -1362,11 +1427,16 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
}
}
}
let zoom = spring_zoom_mode(app, ui.ctx(), space);
if spring_zoom_input(app, ui, resp, &xf, zoom) {
return;
}
// Space-drag = hand (unless a tool drag is under way: then Space is a modifier, e.g. Live
// Distribute while resizing). Middle-drag pans too, with any tool; tools only
// see the primary button.
let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::new(("canvas_pointer_down", app.pane)))).unwrap_or(false);
if !tool_drag && ((space && resp.dragged()) || resp.dragged_by(egui::PointerButton::Middle)) {
let hand = space && !ui.input(|i| i.modifiers.command);
if !tool_drag && ((hand && resp.dragged()) || resp.dragged_by(egui::PointerButton::Middle)) {
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);
@@ -1772,6 +1842,216 @@ mod tests {
(s.horizontal_units, s.vertical_units)
}
fn zoom_app(tool: &str) -> DesignApp {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
app.select_tool(tool);
app.ui.rulers = false;
app
}
fn canvas_frame(app: &mut DesignApp, ctx: &egui::Context, m: egui::Modifiers, mut events: Vec<egui::Event>) -> egui::CursorIcon {
events.insert(0, egui::Event::ModifiersChanged(m));
let mut output = ctx.run_ui(
egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() },
|ui| {
app.logic(ui.ctx());
// Fonts installed by logic become available at the start of the next frame,
// just as in the full application UI.
if app.fonts_ready {
show(app, ui);
}
},
);
output.textures_delta.clear();
output.platform_output.cursor_icon
}
fn space_key(pressed: bool, modifiers: egui::Modifiers) -> egui::Event {
egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers }
}
fn primary(pos: Pos2, pressed: bool, modifiers: egui::Modifiers) -> egui::Event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }
}
fn hover(app: &mut DesignApp, ctx: &egui::Context, p: Pos2) {
for _ in 0..3 {
canvas_frame(app, ctx, egui::Modifiers::NONE, vec![egui::Event::PointerMoved(p)]);
}
}
const CMD: egui::Modifiers = egui::Modifiers { command: true, mac_cmd: true, ..egui::Modifiers::NONE };
#[test]
fn spring_zoom_click_is_anchored_view_only_and_release_restores_previous_tool() {
for tool in ["rectangleFrame", "directSelection", "type", "hand", "zoom"] {
let mut app = zoom_app(tool);
if tool == "rectangleFrame" {
let frame = app.run("frame.create", json!({"rect":[36,36,300,200],"content":"text","text":"Hello"})).unwrap();
app.run("text.select", json!({"story":frame["story"],"anchor":5})).unwrap();
}
let ctx = egui::Context::default();
let p = pos2(300.0, 250.0);
hover(&mut app, &ctx, p);
let before = *app.view().unwrap();
let doc = app.session.doc().unwrap().doc.clone();
let selection = app.session.doc().unwrap().selection.clone();
let history = app.run("document.history", json!({})).unwrap();
let cursor = canvas_frame(&mut app, &ctx, CMD, vec![space_key(true, CMD)]);
assert_eq!(cursor, egui::CursorIcon::ZoomIn, "{tool}");
canvas_frame(&mut app, &ctx, CMD, vec![primary(p, true, CMD)]);
canvas_frame(&mut app, &ctx, CMD, vec![primary(p, false, CMD)]);
let after = *app.view().unwrap();
assert_eq!(after.zoom, before.zoom * 2.0, "{tool}");
let anchor = Xf::new(app.canvas_rect.unwrap(), &before).to_canvas(p);
assert!((Xf::new(app.canvas_rect.unwrap(), &after).to_canvas(p) - anchor).hypot() < 1e-9);
let cursor = canvas_frame(&mut app, &ctx, egui::Modifiers::NONE, vec![space_key(false, egui::Modifiers::NONE)]);
assert_eq!(app.session.tool_id(), tool);
let expected = app.session.cursor(anchor, Mods::default(), app.view_info());
assert_eq!(cursor, cursor_icon(expected), "{tool}");
assert!(std::sync::Arc::ptr_eq(&doc, &app.session.doc().unwrap().doc));
assert_eq!(selection, app.session.doc().unwrap().selection);
assert_eq!(history, app.run("document.history", json!({})).unwrap());
}
}
#[test]
fn spring_zoom_alt_clicks_out_and_modifier_release_switches_back_to_hand() {
let mut app = zoom_app("rectangleFrame");
let ctx = egui::Context::default();
let p = pos2(300.0, 250.0);
hover(&mut app, &ctx, p);
let z = app.view().unwrap().zoom;
let alt = egui::Modifiers { alt: true, ..CMD };
assert_eq!(canvas_frame(&mut app, &ctx, CMD, vec![space_key(true, CMD)]), egui::CursorIcon::ZoomIn);
assert_eq!(canvas_frame(&mut app, &ctx, alt, vec![]), egui::CursorIcon::ZoomOut);
canvas_frame(&mut app, &ctx, alt, vec![primary(p, true, alt)]);
canvas_frame(&mut app, &ctx, alt, vec![primary(p, false, alt)]);
assert_eq!(app.view().unwrap().zoom, z * 0.5);
assert_eq!(canvas_frame(&mut app, &ctx, egui::Modifiers::NONE, vec![]), egui::CursorIcon::Grab);
let before = *app.view().unwrap();
canvas_frame(&mut app, &ctx, egui::Modifiers::NONE, vec![primary(p, true, egui::Modifiers::NONE)]);
canvas_frame(&mut app, &ctx, egui::Modifiers::NONE, vec![egui::Event::PointerMoved(p + vec2(80.0, 0.0))]);
canvas_frame(&mut app, &ctx, egui::Modifiers::NONE, vec![primary(p + vec2(80.0, 0.0), false, egui::Modifiers::NONE)]);
assert_eq!(app.view().unwrap().zoom, before.zoom);
assert!(app.view().unwrap().origin.x < before.origin.x);
assert_eq!(app.session.tool_id(), "rectangleFrame");
}
#[test]
fn spring_zoom_scrubs_and_owns_pointer_release_after_shortcut_release() {
let mut app = zoom_app("rectangleFrame");
let ctx = egui::Context::default();
let p = pos2(300.0, 250.0);
hover(&mut app, &ctx, p);
let z = app.view().unwrap().zoom;
let doc = app.session.doc().unwrap().doc.clone();
// Ctrl is the platform command modifier on Windows/Linux, using the same input path.
let cmd = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE };
canvas_frame(&mut app, &ctx, cmd, vec![space_key(true, cmd), primary(p, true, cmd)]);
let end = p + vec2(150.0, 0.0);
canvas_frame(&mut app, &ctx, cmd, vec![egui::Event::PointerMoved(end)]);
assert!((app.view().unwrap().zoom - z * std::f64::consts::E).abs() < 1e-9);
let scrubbed = app.view().unwrap().zoom;
assert_eq!(canvas_frame(&mut app, &ctx, egui::Modifiers::NONE, vec![space_key(false, egui::Modifiers::NONE)]), egui::CursorIcon::Crosshair);
canvas_frame(&mut app, &ctx, egui::Modifiers::NONE, vec![primary(end, false, egui::Modifiers::NONE)]);
assert_eq!(app.view().unwrap().zoom, scrubbed, "no extra click zoom at the end of a scrub");
assert!(std::sync::Arc::ptr_eq(&doc, &app.session.doc().unwrap().doc));
// The original tool still draws after the temporary gesture.
canvas_frame(&mut app, &ctx, egui::Modifiers::NONE, vec![primary(p, true, egui::Modifiers::NONE)]);
canvas_frame(&mut app, &ctx, egui::Modifiers::NONE, vec![egui::Event::PointerMoved(p + vec2(50.0, 50.0))]);
canvas_frame(&mut app, &ctx, egui::Modifiers::NONE, vec![primary(p + vec2(50.0, 50.0), false, egui::Modifiers::NONE)]);
assert!(!std::sync::Arc::ptr_eq(&doc, &app.session.doc().unwrap().doc));
assert_eq!(app.session.tool_id(), "rectangleFrame");
}
#[test]
fn spring_zoom_is_suppressed_while_typing_in_a_text_frame() {
let mut app = zoom_app("type");
let frame = app.run("frame.create", json!({"rect":[36,36,300,200],"content":"text","text":"Hello"})).unwrap();
app.run("text.select", json!({"story":frame["story"],"anchor":5})).unwrap();
assert!(app.session.wants_text());
let ctx = egui::Context::default();
let p = pos2(300.0, 250.0);
hover(&mut app, &ctx, p);
let before = *app.view().unwrap();
let alt = egui::Modifiers { alt: true, ..CMD };
for m in [CMD, alt] {
app.run("text.select", json!({"story":frame["story"],"anchor":5})).unwrap();
assert!(app.session.wants_text());
assert!(!matches!(canvas_frame(&mut app, &ctx, m, vec![space_key(true, m)]), egui::CursorIcon::ZoomIn | egui::CursorIcon::ZoomOut));
canvas_frame(&mut app, &ctx, m, vec![primary(p, true, m)]);
canvas_frame(&mut app, &ctx, m, vec![primary(p, false, m)]);
assert_eq!(app.view().unwrap().zoom, before.zoom);
assert_eq!(app.view().unwrap().origin, before.origin);
canvas_frame(&mut app, &ctx, egui::Modifiers::NONE, vec![space_key(false, egui::Modifiers::NONE)]);
}
}
#[test]
fn spring_zoom_is_suppressed_while_a_text_field_has_focus() {
let mut app = zoom_app("rectangleFrame");
let ctx = egui::Context::default();
let mut text = String::new();
let p = pos2(300.0, 250.0);
hover(&mut app, &ctx, p);
let before = *app.view().unwrap();
for m in [egui::Modifiers::NONE, CMD, egui::Modifiers { alt: true, ..CMD }] {
let mut output = ctx.run_ui(
egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
events: vec![egui::Event::ModifiersChanged(m), space_key(true, m), egui::Event::Text("x".into()), egui::Event::PointerMoved(p)],
..Default::default()
},
|ui| {
ui.text_edit_singleline(&mut text).request_focus();
show(&mut app, ui);
},
);
output.textures_delta.clear();
assert!(ctx.text_edit_focused());
assert!(!matches!(output.platform_output.cursor_icon, egui::CursorIcon::ZoomIn | egui::CursorIcon::ZoomOut | egui::CursorIcon::Grab));
assert_eq!(app.view().unwrap().zoom, before.zoom);
assert_eq!(app.session.tool_id(), "rectangleFrame");
}
assert!(!text.is_empty(), "typing still reaches the field");
}
#[test]
fn spring_zoom_does_not_interrupt_an_existing_tool_drag() {
let mut app = zoom_app("rectangleFrame");
let ctx = egui::Context::default();
let p = pos2(300.0, 250.0);
hover(&mut app, &ctx, p);
let before = *app.view().unwrap();
let doc = app.session.doc().unwrap().doc.clone();
canvas_frame(&mut app, &ctx, egui::Modifiers::NONE, vec![primary(p, true, egui::Modifiers::NONE)]);
let end = p + vec2(100.0, 100.0);
canvas_frame(&mut app, &ctx, CMD, vec![space_key(true, CMD), egui::Event::PointerMoved(end)]);
canvas_frame(&mut app, &ctx, CMD, vec![primary(end, false, CMD)]);
assert_eq!(app.view().unwrap().zoom, before.zoom);
assert_eq!(app.view().unwrap().origin, before.origin);
assert!(!std::sync::Arc::ptr_eq(&doc, &app.session.doc().unwrap().doc));
}
#[test]
fn spring_zoom_does_not_zoom_on_the_rulers() {
let mut app = zoom_app("rectangleFrame");
app.ui.rulers = true;
let ctx = egui::Context::default();
hover(&mut app, &ctx, pos2(300.0, 250.0));
let rect = app.canvas_rect.unwrap();
let p = pos2(rect.min.x - RULER / 2.0, rect.center().y);
hover(&mut app, &ctx, p);
let before = *app.view().unwrap();
canvas_frame(&mut app, &ctx, CMD, vec![space_key(true, CMD)]);
canvas_frame(&mut app, &ctx, CMD, vec![primary(p, true, CMD)]);
canvas_frame(&mut app, &ctx, CMD, vec![primary(p, false, CMD)]);
assert_eq!(app.view().unwrap().zoom, before.zoom);
assert_eq!(app.view().unwrap().origin, before.origin);
}
#[test]
fn right_clicking_a_ruler_sets_that_rulers_units() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+3 -1
View File
@@ -1,6 +1,6 @@
# DesignCraft parity with Adobe InDesign
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (shared print spooler; Windows CI passes; spring-loaded Zoom navigation; estimates unchanged) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md)
summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature
@@ -398,6 +398,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- 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.*`.
- Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles).
- Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point.
- Spring-loaded Zoom: hold Cmd+Space (Ctrl+Space on Windows/Linux) for the Zoom cursor and click/scrubby zoom; Alt-click zooms out. Release returns to the previous tool without changing the document or undo history. Text-frame and text-field typing suppress the shortcut; Space remains Hand.
- Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it.
- Pencil tool (N): freehand strokes simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`).
- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`.
@@ -441,6 +442,7 @@ Task notes that headed ROADMAP.md:
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Spring-loaded Zoom navigation added to the working feature inventory (DC-1); readiness estimates unchanged |
| 2026-10-10 | minor | Full number 42% → 47%: method aligned with the standard (plain weighted sum, the ×0.9 discount dropped), no new evidence; readiness table with hours for full, mainstream and essentials; beta distance 33 → 28 points |
| 2026-10-10 | minor | Added mainstream practitioner (~42%) and essentials user (~54%) numbers with written weights and discounts and a user-evidence count; full number (42%) now written as 47.2% × 0.9, value unchanged |
| 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate (manual threading missing); Type & text frames row updated |
+10 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, first version) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (spring-loaded Zoom navigation) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS)
Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel,
against InDesign 2026. The look was measured from the running app
@@ -58,6 +58,14 @@ Counted 2026-10-10: 27 rows, 11 ✓, 14 ~, 2 ✗ → **67% presence**, scoring
for real work is lower, ~45%, because threading is missing and three of the ~ rows (copy/paste,
context menus, typing at a caret) are things a layout artist does every minute, and their failures stop work.
Navigation also supports held Space as Hand and held Cmd+Space as Zoom (Ctrl+Space on
Windows/Linux), with Alt-click zooming out. Releasing restores the previous tool and its state.
The temporary Zoom uses the selected Zoom tool's click and horizontal scrubby drag behavior;
marquee zoom is not implemented. It is suppressed while typing in a document text frame or a
focused text field, and does not interrupt an existing tool drag. Synthetic canvas input tests
cover the cursors, click anchors, scrub/release ownership, restoration, typing guards and undo
history (DC-1).
## Remaining effort
| Work | Opus 5.5 h |
@@ -73,5 +81,6 @@ context menus, typing at a caret) are things a layout artist does every minute,
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Added held Cmd+Space / Cmd+Alt+Space Zoom navigation and canvas input regression tests |
| 2026-10-10 | minor | Threading row corrected to ✗ after checking the code (no out-port interaction, no thread command) |
| 2026-10-10 | major | Created: measured menu coverage, 27-row interaction checklist from the code and issues |