Files
vectorcraft/crates/ui-egui/src/app/control.rs
T
dotCooCoo 74863fa778 Engine: execute refuses keys a command doesn't name, in every build
Session::execute ran check_params only in the engine's own tests, so a
direct execute in a release build ran a misspelled key with the
command's default. It now checks every call, the commands a command runs
included, as MCP and the control port already did. A command.batch or
an action.batch is checked before its first step runs, and each step is
checked again as it runs, so an effect.setParams step is checked
against the effects the steps before it added.

Saved steps play through Session::replay, which takes keys a command no
longer reads, so an action recorded by an older version still plays and
Session::replay still plays an older journal. The Actions panel's Play
(Accelerated, Step by Step and Pause For, its function keys and
ui.action.play) and the Batch dialog use it.

Docs: the cmd/params.rs module doc, the Parameter keys section of
docs/mcp.md and a line in docs/target-app-parity.md describe both
paths.
2026-11-08 11:22:28 +07:00

130 lines
7.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::{
MAX_PENDING_SCREENSHOTS, MAX_SCREENSHOT_BYTES, MAX_SCREENSHOT_TOTAL_BYTES, PendingScreenshot, QueuedScreenshot, SCREENSHOT_TIMEOUT_MS,
VectorcraftApp, file_access, json, now_ms,
};
use crate::control;
impl VectorcraftApp {
pub(crate) fn drain_control(&mut self, ctx: &egui::Context) {
let Some(rx) = self.control_rx.take() else { return };
let mut handled = 0;
while handled < control::MAX_CONTROL_REQUESTS_PER_FRAME {
let Some(req) = self.deferred_control.take().or_else(|| rx.try_recv().ok()) else { break };
// Synthetic input sent before this tool action was dropped (see
// `discard_stale_injected_batch`): it would reach the tool without that input.
let tool_action = matches!(req.method.as_str(), "ui.pointer" | "ui.tool.select");
if tool_action && req.made_before(self.synthetic_dropped_before) {
// One its caller gave up is skipped without counting toward this frame's limit.
if req.claim() {
handled += 1;
let _ = req.reply.send(json!({
"ok": false,
"error": "input sent before this tool action was dropped because the document, dialog or focus changed"
}));
}
continue;
}
// `ui.pointer` and `ui.tool.select` act at once, while `ui.key` and other synthetic
// input reach the tool in a later UI pass. Wait for that earlier input, so a key
// pressed during a drag reaches its tool before a pointer event or tool switch.
// One its caller gave up meanwhile is dropped below instead of holding back the rest.
if tool_action && !req.given_up() && (!self.synthetic.is_empty() || self.synthetic_injected) {
self.deferred_control = Some(req);
ctx.request_repaint();
break;
}
// Its caller stopped waiting (the app was busy or its window not drawn for too long)
// and was told it won't run: an edit applied now would come from nowhere (#1725).
if !req.claim() {
continue;
}
handled += 1;
let reply = req.reply.clone();
// Guarded per request: a panic must not drop the channel (taken out of `self` above).
let roots = self.automation_roots.clone();
self.ui.answering_control = true;
let answer = file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req)));
self.ui.answering_control = false;
let outcome = match answer {
Ok(outcome) => outcome,
Err(msg) => {
self.picks.recover();
self.session.set_replaying(false);
control::err(format!("internal error: {msg} (please report this bug)"))
}
};
match outcome {
control::Outcome::Done(v) => {
let _ = reply.send(v);
}
control::Outcome::Screenshot { path, data } => {
// Admit with the shared ceilings (4 requests, 128 MiB each, 256 MiB together)
// before issue, reserving the checked bytes this viewport would need.
match self.screenshot_pixels(ctx) {
Ok((width, height, bytes)) if bytes > MAX_SCREENSHOT_BYTES => {
let _ = reply.send(json!({"ok": false, "error": format!(
"screenshot would use {bytes} bytes ({width}×{height}), over the {MAX_SCREENSHOT_BYTES}-byte limit"
)}));
}
Ok((width, height, bytes)) if data && path.is_none() && bytes > control::MAX_SCREENSHOT_PNG_REPLY_BYTES => {
let _ = reply.send(json!({"ok": false, "error": format!(
"a PNG reply of this {width}×{height} screenshot ({bytes} bytes of pixels) would exceed the {}-byte reply-encoding limit; write it to a path instead",
control::MAX_SCREENSHOT_PNG_REPLY_BYTES
)}));
}
Ok((_, _, _)) if self.pending_screenshots.len() >= MAX_PENDING_SCREENSHOTS => {
let _ = reply.send(json!({"ok": false, "error": format!(
"already {MAX_PENDING_SCREENSHOTS} screenshots are waiting; wait for one to finish"
)}));
}
Ok((width, height, bytes)) if self.screenshot_reserved.saturating_add(bytes) > MAX_SCREENSHOT_TOTAL_BYTES => {
let _ = reply.send(json!({
"ok": false,
"error": format!(
"screenshots already reserved {} bytes; {bytes} more ({width}×{height}) would pass the {MAX_SCREENSHOT_TOTAL_BYTES}-byte limit",
self.screenshot_reserved
)
}));
}
Ok((_, _, bytes)) => {
self.screenshot_token += 1;
let id = self.screenshot_token;
let settle = ctx.global_style().animation_time as f64 * 2000.0 + 80.0;
let due_ms = now_ms() + settle;
self.screenshot_reserved = self.screenshot_reserved.saturating_add(bytes);
self.queued_screenshots.push(QueuedScreenshot { id, due_ms, frames: 0 });
let document = self.document_identity();
self.pending_screenshots.push(PendingScreenshot {
id,
path,
data,
reply,
deadline_ms: due_ms + SCREENSHOT_TIMEOUT_MS,
reserved_bytes: bytes,
document,
slot: None,
issued: None,
attempts: 0,
});
}
Err(e) => {
let _ = reply.send(json!({"ok": false, "error": e}));
}
}
}
}
}
if handled == control::MAX_CONTROL_REQUESTS_PER_FRAME {
// Leave later requests on the channel and schedule another frame to serve them.
ctx.request_repaint();
}
self.control_rx = Some(rx);
}
/// The active document's identity: its process-unique uid and revision. A capture records it
/// at admission and issuance and refuses to answer with a different document or undo state.
pub(super) fn document_identity(&self) -> Option<(u64, u64)> {
self.session.active().map(|document| (document.uid, document.revision))
}
}