Merge PR #859 (with main's 255-click limit kept inside its event budget)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-14 12:58:52 -04:00
commit d07cbbe94c
4 files changed
+166 -11

No files matched your search

+2
View File
@@ -4,6 +4,7 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (parent canvas refresh) · **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.
@@ -151,6 +152,7 @@ Newest first.
refuse threads. The alpha gate now waits only on the Primary Text Frame page fixes (#165). No
readiness percentage change.
- **2026-10-13** · Layout ▸ Table of Contents opens a real dialog: title and title style, Include Paragraph Styles and Other Styles lists with Add and Remove, and per style its entry style, page number position (after, before, none), separator and level. Replace Existing regenerates the current table of contents in place; otherwise OK makes a new frame on the page in view. IDML export writes the definition as a TOC style that import reads back, so Update works after a round trip. No readiness percentage change.
- **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.
+149 -9
View File
@@ -100,6 +100,30 @@ fn key_from(name: &str) -> Option<egui::Key> {
})
}
// 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)?;
if count > 255 {
return Err("`count` must be an integer from 1 through 255".into());
}
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))
}
/// Handle one control request. Commands it runs leave the user's system clipboard alone.
pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
app.install_clipboard_guard(ctx);
@@ -196,14 +220,11 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest
ok(Value::Null)
}
"ui.click" | "ui.drag" => {
let count = match p.get("count") {
None => 1,
Some(v) => match v.as_u64().filter(|n| (1..=255).contains(n)) {
Some(n) => n,
None => return err("`count` must be an integer from 1 through 255"),
},
};
// Screen-space (egui points) pointer input through egui itself: reaches every widget.
let (count, steps) = match pointer_counts(p, req.method == "ui.drag", app.control_synthetic.len().saturating_add(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,
@@ -223,8 +244,6 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest
app.control_synthetic.push(egui::Event::PointerMoved(a));
app.control_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.control_synthetic.push(egui::Event::PointerMoved(a + (end - a) * t));
@@ -448,6 +467,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.control_synthetic = pending.clone();
let reply = call(&mut app, &ctx, method, params);
assert_eq!(reply["ok"], false, "{method}: {reply}");
assert_eq!(app.control_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.control_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.control_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.drag", json!({"steps": 9492, "count": 255})), ("ui.drag", json!({"steps": 10000})), ("ui.drag", json!({"steps": 9998, "count": 2}))]
{
app.control_synthetic.clear();
assert_eq!(call(&mut app, &ctx, method, params)["ok"], true, "{method}");
assert_eq!(app.control_synthetic.len(), 10003);
assert!(matches!(app.control_synthetic.last(), Some(egui::Event::PointerButton { pressed: false, .. })));
let queued = app.control_synthetic.clone();
assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], false);
assert_eq!(app.control_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.drag", json!({"steps": 9998}))["ok"], true);
assert_eq!(app.control_synthetic.len(), 10001);
let queued = app.control_synthetic.clone();
assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], false);
assert_eq!(app.control_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.control_synthetic.len(), 10000);
assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], true);
assert_eq!(app.control_synthetic.len(), 10003);
app.control_synthetic.clear();
assert_eq!(call(&mut app, &ctx, "ui.move", json!({"x": 12, "y": 34}))["ok"], true);
let queued = app.control_synthetic.clone();
assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"steps": 10000}))["ok"], false);
assert_eq!(app.control_synthetic, queued);
assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"steps": 9999}))["ok"], true);
assert_eq!(app.control_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.control_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.control_synthetic.clear();
assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"x": 10, "y": 20, "toX": 30, "toY": 40}))["ok"], true);
assert_eq!(app.control_synthetic.len(), 11, "default drag has eight steps");
assert_eq!(app.control_synthetic.get(9), Some(&egui::Event::PointerMoved(end)));
app.control_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.control_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();
+12 -1
View File
@@ -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", clicks?}], mods?}` | Drive the active tool through the same code path as the mouse; `clicks` on a `down` is the press count (3 = triple click); returns the selection, the tool and its cursor at the last event (e.g. `LoadedText`, `ThreadLink`, `Unthread`) |
| `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. `count` is at most 255; an empty
queue accepts 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,
+3 -1
View File
@@ -1,6 +1,6 @@
# Where DesignCraft falls short of InDesign
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gap 0 shrunk: text threads by hand; threading gaps added) · **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.
@@ -32,6 +32,8 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
open (#238, #187, #160, #312); 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).