Skip to main content

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}