mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 21:51:36 +00:00
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:
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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".
|
||||
|
||||
Reference in New Issue
Block a user