Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions crates/mcp/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions crates/ui-egui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,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,
Expand Down
4 changes: 4 additions & 0 deletions crates/ui-egui/src/app/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/ui-egui/src/canvas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ use tool_overlays::{
print_tiling_overlay, rotate_view_dial, shaper_widget_overlay, slice_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)]
Expand Down
8 changes: 1 addition & 7 deletions crates/ui-egui/src/canvas/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 } => {
Expand Down
10 changes: 10 additions & 0 deletions crates/ui-egui/src/canvas/view.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
114 changes: 113 additions & 1 deletion crates/ui-egui/src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -608,10 +608,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));
Expand All @@ -634,6 +740,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)),
Expand All @@ -642,7 +755,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),
Expand Down
2 changes: 2 additions & 0 deletions crates/ui-egui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,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
Expand Down
Loading