teksilo_scene/constrain.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The **geometry constraint**: one closure that rewrites a gesture's proposed
5//! geometry *before* anything is applied.
6//!
7//! Snap-to-grid, axis lock, page-bounds clamping and "this item may not leave
8//! its lane" are all one hook. It is Qt's
9//! `QGraphicsItem::itemChange(ItemPositionChange, value) -> value` in the shape
10//! Rust's borrow rules allow: the scene is lent **read-only** and the decision
11//! is *returned* rather than written.
12//!
13//! # The quantity is a frame, not a pointer
14//!
15//! A constraint is offered a [`TransformFrame`] — the scene-space box the
16//! gesture's items occupy — and hands one back. That choice is the whole reason
17//! the obvious snap-to-grid example is actually correct:
18//!
19//! * A **pointer** position carries the grab offset. Snapping the cursor to a
20//! 25 dp grid leaves an item grabbed 37 dp from its corner sitting 37 dp
21//! off-grid, on every drag, for ever.
22//! * A **`local_pos`** is stated in the item's *parent's* frame, so a
23//! constraint written against a scene grid silently means something else for
24//! a parented item.
25//! * A **frame** is the box the user can see, in scene coordinates, for one
26//! item or for a whole selection. Snapping `proposed.rect.x` snaps the thing
27//! the user is looking at.
28//!
29//! # It runs on every route, and it cannot disagree with itself
30//!
31//! There is deliberately **no phase parameter**. The constraint is a pure
32//! function of the proposal, re-run from scratch on every sample including the
33//! release one, so the frame the last preview drew *is* the frame the commit
34//! writes — by construction, not by convention. A hook that could snap loosely
35//! while dragging and hard on release would be a hook that guarantees a jump at
36//! the release, which is the one failure this whole design exists to remove.
37//!
38//! Four routes consult it, and they all consult the same closure:
39//!
40//! | route | who runs it |
41//! | --- | --- |
42//! | the selection transform controller (move / resize / rotate; pointer, keyboard and AT) | [`SceneView`](crate::SceneView) |
43//! | the lightweight item drag | [`SceneView`](crate::SceneView) |
44//! | the `Alt`+arrow keyboard nudge | [`SceneView`](crate::SceneView) |
45//! | an app's own drag — a heavyweight card's `on_drag` handler | the app, via [`SceneModel::constrain_move`](crate::SceneModel::constrain_move) |
46//!
47//! The fourth is why the constraint lives on the **model** rather than on a
48//! view: a card's widget is built by a per-view delegate and holds a
49//! [`SceneModel`](crate::SceneModel) clone, never a `&SceneView`, so a
50//! view-installed closure would be unreachable from the one tier the motivating
51//! use cases (a corkboard of cards on a grid) actually live in. Geometry policy
52//! is a property of the *document* — "this corkboard is on a 25 dp grid" — not
53//! of a pane looking at it, and two panes onto one scene that snapped
54//! differently would be a bug rather than a feature. Contrast
55//! [`SceneView::focus_order`](crate::SceneView::focus_order),
56//! [`SceneView::drag_mode`](crate::SceneView::drag_mode) and
57//! [`SceneView::magnetism`](crate::SceneView::magnetism), which are per-view
58//! because they say what *this pane* lets you do.
59//!
60//! # A programmatic write is never constrained
61//!
62//! [`SceneModel::set_local_pos`](crate::SceneModel::set_local_pos) and every
63//! other mutator land **exactly** where they say. Snapping a document load, a
64//! [`SceneListAdapter`](crate::SceneListAdapter) rebuild or a data-layer replay
65//! is Qt's own best-known footgun with `itemChange`, and it is avoided here not
66//! by where the closure is stored but by *who consults it*: the gesture paths
67//! do, the mutators do not.
68//!
69//! # What it costs
70//!
71//! A scene with **no** constraint pays one `Option` test per gesture sample and
72//! never builds a call. Measured on a 20 000-item scene, a pointer sample costs
73//! the same with the mechanism present and absent — the difference is below the
74//! run-to-run noise of the measurement.
75//!
76//! A scene **with** one pays for the closure, and the closure is a *pure
77//! function re-run per query* rather than a per-gesture callback: one sample
78//! that also lays out, paints and re-walks the accessibility tree asks it
79//! several times (six, at the time of writing). That count is bounded and
80//! independent of both the scene's size and the selection's — pinned by
81//! `the_constraint_is_asked_a_bounded_number_of_times_per_sample` — but it is
82//! not one. Keep the closure cheap; a constraint that must run an expensive
83//! spatial query should memoise on `proposed` inside its own capture.
84//!
85//! Re-running rather than caching is deliberate. The answer has to be the same
86//! for the chrome, the preview, the commit and the announcement, and the
87//! cheapest way to guarantee that is for there to be nothing to invalidate.
88//!
89//! # Testing a policy
90//!
91//! A [`ProposedChange`] is built by the framework and is deliberately not
92//! consumer-constructible. To exercise a policy closure without a widget tree,
93//! drive it through the public door the app-owned drag uses — a bare
94//! [`SceneModel`](crate::SceneModel) is enough:
95//!
96//! ```
97//! # use teksilo_canvas::{Point, Rect, Vec2};
98//! # use teksilo_scene::{ChangeVerdict, RectItem, SceneModel, TransformSource};
99//! let model = SceneModel::new();
100//! let a = model.add_item(RectItem::new(Rect::new(0.0, 0.0, 40.0, 40.0)), Point::ZERO);
101//! model.set_geometry_constraint(|c| {
102//! let t = c.translation();
103//! ChangeVerdict::Adjust(c.translated(Vec2::new(t.x, 0.0))) // horizontal only
104//! });
105//!
106//! let start = model.transform_frame(&[a]).expect("resolves");
107//! let applied =
108//! model.constrain_move(&[a], start, Vec2::new(30.0, 12.0), TransformSource::Pointer);
109//! assert_eq!(applied, Vec2::new(30.0, 0.0));
110//! ```
111//!
112//! # What it may touch
113//!
114//! It may **read** the scene through the `&Scene` it is handed — a shared
115//! borrow is already open, and shared plus shared is legal. It may **not**
116//! write: a write needs the cell exclusively. That is enforced rather than
117//! documented — [`SceneModel::write_guard`](crate::SceneModel::write_guard)
118//! checks the constraint flag and panics naming this hook and what to do
119//! instead (return the verdict), so the failure reads as the bug it is rather
120//! than as `RefCell already borrowed`.
121//!
122//! # Do not capture a `SceneModel`
123//!
124//! [`ProposedChange::scene`] is the *whole* read surface — every query
125//! [`SceneModel`](crate::SceneModel) offers is a shared borrow delegating to
126//! the same [`Scene`], so a captured handle buys a constraint nothing it is
127//! allowed to do.
128//!
129//! It costs something, though. The scene **owns** the closure
130//! (`Rc<RefCell<Scene>>` → `Scene::geometry_constraint` → the closure), so a
131//! closure holding a `SceneModel` clone closes that ring and nothing in it is
132//! ever dropped: every item, every heavyweight payload, the journal and the
133//! spatial index stay alive for the life of the process. It is the ordinary
134//! `Rc` cycle, and it is invisible — the scene keeps working perfectly.
135//!
136//! Two safe forms, in order of preference:
137//!
138//! ```
139//! # use teksilo_canvas::{Point, Rect};
140//! # use teksilo_scene::{ChangeVerdict, RectItem, SceneModel};
141//! # let model = SceneModel::new();
142//! # let lane = model.add_item(RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)), Point::ZERO);
143//! // 1. Read through the scene you are handed. Nothing captured, nothing to leak.
144//! model.set_geometry_constraint(move |c| match c.scene.scene_rect(lane) {
145//! Some(r) if r.contains(Point::new(c.proposed.rect.x, c.proposed.rect.y)) => {
146//! ChangeVerdict::Accept
147//! }
148//! _ => ChangeVerdict::Reject,
149//! });
150//!
151//! // 2. When a policy object genuinely holds a model for its *other* work,
152//! // capture the weak handle and upgrade inside the call.
153//! let weak = model.downgrade();
154//! model.set_geometry_constraint(move |c| match weak.upgrade() {
155//! Some(m) if m.len() > 1 => ChangeVerdict::Accept,
156//! _ => ChangeVerdict::Adjust(c.start),
157//! });
158//! ```
159//!
160//! `a_constraint_capturing_a_strong_handle_leaks_the_scene` (in
161//! `tests/constraint_lifetime.rs`, beside
162//! `a_constraint_capturing_a_weak_handle_lets_the_scene_drop`) pins both halves
163//! with a `Drop` sentinel.
164//!
165//! # What it may hand back
166//!
167//! An applicable frame: every field finite, neither extent negative. That is
168//! not a formality. A one-character slip in the snap closure above —
169//! `let grid = 0.0;` — makes `(x / grid).round() * grid` a `NaN`, and a `NaN`
170//! frame applied verbatim gives the item a `NaN` position, an inverted
171//! infinite AABB, and a permanent absence from hit-testing, from the marquee
172//! and from every spatial query, with no panic to say so. So an
173//! [`ChangeVerdict::Adjust`] whose frame cannot be applied is **refused**: the
174//! sample behaves as [`ChangeVerdict::Reject`], and in a debug build it panics
175//! naming the offending frame rather than losing the item quietly.
176
177use std::cell::Cell;
178use std::rc::Rc;
179
180use teksilo_canvas::{Point, Rect, Transform2D, Vec2};
181
182use crate::item::ItemId;
183use crate::scene::Scene;
184use crate::transform_session::{TransformFrame, TransformOp, TransformSource};
185
186/// A geometry constraint: the closure installed by
187/// [`SceneModel::set_geometry_constraint`](crate::SceneModel::set_geometry_constraint).
188pub(crate) type GeometryConstraint = Rc<dyn Fn(&ProposedChange<'_>) -> ChangeVerdict>;
189
190/// A change a gesture is about to make, offered to the geometry constraint.
191///
192/// Every field is a *question*, never a channel: the answer is the returned
193/// [`ChangeVerdict`].
194#[derive(Debug)]
195#[non_exhaustive]
196pub struct ProposedChange<'a> {
197 /// The scene, read-only. Query siblings, guides, parents, magnets, flags —
198 /// anything a constraint needs to decide.
199 ///
200 /// This is the whole read surface, and the reason a constraint never needs
201 /// to capture a [`SceneModel`](crate::SceneModel): one that does closes a
202 /// reference cycle through the closure the scene owns and leaks the scene.
203 /// A closure that must reach the model holds a
204 /// [`WeakSceneModel`](crate::WeakSceneModel). Writes panic; see the module
205 /// header.
206 pub scene: &'a Scene,
207 /// The **roots** this gesture will change, pruned of any item whose
208 /// ancestor is also in the set — a descendant moves because its root does,
209 /// so it is deliberately not listed.
210 ///
211 /// The transform controller narrows this further to the roots carrying the
212 /// operation's own flag
213 /// ([`TransformOp::required_flag`](crate::TransformOp::required_flag)); the
214 /// lightweight item drag lists the whole drag group, which is what it
215 /// moves.
216 ///
217 /// There is no "primary": the subject of the decision is the frame, which
218 /// is what makes the constraint grab-offset invariant. Per-item policy is
219 /// still expressible — a one-item gesture is `items.len() == 1`.
220 pub items: &'a [ItemId],
221 /// What the gesture asks of them.
222 pub op: TransformOp,
223 /// The frame the gesture started from. Fixed for the gesture's life, so a
224 /// constraint measures total travel rather than per-sample travel.
225 pub start: TransformFrame,
226 /// The frame about to be applied, **after** magnetism.
227 ///
228 /// Both frames' `rect`s are stated in the frame's **own** basis. For a
229 /// single rotated item that is the item's basis, so a grid snap written
230 /// against `rect.x` snaps along the item's axes; a multi-item frame is
231 /// axis-aligned, so the same rule snaps along the scene's.
232 pub proposed: TransformFrame,
233 /// Which route produced it.
234 pub source: TransformSource,
235 /// Whether magnetism moved [`proposed`](Self::proposed) onto a magnet.
236 ///
237 /// The seam where the two snapping systems compose: returning
238 /// [`ChangeVerdict::Accept`] when this is `true` lets an explicit magnet
239 /// win over a standing rule. Overriding it is also allowed — and then the
240 /// magnet's connection does **not** fire, because a magnet whose alignment
241 /// was overruled did not connect anything.
242 ///
243 /// Only the lightweight item drag runs magnetism today, so this is `false`
244 /// on the transform-controller, keyboard-nudge and app-driven routes.
245 pub magnet_snapped: bool,
246}
247
248impl ProposedChange<'_> {
249 /// The scene-space translation from [`start`](Self::start) to
250 /// [`proposed`](Self::proposed).
251 ///
252 /// The quantity an axis lock or a grid snap works in. Exact for a
253 /// [`TransformOp::Move`]; for a resize or a rotate it is the movement of
254 /// the frame's origin, and the extent change is in the two frames
255 /// themselves.
256 pub fn translation(&self) -> Vec2 {
257 let d = Point::new(
258 self.proposed.rect.x - self.start.rect.x,
259 self.proposed.rect.y - self.start.rect.y,
260 );
261 let p = Transform2D::rotate(self.start.rotation).apply_point(d);
262 Vec2::new(p.x, p.y)
263 }
264
265 /// [`start`](Self::start) moved by a scene-space translation — the frame to
266 /// hand back from [`ChangeVerdict::Adjust`] once a move has been rewritten.
267 ///
268 /// The exact inverse of [`translation`](Self::translation), so
269 /// `c.translated(c.translation())` is `c.proposed` for a move.
270 pub fn translated(&self, translation: Vec2) -> TransformFrame {
271 let d = Transform2D::rotate(-self.start.rotation)
272 .apply_point(Point::new(translation.x, translation.y));
273 TransformFrame {
274 rect: Rect::new(
275 self.start.rect.x + d.x,
276 self.start.rect.y + d.y,
277 self.proposed.rect.width,
278 self.proposed.rect.height,
279 ),
280 rotation: self.proposed.rotation,
281 count: self.proposed.count,
282 }
283 }
284}
285
286/// What a geometry constraint decides.
287#[derive(Debug, Clone, Copy, PartialEq)]
288#[non_exhaustive]
289pub enum ChangeVerdict {
290 /// Apply [`ProposedChange::proposed`] unchanged.
291 Accept,
292 /// Apply this frame instead. The delta is **re-derived** from it, so the
293 /// preview, the commit, the chrome and the announcement cannot disagree
294 /// about what happened.
295 Adjust(TransformFrame),
296 /// Apply nothing: this sample leaves the items where the gesture found
297 /// them.
298 ///
299 /// Exactly equivalent to `Adjust(change.start)`, and deliberately so — a
300 /// "freeze at the last accepted sample" rejection would need history, and
301 /// the next accepted sample would then jump by everything the frozen ones
302 /// travelled. Stateless refusal means a rejected gesture shows nothing
303 /// happening and resumes the moment the proposal becomes acceptable, and a
304 /// gesture that *ends* rejected commits nothing at all — its delta is the
305 /// identity, so
306 /// [`TransformOutcome::Cancelled`](crate::TransformOutcome::Cancelled) is
307 /// what `on_end` reports.
308 Reject,
309}
310
311/// The constraint plus the scene borrow it reads, bundled for one call.
312///
313/// Built by the view (or by [`SceneModel`](crate::SceneModel)'s public doors)
314/// under a live **shared** borrow and dropped before any write. `None` where no
315/// constraint is installed, which is what makes an unconstrained scene pay one
316/// `Option` test per sample and not one closure call.
317pub(crate) struct ConstraintCall<'a> {
318 scene: &'a Scene,
319 constraint: GeometryConstraint,
320}
321
322impl<'a> ConstraintCall<'a> {
323 /// A call, if `scene` carries a constraint.
324 ///
325 /// # Panics
326 ///
327 /// When a constraint is already running on this scene. Nothing in the
328 /// framework nests one, so the only way to get here is a constraint that
329 /// called [`SceneModel::constrain_move`](crate::SceneModel::constrain_move)
330 /// or `constrain_frame` on its own model — which would recurse until the
331 /// stack ran out. A named panic is a better answer than that.
332 pub(crate) fn new(scene: &'a Scene) -> Option<Self> {
333 let constraint = scene.geometry_constraint()?;
334 assert!(
335 !scene.in_geometry_constraint(),
336 "a geometry constraint asked this scene to constrain something. \
337 The constraint IS the answer — compute the frame you want and \
338 return ChangeVerdict::Adjust(frame); calling constrain_move / \
339 constrain_frame from inside one would recurse without end."
340 );
341 Some(Self { constraint, scene })
342 }
343
344 /// Run the constraint over one proposed frame and return the frame to
345 /// apply.
346 ///
347 /// An [`ChangeVerdict::Adjust`] frame is **validated** before it is handed
348 /// on; see [`frame_is_applicable`] and the module header for why an
349 /// unvalidated one loses the item.
350 pub(crate) fn frame(
351 &self,
352 op: TransformOp,
353 items: &[ItemId],
354 start: &TransformFrame,
355 proposed: TransformFrame,
356 source: TransformSource,
357 magnet_snapped: bool,
358 ) -> TransformFrame {
359 let change = ProposedChange {
360 scene: self.scene,
361 items,
362 op,
363 start: *start,
364 proposed,
365 source,
366 magnet_snapped,
367 };
368 let _guard = ConstraintGuard::enter(self.scene);
369 match (self.constraint)(&change) {
370 // `Accept` and `Reject` hand back a frame the framework built, so
371 // there is nothing consumer-authored in them to check.
372 ChangeVerdict::Accept => proposed,
373 ChangeVerdict::Reject => *start,
374 ChangeVerdict::Adjust(f) if frame_is_applicable(&f) => f,
375 ChangeVerdict::Adjust(f) => refuse_inapplicable_frame(op, &f, start),
376 }
377 }
378}
379
380/// Whether a frame a constraint handed back can be applied at all.
381///
382/// Finite everywhere, and neither extent negative. Both halves are reachable
383/// from ordinary arithmetic in a policy closure — a zero grid step gives `NaN`,
384/// an unclamped "resize by the delta" gives a mirrored box — and both are
385/// silent once stored: a `NaN` `local_pos` puts the item outside every query
386/// (its scene rect becomes `(inf, inf, -inf, -inf)`, which intersects nothing),
387/// and a negative extent inverts its AABB.
388///
389/// A zero extent is *allowed*: the resize path clamps at `min_size` rather than
390/// mirroring, so a zero-extent frame is one a policy closure asked for — and
391/// unlike a negative one it does not invert the item's AABB.
392fn frame_is_applicable(f: &TransformFrame) -> bool {
393 f.rect.x.is_finite()
394 && f.rect.y.is_finite()
395 && f.rect.width.is_finite()
396 && f.rect.height.is_finite()
397 && f.rotation.is_finite()
398 && f.rect.width >= 0.0
399 && f.rect.height >= 0.0
400}
401
402/// What an inapplicable [`ChangeVerdict::Adjust`] means: nothing happens this
403/// sample, and in a debug build the policy hears about it.
404///
405/// Refusing is the only answer that cannot lose data. Clamping would invent a
406/// geometry the app did not ask for and would keep the gesture running against
407/// a rule that is broken; applying it removes the item from the document with
408/// no panic and no event. Refusing leaves the items where the gesture found
409/// them — exactly [`ChangeVerdict::Reject`], which is stateless, so the gesture
410/// resumes the moment the closure returns something applicable again.
411#[cold]
412#[inline(never)]
413fn refuse_inapplicable_frame(
414 op: TransformOp,
415 returned: &TransformFrame,
416 start: &TransformFrame,
417) -> TransformFrame {
418 if cfg!(debug_assertions) {
419 panic!(
420 "a geometry constraint returned ChangeVerdict::Adjust({returned:?}) for a \
421 {op:?}, and that frame cannot be applied: every field must be finite and \
422 neither extent may be negative. The usual cause is arithmetic on a zero \
423 or unset step — `(x / grid).round() * grid` is NaN when `grid` is 0.0. \
424 Applying it would give the item a NaN position and an inverted AABB, \
425 which removes it from hit-testing, from the marquee and from every \
426 spatial query for good, so this sample is refused instead (as \
427 ChangeVerdict::Reject). The proposal the framework offered was \
428 applicable: {start:?} was the frame the gesture started from."
429 );
430 }
431 *start
432}
433
434/// Constrain a pure **translation** of `items` and read the answer back as a
435/// translation.
436///
437/// The one implementation shared by every move-only route — the lightweight
438/// item drag, the `Alt`+arrow nudge and the public
439/// [`SceneModel::constrain_move`](crate::SceneModel::constrain_move) — so an
440/// app's own drag and the view's own drag cannot end up obeying two different
441/// readings of one closure.
442///
443/// Only the returned frame's **origin** is read: a translation is all these
444/// routes can express, so a constraint that also resizes the frame has that
445/// part of its answer ignored rather than silently half-applied.
446pub(crate) fn constrained_translation(
447 call: &ConstraintCall<'_>,
448 items: &[ItemId],
449 start: &TransformFrame,
450 translation: Vec2,
451 source: TransformSource,
452 magnet_snapped: bool,
453) -> Vec2 {
454 let to_frame =
455 Transform2D::rotate(-start.rotation).apply_point(Point::new(translation.x, translation.y));
456 let proposed = TransformFrame {
457 rect: Rect::new(
458 start.rect.x + to_frame.x,
459 start.rect.y + to_frame.y,
460 start.rect.width,
461 start.rect.height,
462 ),
463 ..*start
464 };
465 let out = call.frame(
466 TransformOp::Move,
467 items,
468 start,
469 proposed,
470 source,
471 magnet_snapped,
472 );
473 let d = Transform2D::rotate(start.rotation).apply_point(Point::new(
474 out.rect.x - start.rect.x,
475 out.rect.y - start.rect.y,
476 ));
477 Vec2::new(d.x, d.y)
478}
479
480/// Raises the scene's "a constraint is running" flag for the duration of one
481/// constraint call and lowers it again on the way out — including on an unwind,
482/// which is why it is a guard and not a pair of statements.
483///
484/// The flag is what lets
485/// [`SceneModel::write_guard`](crate::SceneModel::write_guard) name the caller's
486/// mistake instead of reporting `RefCell already borrowed`.
487///
488/// A flag and not a counter: [`ConstraintCall::new`] refuses to build a second
489/// call while one is running, so the only two states are "running" and "not",
490/// and a counter would advertise a nesting the crate does not have.
491struct ConstraintGuard<'a>(&'a Cell<bool>);
492
493impl<'a> ConstraintGuard<'a> {
494 fn enter(scene: &'a Scene) -> Self {
495 let flag = scene.constraint_running_cell();
496 debug_assert!(
497 !flag.get(),
498 "ConstraintGuard entered while a constraint was already running — \
499 ConstraintCall::new is supposed to have refused that."
500 );
501 flag.set(true);
502 Self(flag)
503 }
504}
505
506impl Drop for ConstraintGuard<'_> {
507 fn drop(&mut self) {
508 self.0.set(false);
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use crate::items::RectItem;
516
517 fn change<'a>(
518 scene: &'a Scene,
519 items: &'a [ItemId],
520 start: TransformFrame,
521 proposed: TransformFrame,
522 ) -> ProposedChange<'a> {
523 ProposedChange {
524 scene,
525 items,
526 op: TransformOp::Move,
527 start,
528 proposed,
529 source: TransformSource::Pointer,
530 magnet_snapped: false,
531 }
532 }
533
534 fn frame(x: f32, y: f32, rotation: f32) -> TransformFrame {
535 TransformFrame {
536 rect: Rect::new(x, y, 40.0, 20.0),
537 rotation,
538 count: 1,
539 }
540 }
541
542 #[test]
543 fn translation_and_translated_round_trip_unrotated() {
544 let scene = Scene::new();
545 let ids: Vec<ItemId> = Vec::new();
546 let c = change(&scene, &ids, frame(10.0, 10.0, 0.0), frame(37.0, -4.0, 0.0));
547 let t = c.translation();
548 assert!((t.x - 27.0).abs() < 1e-4, "{t:?}");
549 assert!((t.y + 14.0).abs() < 1e-4, "{t:?}");
550 let back = c.translated(t);
551 assert!((back.rect.x - c.proposed.rect.x).abs() < 1e-4);
552 assert!((back.rect.y - c.proposed.rect.y).abs() < 1e-4);
553 }
554
555 #[test]
556 fn translation_and_translated_round_trip_rotated() {
557 let scene = Scene::new();
558 let ids: Vec<ItemId> = Vec::new();
559 let rot = std::f32::consts::FRAC_PI_4;
560 let c = change(&scene, &ids, frame(10.0, 10.0, rot), frame(37.0, -4.0, rot));
561 let t = c.translation();
562 let back = c.translated(t);
563 assert!((back.rect.x - c.proposed.rect.x).abs() < 1e-3, "{back:?}");
564 assert!((back.rect.y - c.proposed.rect.y).abs() < 1e-3, "{back:?}");
565 }
566
567 #[test]
568 fn reject_is_exactly_adjust_start() {
569 let mut scene = Scene::new();
570 let id = scene.add_item(RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)), Point::ZERO);
571 scene.set_geometry_constraint(|_| ChangeVerdict::Reject);
572 let start = frame(0.0, 0.0, 0.0);
573 let rejected = {
574 let call = ConstraintCall::new(&scene).expect("installed");
575 call.frame(
576 TransformOp::Move,
577 &[id],
578 &start,
579 frame(100.0, 100.0, 0.0),
580 TransformSource::Pointer,
581 false,
582 )
583 };
584 assert_eq!(rejected, start);
585
586 scene.set_geometry_constraint(|c| ChangeVerdict::Adjust(c.start));
587 let adjusted = {
588 let call = ConstraintCall::new(&scene).expect("installed");
589 call.frame(
590 TransformOp::Move,
591 &[id],
592 &start,
593 frame(100.0, 100.0, 0.0),
594 TransformSource::Pointer,
595 false,
596 )
597 };
598 assert_eq!(rejected, adjusted);
599 }
600
601 #[test]
602 fn the_guard_is_raised_only_inside_the_call() {
603 let mut scene = Scene::new();
604 scene.set_geometry_constraint(|c| {
605 assert!(c.scene.in_geometry_constraint(), "depth raised inside");
606 ChangeVerdict::Accept
607 });
608 assert!(!scene.in_geometry_constraint());
609 let start = frame(0.0, 0.0, 0.0);
610 {
611 let call = ConstraintCall::new(&scene).expect("installed");
612 let _ = call.frame(
613 TransformOp::Move,
614 &[],
615 &start,
616 frame(1.0, 1.0, 0.0),
617 TransformSource::Pointer,
618 false,
619 );
620 }
621 assert!(!scene.in_geometry_constraint(), "lowered on the way out");
622 }
623
624 #[test]
625 fn the_guard_is_lowered_when_the_constraint_panics() {
626 let mut scene = Scene::new();
627 scene.set_geometry_constraint(|_| panic!("policy blew up"));
628 let start = frame(0.0, 0.0, 0.0);
629 let hook = std::panic::take_hook();
630 std::panic::set_hook(Box::new(|_| {}));
631 let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
632 let call = ConstraintCall::new(&scene).expect("installed");
633 call.frame(
634 TransformOp::Move,
635 &[],
636 &start,
637 frame(1.0, 1.0, 0.0),
638 TransformSource::Pointer,
639 false,
640 )
641 }));
642 std::panic::set_hook(hook);
643 assert!(caught.is_err());
644 assert!(
645 !scene.in_geometry_constraint(),
646 "a panicking constraint must not strand the scene at depth"
647 );
648 }
649
650 #[test]
651 fn no_constraint_means_no_call() {
652 let scene = Scene::new();
653 assert!(ConstraintCall::new(&scene).is_none());
654 assert!(!scene.has_geometry_constraint());
655 }
656}