#732: bound control-channel click and drag event queues

This commit is contained in:
Andreas Haraminac
2026-10-13 21:55:44 +00:00
parent e862e0a613
commit 5024ed8870
4 changed files with 163 additions and 6 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-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.
+146 -3
View File
@@ -100,6 +100,27 @@ 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)?;
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();
+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"}], 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,
+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 (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).