mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
#732: bound control-channel click and drag event queues
This commit is contained in:
+2
-1
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user