diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f8207..6c6c09c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (bounded control click/drag input) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Control-channel clicks and drags validate positive counts and the complete pending-event budget before queuing input (#732). Oversized requests leave pending gestures intact, preventing unbounded click-count allocation. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 5a4de005..232f2ca2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -100,6 +100,27 @@ fn key_from(name: &str) -> Option { }) } +// Retain room for the existing maximum drag (10,000 moves plus move/down/up), but +// bound the whole gesture and already queued input before generating any events. +const MAX_PENDING_POINTER_EVENTS: usize = 10_003; + +fn pointer_counts(p: &Value, drag: bool, pending: usize) -> Result<(u64, u64), String> { + let positive = |name: &str, default: u64| match p.get(name) { + None => Ok(default), + Some(v) => v.as_u64().filter(|n| *n > 0).ok_or_else(|| format!("`{name}` must be a positive integer")), + }; + let count = positive("count", 1)?; + let steps = if drag { positive("steps", 8)? } else { 0 }; + // One initial move, two button events per click, and each drag step. Check the + // arithmetic too: hostile counts must never overflow into a small budget. + let events = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)); + let available = MAX_PENDING_POINTER_EVENTS.saturating_sub(pending) as u64; + if events.is_none_or(|n| n > available) { + return Err(format!("pointer input would exceed the {MAX_PENDING_POINTER_EVENTS} pending-event limit")); + } + Ok((count, steps)) +} + pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); @@ -181,6 +202,10 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> } "ui.click" | "ui.drag" => { // Screen-space (egui points) pointer input through egui itself: reaches every widget. + let (count, steps) = match pointer_counts(p, req.method == "ui.drag", app.synthetic.len()) { + Ok(counts) => counts, + Err(e) => return err(e), + }; let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; let button = match s("button") { Some("right") | Some("secondary") => egui::PointerButton::Secondary, @@ -200,15 +225,12 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); if req.method == "ui.drag" { - // From the caller: a huge count would queue events until memory runs out. - let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 10_000); for i in 1..=steps { let t = i as f32 / steps as f32; app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t)); } } app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); - let count = p.get("count").and_then(Value::as_u64).unwrap_or(1); for _ in 1..count { app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }); app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); @@ -420,6 +442,127 @@ mod automation_contract_tests { output.textures_delta.clear(); } #[test] + fn oversized_pointer_requests_leave_pending_events_unchanged() { + let mut app = app(); + let ctx = egui::Context::default(); + let pending = vec![egui::Event::PointerMoved(egui::pos2(12.0, 34.0))]; + for (method, params) in [ + ("ui.click", json!({"count": 5002})), + ("ui.drag", json!({"steps": 10000, "count": 2})), + ("ui.drag", json!({"steps": 10001})), + ("ui.click", json!({"count": u64::MAX})), + ("ui.drag", json!({"steps": u64::MAX})), + ("ui.drag", json!({"count": u64::MAX, "steps": u64::MAX})), + ] { + app.synthetic = pending.clone(); + let reply = call(&mut app, &ctx, method, params); + assert_eq!(reply["ok"], false, "{method}: {reply}"); + assert_eq!(app.synthetic, pending, "a rejected gesture must not leave a press queued"); + } + } + #[test] + fn pointer_counts_require_positive_json_integers() { + let mut app = app(); + let ctx = egui::Context::default(); + let pending = vec![egui::Event::PointerMoved(egui::pos2(12.0, 34.0))]; + app.synthetic = pending.clone(); + for value in [json!(0), json!(-1), json!(1.5), Value::Null, json!("2"), json!(true), json!(18446744073709551616_f64)] { + for (method, field) in [("ui.click", "count"), ("ui.drag", "count"), ("ui.drag", "steps")] { + let mut params = json!({}); + params[field] = value.clone(); + let reply = call(&mut app, &ctx, method, params); + assert_eq!(reply["ok"], false, "{method}, {field}={value}: {reply}"); + assert!(reply["error"].as_str().unwrap().contains(field)); + assert_eq!(app.synthetic, pending); + } + } + } + #[test] + fn pointer_gestures_accept_the_exact_event_budget() { + let mut app = app(); + let ctx = egui::Context::default(); + for (method, params) in + [("ui.click", json!({"count": 5001})), ("ui.drag", json!({"steps": 10000})), ("ui.drag", json!({"steps": 9998, "count": 2}))] + { + app.synthetic.clear(); + assert_eq!(call(&mut app, &ctx, method, params)["ok"], true, "{method}"); + assert_eq!(app.synthetic.len(), 10003); + assert!(matches!(app.synthetic.last(), Some(egui::Event::PointerButton { pressed: false, .. }))); + let queued = app.synthetic.clone(); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], false); + assert_eq!(app.synthetic, queued, "a full queue retains its release events"); + } + } + #[test] + fn pointer_budget_counts_pending_input_and_recovers_when_it_drains() { + let mut app = app(); + let ctx = egui::Context::default(); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({"count": 5000}))["ok"], true); + assert_eq!(app.synthetic.len(), 10001); + let queued = app.synthetic.clone(); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], false); + assert_eq!(app.synthetic, queued); + // The ordinary input hook consumes one pointer event per frame, freeing + // enough room for a complete click without dropping earlier input. + app.raw_input_hook(&mut egui::RawInput::default()); + assert_eq!(app.synthetic.len(), 10000); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], true); + assert_eq!(app.synthetic.len(), 10003); + + app.synthetic.clear(); + assert_eq!(call(&mut app, &ctx, "ui.move", json!({"x": 12, "y": 34}))["ok"], true); + let queued = app.synthetic.clone(); + assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"steps": 10000}))["ok"], false); + assert_eq!(app.synthetic, queued); + assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"steps": 9999}))["ok"], true); + assert_eq!(app.synthetic.len(), 10003); + } + #[test] + fn ordinary_pointer_gestures_keep_their_defaults_and_event_order() { + let mut app = app(); + let ctx = egui::Context::default(); + let a = egui::pos2(10.0, 20.0); + let end = egui::pos2(30.0, 40.0); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({"x": 10, "y": 20}))["ok"], true); + assert_eq!( + app.synthetic, + vec![ + egui::Event::PointerMoved(a), + egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }, + egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }, + ] + ); + app.synthetic.clear(); + assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"x": 10, "y": 20, "toX": 30, "toY": 40}))["ok"], true); + assert_eq!(app.synthetic.len(), 11, "default drag has eight steps"); + assert_eq!(app.synthetic.get(9), Some(&egui::Event::PointerMoved(end))); + + app.synthetic.clear(); + assert_eq!( + call( + &mut app, + &ctx, + "ui.drag", + json!({"x": 10, "y": 20, "toX": 30, "toY": 40, "steps": 2, "count": 2, "button": "right", "shift": true}) + )["ok"], + true + ); + let modifiers = egui::Modifiers { shift: true, ..Default::default() }; + let button = egui::PointerButton::Secondary; + assert_eq!( + app.synthetic, + vec![ + egui::Event::PointerMoved(a), + egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }, + egui::Event::PointerMoved(egui::pos2(20.0, 30.0)), + egui::Event::PointerMoved(end), + egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }, + egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }, + egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }, + ] + ); + } + #[test] fn control_typing_uses_explicit_caret_but_retains_widget_events() { let mut app = app(); let ctx = egui::Context::default(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9e514561..6615eb10 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -39,7 +39,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) | | `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas"}], mods?}` | Drive the active tool through the same code path as the mouse | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) | -| `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle` | Real egui pointer input — reaches every widget, menu and panel | +| `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle`, `count?: positive integer` (click/drag, default 1), `steps?: positive integer` (drag, default 8) | Real egui pointer input — reaches every widget, menu and panel | | `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state | | `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` or `characterStyleOptions` with `{name, section}`, or `{new: true}` for a new style) | | `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog | @@ -57,6 +57,17 @@ Headless window screenshots (locked screen, hidden window): `cargo run -p design The MCP server (`designcraft-cli mcp`) wraps the same methods for Claude and other agents. +### Click and drag limits + +`ui.click` and `ui.drag` validate the whole gesture before queuing any input. They permit at +most 10,003 pending synthetic events, including input already waiting for native frames. A +click uses `1 + 2 * count` events; a drag uses `1 + steps + 2 * count`. The count includes the +initial click or drag; further clicks occur at its endpoint. An empty queue therefore accepts +up to 5,001 clicks, or a single drag with up to 10,000 steps. `count` and `steps` must be positive +JSON integers when supplied; invalid or over-budget requests return `ok: false` and leave pending +input unchanged. Oversized gestures are rejected rather than truncated. Wait for queued input +to finish before retrying a request that would exceed the pending-event limit. + ## Shared panel docking Panel arrangements support tab grouping and reordering, horizontal and vertical splits, diff --git a/docs/gaps.md b/docs/gaps.md index ebd2d7da..a578036d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (book chapter update safety) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (bounded control click/drag input) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -38,6 +38,8 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · open (#238, #187, #160, #312); threading (gap 0); double-click word selection in fields (#189); character formatting set at a caret is discarded (#289). - **Evidence:** 16 issues from 2026-10-05 to 2026-10-10, mostly against the 0.4/0.5 release builds. +- **Safety:** control-channel clicks and drags reject invalid or over-budget counts before + queuing input (#732), including combined drag steps, repeated clicks and already pending events. - **Impact:** a layout artist can't finish the simplest job. This dominates the ready-for-real-work number. - **Estimate:** 30–50 h (reproduce per platform through the control channel, regression tests).