Merge pull request #6342 from christianlappin/codex/fix-6322-rotate-view-mcp

MCP: handle Rotate View gestures in the desktop canvas
This commit is contained in:
BFlat
2026-11-10 20:10:24 +07:00
committed by GitHub
10 changed files with 359 additions and 9 deletions
+54
View File
@@ -1463,6 +1463,60 @@ fn pointer_gesture_forwards_backquote_on_each_event() {
assert_eq!(rect["bounds"]["x"], 100.0, "{doc}");
}
/// The documented Rotate View calls reach the desktop control methods, preserving document
/// coordinates and modifiers, and a successful null reset remains a successful MCP response.
#[test]
fn rotate_view_calls_forward_to_desktop_control() {
use std::cell::RefCell;
use std::rc::Rc;
struct Recording(Rc<RefCell<Vec<(String, Value)>>>);
impl Backend for Recording {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
self.0.borrow_mut().push((method.into(), params));
Ok(Value::Null)
}
fn has_ui(&self) -> bool {
true
}
fn describe(&self) -> String {
"desktop control contract".into()
}
}
let seen = Rc::new(RefCell::new(Vec::new()));
let mut s = Server::new(Box::new(Recording(seen.clone())));
let selected = call(&mut s, 1, "select_tool", json!({"tool": "rotateView"}));
let turned = call(
&mut s,
2,
"pointer_gesture",
json!({"events": [
{"kind": "down", "x": 300, "y": 150}, {"kind": "drag", "x": 200, "y": 250}, {"kind": "up", "x": 200, "y": 250}
], "mods": {"shift": true}}),
);
let reset = call(&mut s, 3, "command_run", json!({"id": "view.rotateReset", "params": {}}));
for reply in [&selected, &turned, &reset] {
assert_eq!(reply["isError"], false, "{reply}");
}
assert_eq!(text_of(&reset), "null");
assert_eq!(
*seen.borrow(),
vec![
("ui.tool.select".into(), json!({"tool": "rotateView"})),
(
"ui.pointer".into(),
json!({"events": [
{"kind": "down", "x": 300, "y": 150, "space": "doc"},
{"kind": "drag", "x": 200, "y": 250, "space": "doc"},
{"kind": "up", "x": 200, "y": 250, "space": "doc"}
], "mods": {"shift": true}})
),
("engine.execute".into(), json!({"command": "view.rotateReset", "params": {}}))
]
);
}
/// A `backquote` that is not a boolean is an error, and none of the gesture's events runs.
#[test]
fn pointer_gesture_rejects_a_backquote_that_is_not_a_boolean() {
+1
View File
@@ -126,6 +126,7 @@ impl VectorcraftApp {
updates_rt: Default::default(),
frame_rate: Default::default(),
canvas_rect: None,
control_rotate_press: None,
hover_doc: None,
titlebar_from_env: false,
custom_titlebar: false,
+4
View File
@@ -404,6 +404,10 @@ impl VectorcraftApp {
if let Err(e) = switched {
self.ui.status = e.to_string();
}
if before != self.session.tool_id() {
// A control gesture cannot resume after another tool has owned the pointer.
self.control_rotate_press = None;
}
// A newly chosen Artboard tool starts on the active artboard (the view's).
if before != "artboard"
&& self.session.tool_id() == "artboard"
+1
View File
@@ -77,6 +77,7 @@ use tool_overlays::{
print_tiling_overlay, rotate_view_dial, shaper_widget_overlay, slice_overlay, spelling_overlay, thread_overlays,
};
pub(crate) use view::animated_zoom;
pub(crate) use view::drag_rotation;
pub use view::fit;
use view::scroll_bars;
#[cfg(test)]
+1 -7
View File
@@ -337,14 +337,8 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2)
}
}
Drag::RotateView { start_angle, start_rot, snaps } => {
let c = rect.center();
let a = (p.y - c.y).atan2(p.x - c.x) as f64;
let mut deg = start_rot + (a - start_angle).to_degrees();
if snaps && m.shift {
deg = (deg / 15.0).round() * 15.0;
}
if let Some(vm) = app.view_mut() {
vm.rotation = vectorcraft_geom::normalize_deg(deg);
vm.rotation = super::view::drag_rotation(rect.center(), p, start_angle, start_rot, snaps && m.shift);
}
}
Drag::ZoomBox { start } => {
+10
View File
@@ -1,6 +1,16 @@
use super::drag::{drag_id, drop_press};
use super::{CornerRadius, Drag, Point, Pos2, Rect, SCROLL_BAR, Sense, Stroke, Tokens, Ui, VectorcraftApp, View, Xf, pos2, vec2};
/// The mouse and control-channel Rotate View gestures use the same angle and Shift snapping.
pub(crate) fn drag_rotation(center: Pos2, point: Pos2, start_angle: f64, start_rotation: f64, snap: bool) -> f64 {
let angle = (point.y - center.y).atan2(point.x - center.x) as f64;
let mut degrees = start_rotation + (angle - start_angle).to_degrees();
if snap {
degrees = (degrees / 15.0).round() * 15.0;
}
vectorcraft_geom::normalize_deg(degrees)
}
/// Fit the view (View → Fit Artboard / Fit All / Actual Size, Arrange Tools › Zoom to Selection).
pub fn fit(app: &mut VectorcraftApp, how: &str) {
let Some(rect) = app.canvas_rect else {
+113 -1
View File
@@ -610,10 +610,116 @@ pub(crate) fn save_screenshot_with_limit(
#[cfg(test)]
mod tests;
/// The window and coordinate mapping where a control-channel Rotate View drag began.
#[derive(Clone, Copy)]
pub(crate) struct RotateViewPress {
window: (u64, u32),
document_resets: u64,
xf: Xf,
start_angle: f64,
start_rotation: f64,
}
/// Rotate View belongs to the canvas, not the engine's placeholder tool. Keep its press mapping
/// across requests so document-coordinate drags do not move again as the view turns underneath.
fn rotate_view_pointer(
app: &mut VectorcraftApp,
kind: PointerKind,
position: vectorcraft_geom::Point,
screen: bool,
shift: bool,
) -> Result<(), String> {
let window = app.session.active().zip(crate::doc_windows::current(app)).map(|(d, w)| (d.uid, w.1)).ok_or("no document")?;
let document_resets = app.session.tool_resets();
let stored = if kind == PointerKind::Down { None } else { app.control_rotate_press };
if kind == PointerKind::Drag && stored.is_none() {
return Err("Rotate View needs a down event before a drag".into());
}
let mut press = match stored {
Some(press) => press,
None => {
let rect = app.canvas_rect.ok_or("no canvas yet")?;
let view = app.view().ok_or("no document")?;
RotateViewPress { window, document_resets, xf: Xf::new(rect, view), start_angle: 0.0, start_rotation: view.rotation }
}
};
// A gesture cannot turn a different document/view or resume after its document was reset.
if (press.window, press.document_resets) != (window, document_resets) {
return Err("the Rotate View gesture's window or document changed".into());
}
let point = if screen { egui::pos2(position.x as f32, position.y as f32) } else { press.xf.to_screen(position) };
let center = press.xf.rect.center();
if !point.is_finite() || !center.is_finite() {
return Err("Rotate View needs finite canvas coordinates".into());
}
match kind {
PointerKind::Down => {
press.start_angle = (point.y - center.y).atan2(point.x - center.x) as f64;
app.control_rotate_press = Some(press);
}
PointerKind::Drag => {
app.view_mut().ok_or("no document")?.rotation =
crate::canvas::drag_rotation(center, point, press.start_angle, press.start_rotation, shift);
}
PointerKind::Up => {
app.control_rotate_press = None;
}
PointerKind::Move | PointerKind::DoubleClick => {}
}
Ok(())
}
/// A Rotate View packet accepts only the documented boolean modifier fields.
fn validate_rotate_view_mods(value: Option<&Value>) -> Result<(), String> {
let Some(value) = value else { return Ok(()) };
let Some(fields) = value.as_object() else { return Err("Rotate View `mods` must be an object of booleans".into()) };
if fields.iter().any(|(key, value)| !matches!(key.as_str(), "shift" | "alt" | "cmd" | "ctrl" | "space") || !value.is_boolean()) {
return Err("Rotate View `mods` accepts only boolean shift, alt, cmd, ctrl and space".into());
}
Ok(())
}
/// Reject malformed coordinates and modifiers before any event can change the angle or press.
fn validate_rotate_view_events(params: &Value, events: &[Value]) -> Result<(), String> {
validate_rotate_view_mods(params.get("mods"))?;
for event in events {
for axis in ["x", "y"] {
if event.get(axis).and_then(Value::as_f64).filter(|value| value.is_finite()).is_none() {
return Err(format!("Rotate View `{axis}` must be a finite number"));
}
}
validate_rotate_view_mods(event.get("mods"))?;
if event.get("space").is_some_and(|space| !matches!(space.as_str(), Some("doc" | "screen"))) {
return Err("Rotate View `space` must be doc or screen".into());
}
}
Ok(())
}
/// `ui.pointer`: pointer events through the tools.
fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let rotating = app.session.tool_id() == "rotateView";
let before = if rotating { app.view().map(|view| view.rotation) } else { None };
let outcome = pointer_events_inner(app, ctx, req);
if rotating && matches!(&outcome, Outcome::Done(reply) if reply.get("ok").and_then(Value::as_bool) == Some(false)) {
// A failed packet must neither leave a partial turn nor allow a later drag to resume it.
app.control_rotate_press = None;
if let (Some(before), Some(view)) = (before, app.view_mut()) {
view.rotation = before;
}
ctx.request_repaint();
}
outcome
}
/// Apply a pointer packet after Rotate View validation; other tools retain their existing routing.
fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let p = &req.params;
let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") };
let rotating = app.session.tool_id() == "rotateView";
if rotating && let Err(error) = validate_rotate_view_events(p, events) {
return err(error);
}
let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default();
let view = app.view_info();
let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v));
@@ -636,6 +742,13 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe
let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0);
let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0);
let screen = e.get("space").and_then(Value::as_str) == Some("screen");
let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods);
if rotating {
if let Err(e) = rotate_view_pointer(app, kind, vectorcraft_geom::Point::new(x, y), screen, mods.shift) {
return err(e);
}
continue;
}
let pos = if screen {
match xf {
Some(xf) => xf.to_doc(egui::pos2(x as f32, y as f32)),
@@ -644,7 +757,6 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe
} else {
vectorcraft_geom::Point::new(x, y)
};
let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods);
// A tilt given in screen axes leans the same way on the page as on the screen.
let pen = PointerEvent::json_pen(e).map(|q| match xf {
Some(xf) if screen => crate::canvas::pose_on_page(q, &xf),
+2
View File
@@ -187,6 +187,8 @@ pub struct VectorcraftApp {
frame_rate: frame_rate::FrameRate,
/// Canvas rect of the last frame (screen points), for control-channel coordinate mapping.
pub canvas_rect: Option<egui::Rect>,
/// An unfinished control-channel Rotate View gesture, kept only for this app instance.
pub(crate) control_rotate_press: Option<control::RotateViewPress>,
/// Hover position in document coordinates.
pub hover_doc: Option<vectorcraft_geom::Point>,
/// System clipboard: SVG to publish next frame, the last SVG we published (so pasting it back
+158
View File
@@ -30,6 +30,13 @@ fn with_document() -> VectorcraftApp {
app
}
/// Send a control request and keep its success/error value for protocol assertions.
fn control_response(app: &mut VectorcraftApp, ctx: &egui::Context, method: &str, params: Value) -> Value {
let (request, _) = crate::control::ControlRequest::new(method, params);
let crate::control::Outcome::Done(reply) = crate::control::handle(app, ctx, &request) else { panic!("not done") };
reply
}
/// Returns the labels of the View › Rotate View items that are checked.
fn checked(app: &VectorcraftApp) -> Vec<&'static str> {
ANGLES.iter().filter(|(_, a)| menus::checked(app, "view.rotate", &json!({ "angle": a })) == Some(true)).map(|(l, _)| *l).collect()
@@ -55,6 +62,157 @@ fn view_rotate_turns_the_view_to_any_angle() {
assert_eq!(empty.run("view.rotate", json!({"angle": 30})).unwrap_err(), "no document");
}
/// MCP's `pointer_gesture` reaches `ui.pointer`: view tools must turn the canvas there too,
/// rather than silently sending the gesture to the engine's no-op tool.
#[test]
fn control_rotate_view_gesture_turns_the_canvas_and_reset_restores_it() {
let mut app = with_document();
let ctx = egui::Context::default();
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)));
let view = app.view_mut().unwrap();
view.center = vectorcraft_geom::Point::new(200.0, 150.0);
view.zoom = 1.0;
let revision = app.session.active().unwrap().revision;
let send = super::synthetic::control;
send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"}));
send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]}));
let mut frame = ctx.run_ui(Default::default(), |_| {});
frame.textures_delta.clear();
send(
&mut app,
&ctx,
"ui.pointer",
json!({"events": [
{"kind": "drag", "x": 200, "y": 250},
{"kind": "up", "x": 200, "y": 250}
]}),
);
let view = app.view().unwrap();
assert!((view.rotation - 90.0).abs() < 1e-3, "{}", view.rotation);
assert_eq!(view.center, vectorcraft_geom::Point::new(200.0, 150.0));
assert_eq!(view.zoom, 1.0);
assert_eq!(app.session.active().unwrap().revision, revision, "navigation does not edit the document");
let reset = control_response(&mut app, &ctx, "engine.execute", json!({"command": "view.rotateReset", "params": {}}));
assert_eq!(reset["ok"], true);
assert!(reset["result"].is_null());
assert_eq!(app.view().unwrap().rotation, 0.0);
}
/// Repeated document points stay fixed across requests, including Shift snapping from a turned view.
#[test]
fn control_rotate_view_keeps_press_coordinates_and_shift_snapping_across_requests() {
let mut app = with_document();
let ctx = egui::Context::default();
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)));
let view = app.view_mut().unwrap();
view.center = vectorcraft_geom::Point::new(200.0, 150.0);
view.zoom = 1.0;
view.rotation = 30.0;
let send = super::synthetic::control;
send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"}));
send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]}));
let angle = 20_f64.to_radians();
let (x, y) = (200.0 + 100.0 * angle.cos(), 150.0 + 100.0 * angle.sin());
for _ in 0..2 {
send(&mut app, &ctx, "ui.pointer", json!({"mods": {"shift": true}, "events": [{"kind": "drag", "x": x, "y": y}]}));
assert_eq!(app.view().unwrap().rotation, 45.0, "the same document point does not turn again as the view changes");
}
send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "up", "x": x, "y": y}]}));
assert_eq!(app.view().unwrap().rotation, 45.0);
}
/// Invalid coordinates and document changes cannot turn a different view.
#[test]
fn control_rotate_view_rejects_nonfinite_positions_and_a_changed_document() {
let mut app = with_document();
let ctx = egui::Context::default();
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)));
let send = super::synthetic::control;
send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"}));
let (req, _) = crate::control::ControlRequest::new("ui.pointer", json!({"events": [{"kind": "down", "x": 1e300, "y": 0}]}));
let crate::control::Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("not done") };
assert_eq!(reply["ok"], false);
assert_eq!(app.view().unwrap().rotation, 0.0);
send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]}));
app.run("file.new", json!({})).unwrap();
let (req, _) = crate::control::ControlRequest::new("ui.pointer", json!({"events": [{"kind": "drag", "x": 200, "y": 250}]}));
let crate::control::Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("not done") };
assert_eq!(reply["ok"], false);
assert_eq!(app.view().unwrap().rotation, 0.0);
}
/// An invalid packet leaves the angle unchanged and requires a fresh press, including errors
/// after an otherwise valid drag and malformed releases or hovers.
#[test]
fn control_rotate_view_rejects_malformed_packets_without_leaving_a_press() {
let drag = json!({"kind": "drag", "x": 200, "y": 250});
for packet in [
json!({"events": [drag.clone(), {"kind": "drag", "y": 250}]}),
json!({"events": [drag.clone(), {"kind": "drag", "x": "200", "y": 250}]}),
json!({"events": [drag.clone(), {"kind": "drag", "x": 200, "y": 250, "mods": {"shift": "yes"}}]}),
json!({"mods": "shift", "events": [drag.clone()]}),
json!({"events": [drag.clone(), {"kind": "drag", "x": 200, "y": 250, "mods": {"unknown": true}}]}),
json!({"events": [drag.clone(), {"kind": "drag", "x": 1e300, "y": 250}]}),
json!({"events": [drag.clone(), {"kind": "up", "x": 1e300, "y": 250}]}),
json!({"events": [drag.clone(), {"kind": "move", "x": 1e300, "y": 250}]}),
json!({"events": [drag.clone(), {"kind": "doubleclick", "x": 1e300, "y": 250}]}),
json!({"events": [drag.clone(), {"kind": "wiggle", "x": 200, "y": 250}]}),
json!({"events": [drag.clone(), {"kind": "drag", "x": 200, "y": 250, "space": "window"}]}),
json!({"events": null}),
] {
let mut app = with_document();
let ctx = egui::Context::default();
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)));
let view = app.view_mut().unwrap();
view.center = vectorcraft_geom::Point::new(200.0, 150.0);
view.zoom = 1.0;
super::synthetic::control(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"}));
super::synthetic::control(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]}));
let reply = control_response(&mut app, &ctx, "ui.pointer", packet.clone());
assert_eq!(reply["ok"], false, "{packet}: {reply}");
assert_eq!(app.view().unwrap().rotation, 0.0, "{packet}");
let resume = control_response(&mut app, &ctx, "ui.pointer", json!({"events": [drag.clone()]}));
assert_eq!(resume["ok"], false, "a failed packet cancels its press: {packet}");
assert_eq!(app.view().unwrap().rotation, 0.0);
}
}
/// Screen-space gestures survive frames, stop on release or a tool change, and start afresh
/// from the current angle. A press at the pivot uses the same finite convention as the mouse.
#[test]
fn control_rotate_view_screen_events_require_a_live_press() {
let mut app = with_document();
let ctx = egui::Context::default();
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)));
let view = app.view_mut().unwrap();
view.center = vectorcraft_geom::Point::new(1200.0, 900.0);
view.zoom = 2.0;
let send = super::synthetic::control;
send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"}));
let down = json!({"events": [{"kind": "down", "x": 500, "y": 300, "space": "screen"}]});
let drag = json!({"events": [{"kind": "drag", "x": 400, "y": 400, "space": "screen"}]});
assert_eq!(control_response(&mut app, &ctx, "ui.pointer", drag.clone())["ok"], false);
send(&mut app, &ctx, "ui.pointer", down.clone());
let mut frame = ctx.run_ui(Default::default(), |_| {});
frame.textures_delta.clear();
send(&mut app, &ctx, "ui.pointer", drag.clone());
assert!((app.view().unwrap().rotation - 90.0).abs() < 1e-3);
send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "up", "x": 400, "y": 400, "space": "screen"}]}));
assert_eq!(control_response(&mut app, &ctx, "ui.pointer", drag.clone())["ok"], false, "release ends the press");
send(&mut app, &ctx, "ui.pointer", down.clone());
send(&mut app, &ctx, "ui.tool.select", json!({"tool": "selection"}));
send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"}));
assert_eq!(control_response(&mut app, &ctx, "ui.pointer", drag.clone())["ok"], false, "a tool change ends the press");
send(&mut app, &ctx, "ui.pointer", down);
send(&mut app, &ctx, "ui.pointer", drag);
assert!((app.view().unwrap().rotation.abs() - 180.0).abs() < 1e-3, "a new press starts at the current angle");
let before = app.view().unwrap().rotation;
send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 400, "y": 300, "space": "screen"}]}));
send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "drag", "x": 500, "y": 300, "space": "screen"}]}));
assert!(app.view().unwrap().rotation.is_finite());
assert!((app.view().unwrap().rotation - before).abs() < 1e-3);
}
#[test]
fn the_view_menu_lists_the_status_bar_angles_and_checks_the_current_one() {
let mut app = with_document();
+15 -1
View File
@@ -4191,7 +4191,21 @@ every document they read except a native file and the native document that an SV
`view.rotate {angle}` (desktop app) turns the view to `angle` degrees about the center of the canvas; a positive angle
turns the art clockwise on screen. View › Rotate View and the status bar's angle menu run it. It returns `{angle}`, the
angle it set, normalized to (-180, 180], and `view.rotateReset` turns the view back to 0.
angle it set, normalized to (-180, 180], and `view.rotateReset` turns the view back to 0, returning `null` on success.
Select the Rotate View Tool with `select_tool` and the tool id `rotateView`. Its `pointer_gesture`
drags turn the canvas about its center; Shift snaps to 15°. Document coordinates for a rotation
gesture use the view at the initial down event, including when the gesture spans several calls.
The control channel rejects malformed rotation packets without changing the angle and cancels
the current press. Start a new down event after an error.
Reset Rotate View is the command `view.rotateReset`:
```json
{"name":"select_tool","arguments":{"tool":"rotateView"}}
{"name":"pointer_gesture","arguments":{"events":[{"kind":"down","x":300,"y":150},{"kind":"drag","x":200,"y":250},{"kind":"up","x":200,"y":250}]}}
{"name":"command_run","arguments":{"id":"view.rotateReset","params":{}}}
```
`view.rotateToSelection` (desktop app, View › Rotate View to Selection) turns the view about the center of the canvas to
the angle the selected objects share (`selectionRotation` in `document.inspect`, 0 when their angles differ), so their
bounding box is square to the window. It returns `{angle}`, and with nothing selected it returns "nothing selected".