diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index c3869c599..eb9c3526e 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -104,6 +104,7 @@ impl VectorcraftApp { clipboard_publishing: Default::default(), pending_url: None, control_rx: None, + deferred_control: None, pending_screenshots: vec![], queued_screenshots: vec![], screenshot_token: 0, diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index bbdf60bef..cde629466 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -9,7 +9,15 @@ impl VectorcraftApp { let Some(rx) = self.control_rx.take() else { return }; let mut handled = 0; while handled < control::MAX_CONTROL_REQUESTS_PER_FRAME { - let Ok(req) = rx.try_recv() else { break }; + let Some(req) = self.deferred_control.take().or_else(|| rx.try_recv().ok()) else { break }; + // `ui.pointer` drives the tool at once, while `ui.key` and the other synthetic input + // reach it in a later UI pass: a pointer request waits until the input sent before it + // was applied, so a key pressed during a drag changes the drag before its release. + if req.method == "ui.pointer" && (!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() { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d25a08f94..045ad9732 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -10,7 +10,8 @@ //! - `ui.contextMenu.list`: the canvas context menu for the current selection, flattened //! - `ui.tool.select {tool}`, `ui.tool.list` //! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, backquote?, space?: "doc"|"screen"}], mods?, backquote?}`: -//! drive the active tool through the same path as the mouse +//! drive the active tool through the same path as the mouse, after the synthetic input sent +//! before it was applied //! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: synthetic keyboard input //! - `ui.move {x, y}` / `ui.click {x, y, button?, count?, shift?…}` / `ui.drag {x, y, toX, toY, steps?}`: //! real egui pointer input in screen points (reaches every widget: panels, flyouts, dialogs) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 48b4e8dd3..5b0aa93d8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -151,6 +151,9 @@ pub struct VectorcraftApp { /// moves ([`native_menu::sync`]). run_count: u64, control_rx: Option>, + /// A `ui.pointer` request taken from the channel while synthetic input sent before it was not + /// yet applied: it runs, ahead of the channel, on the first frame after that input is applied. + deferred_control: Option, /// Window captures asked for and not answered yet. pending_screenshots: Vec, /// Admitted captures whose settled frame has not come yet (issued one at a time). diff --git a/crates/ui-egui/src/tests/synthetic.rs b/crates/ui-egui/src/tests/synthetic.rs index cb328e235..c6e64a12f 100644 --- a/crates/ui-egui/src/tests/synthetic.rs +++ b/crates/ui-egui/src/tests/synthetic.rs @@ -82,6 +82,41 @@ fn a_typed_key_holds_its_modifiers_for_its_text() { assert_eq!(app.session.tool_id(), "blobBrush", "Shift+B, not B"); } +/// A `ui.pointer` release sent right after a `ui.key` waits until the key reached the busy tool: +/// C pressed while an arc is dragged out closes the arc drawn on release. +#[test] +fn a_pointer_release_after_a_key_waits_for_the_key() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("tool.select", json!({"tool": "arc"})).unwrap(); + let ctx = egui::Context::default(); + control(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 100, "y": 100}, {"kind": "drag", "x": 200, "y": 180}]})); + let (key, _key_reply) = ControlRequest::new("ui.key", json!({"key": "c"})); + let (up, up_reply) = ControlRequest::new("ui.pointer", json!({"events": [{"kind": "up", "x": 200, "y": 180}]})); + tx.send(key).unwrap(); + tx.send(up).unwrap(); + // Each frame as the host runs it: the input hook, the control requests, then the UI pass. + let mut released = None; + for _ in 0..8 { + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + app.drain_control(&ctx); + if let Ok(r) = up_reply.try_recv() { + released = Some(r); + break; + } + let mut out = ctx.run_ui(raw, |ui| crate::shortcuts::handle(&mut app, ui.ctx())); + out.textures_delta.clear(); + } + let r = released.expect("the release ran"); + assert_eq!(r["ok"], true, "{r}"); + let document = app.session.active().unwrap(); + let arc = *document.selection.objects.last().expect("the arc drawn on release"); + let closed = document.doc.node(arc).unwrap().path_data().unwrap().subpaths[0].closed; + assert!(closed, "C reached the Arc tool before the release"); +} + #[test] fn a_drag_holds_its_modifiers_from_the_press_to_the_release() { let (mut app, _) = one_square(); diff --git a/docs/mcp.md b/docs/mcp.md index 8fd7a394a..624ab3123 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -426,7 +426,7 @@ opens the picker through `run_command` with the remote backend. | `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards (with `color`, `#rrggbb`, for one with a background color), layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. | | `inspect_ui` | `{}` | UI state. Remote mode only. | | `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … | -| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. | +| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. Remote, the events wait until the keys and other input sent before them (`press_key`, `type_text`) have reached the app, so a key pressed between two calls of one drag applies before the release. | | `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. | | `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. | | `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. |