Skip to main content

teksilo_scene/
transform_session.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The selection **transform controller**: group move, group resize and
5//! group rotate, with a frame and handles drawn over the selection.
6//!
7//! # The one design choice everything else follows from
8//!
9//! A transform is a **per-view, model-free session**. It reads
10//! [`SceneSelection`](crate::SceneSelection), it paints a frame, and it
11//! mutates the scene **exactly once** — at the end of the gesture — through a
12//! single [`Scene::apply_transform_delta`](crate::Scene::apply_transform_delta).
13//! Nothing is written while the pointer moves.
14//!
15//! Three things fall out of that and none of them had to be built:
16//!
17//! * **Cancel is `session = None`.** There is no rollback, so there is no
18//!   rollback to get wrong — and the crate has already shipped one drag whose
19//!   cancel arm left a stranded translation behind.
20//! * **One gesture is one reversible step.** `apply_transform_delta` is the
21//!   only write, so the transaction boundary is structural rather than a
22//!   convention someone has to remember. (The *history* that makes use of it
23//!   belongs to the data layer, not here — this crate builds the mechanism and
24//!   stops.)
25//! * **A pointer sample costs a relayout, not N model writes.** A ten-item
26//!   selection emits zero `ItemChange`s until the release.
27//!
28//! # What a resize does, and why there is only one answer
29//!
30//! The **commit** writes `local_bounds`, and the item reflows into the new box.
31//! Not a visual scale that is later baked: a heavyweight card relayouts, a
32//! [`RectItem`](crate::RectItem) redraws, a [`PathItem`](crate::PathItem) fits
33//! its geometry to the rectangle. A heavyweight entry's `local_bounds` *is* its
34//! layout size, so there is no second route for it, and since `PathItem` grew a
35//! real fit there is no second route for the lightweight tier either.
36//!
37//! ## The preview is not that, on the lightweight tier
38//!
39//! While the pointer is down nothing is written, so a resize can only be
40//! *shown*, and the two tiers show it differently:
41//!
42//! | tier | how the preview is shown | what the user sees |
43//! | --- | --- | --- |
44//! | heavyweight card | the placement rectangle is the previewed one, so the widget lays out at the new size | a live reflow; text re-wraps as the handle moves |
45//! | lightweight item | the preview affine is composed into the item's `local → scene` | a **visual scale**; strokes thicken, glyphs stretch |
46//!
47//! Both land on the same `local_bounds` write at the release, and both preview
48//! the same *outline* — the box never jumps — but a wrapped
49//! [`TextItem`](crate::TextItem) re-wraps at the instant the handle is let go,
50//! and a stroked item's hairline snaps back to its real width. That is the
51//! honest description of the current mechanism and it is pinned by
52//! `a_lightweight_resize_previews_the_box_it_commits`.
53//!
54//! Making the lightweight preview a reflow too needs a channel the paint pass
55//! does not have: an item reflows through `SceneItem::set_local_bounds`, which
56//! takes `&mut self`, and the preview must not write the model — that is what
57//! makes "cancel is `session = None`" true. A previewed-bounds field on
58//! `SceneItemPaintContext` would close it; until then the translation and the
59//! rotation halves of the preview are exact on both tiers and the scale half is
60//! exact only on the heavyweight one.
61//!
62//! # What a rotate does, and what it refuses
63//!
64//! It post-rotates the item's own `Transform2D` and orbits its `local_pos`
65//! about the pivot. It is refused for a **heavyweight** entry, and the reason
66//! is not that the framework cannot hit-test a rotated widget — it can, a self
67//! transform scope inverse-transforms before testing. It is that `SceneView`
68//! sizes a card from the AABB of its transformed bounds, so a rotation there
69//! inflates the layout box and rotates nothing visible. Giving cards a real
70//! per-child rotation scope is a change to how they are placed and to how their
71//! accessibility rectangles are derived; it is not a line in this module.
72//!
73//! # Where the gesture is picked up, and the one honest limitation
74//!
75//! A `SceneView` sees a press over a heavyweight child only when that child
76//! does not claim it — a card that calls `capture_pointer` or carries its own
77//! `on_drag` (a `Splitter` handle, a `SpinBox` step button, a `TextInput`'s
78//! selection drag) wins its own press, by design. So **body drag over a card is
79//! best-effort**, and the reliable route is the controller's own chrome: the
80//! frame outline is drawn `padding` **outside** the selection, and both the
81//! frame band and every handle therefore sit on pixels the card does not own.
82//! Grab the frame, not the card.
83//!
84//! # The three routes, and what a screen reader can reach
85//!
86//! The pointer drags a handle. The keyboard enters a roving mode with
87//! [`TransformConfig::transform_key`] (`t` by default), `Tab`s between handles
88//! and drives the focused one with the arrows. Assistive technology has neither
89//! a pointer nor arrow keys — it has *verbs* — so each handle publishes the
90//! verbs that suit what it is:
91//!
92//! | handle | AccessKit role | value | `Increment` / `Decrement` | custom actions |
93//! | --- | --- | --- | --- | --- |
94//! | an edge midpoint | `Slider` | that edge's coordinate | moves **that** coordinate by one | — |
95//! | the rotate puck | `Slider` | the angle in degrees | turns it by one degree | — |
96//! | a corner | `Button` | none — it is two numbers | moves **both** of its coordinates by one | one per direction |
97//! | the frame band | `Button` | none | moves the whole selection on both axes | one per direction |
98//!
99//! One rule covers the whole table: **a verb moves every coordinate the handle
100//! drives, and leaves the rest alone.** See [`TransformHandle::at_steps`] for
101//! why a corner answers a one-dimensional verb with a diagonal, and
102//! [`TransformStep`] for the four named actions that give one axis at a time.
103//!
104//! Every route ends in the same [`LiveSession`] and the same commit, so
105//! `docs/a11y/non-drag-alternatives.md`'s "the alternative must make the same
106//! model change the drag makes" is structural rather than asserted. The names
107//! are [`TransformLabels`], which takes `tr!`.
108
109use std::cell::Cell;
110use std::rc::Rc;
111
112use teksilo_canvas::{Canvas, Point, Rect, Transform2D, Vec2};
113use teksilo_core::event::Key;
114use teksilo_core::signal::{Prop, Signal};
115use teksilo_core::widget::{CursorIcon, EventContext, PaintContext};
116use teksilo_core::widget_id::WidgetId;
117
118use crate::item::ItemId;
119
120/// One grab affordance on the selection frame.
121///
122/// [`Move`](Self::Move) is the frame band itself (and the body of a selected
123/// item); [`Rotate`](Self::Rotate) is the detached puck above the top edge; the
124/// other eight are the resize anchors.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
126#[non_exhaustive]
127pub enum TransformHandle {
128    /// The frame band — drag it to move the whole selection.
129    Move,
130    /// Top-leading corner.
131    TopLeading,
132    /// Top edge midpoint.
133    Top,
134    /// Top-trailing corner.
135    TopTrailing,
136    /// Leading edge midpoint.
137    Leading,
138    /// Trailing edge midpoint.
139    Trailing,
140    /// Bottom-leading corner.
141    BottomLeading,
142    /// Bottom edge midpoint.
143    Bottom,
144    /// Bottom-trailing corner.
145    BottomTrailing,
146    /// The rotate puck, above the top edge.
147    Rotate,
148}
149
150/// Every handle, in the order the keyboard route rotates through them.
151pub(crate) const HANDLE_ORDER: [TransformHandle; 10] = [
152    TransformHandle::Move,
153    TransformHandle::TopLeading,
154    TransformHandle::Top,
155    TransformHandle::TopTrailing,
156    TransformHandle::Trailing,
157    TransformHandle::BottomTrailing,
158    TransformHandle::Bottom,
159    TransformHandle::BottomLeading,
160    TransformHandle::Leading,
161    TransformHandle::Rotate,
162];
163
164impl TransformHandle {
165    /// Bit position in a [`TransformHandleSet`], and the AccessKit element id
166    /// of this handle's node (offset by one so the frame keeps id `0`).
167    pub(crate) const fn index(self) -> u16 {
168        match self {
169            Self::Move => 0,
170            Self::TopLeading => 1,
171            Self::Top => 2,
172            Self::TopTrailing => 3,
173            Self::Leading => 4,
174            Self::Trailing => 5,
175            Self::BottomLeading => 6,
176            Self::Bottom => 7,
177            Self::BottomTrailing => 8,
178            Self::Rotate => 9,
179        }
180    }
181
182    /// Which operation dragging this handle performs.
183    pub const fn op(self) -> TransformOp {
184        match self {
185            Self::Move => TransformOp::Move,
186            Self::Rotate => TransformOp::Rotate,
187            _ => TransformOp::Resize,
188        }
189    }
190
191    /// How this handle drives the frame's leading/trailing extent.
192    fn x_role(self) -> Option<SideRole> {
193        match self {
194            Self::TopLeading | Self::Leading | Self::BottomLeading => Some(SideRole::Min),
195            Self::TopTrailing | Self::Trailing | Self::BottomTrailing => Some(SideRole::Max),
196            _ => None,
197        }
198    }
199
200    /// How this handle drives the frame's top/bottom extent.
201    fn y_role(self) -> Option<SideRole> {
202        match self {
203            Self::TopLeading | Self::Top | Self::TopTrailing => Some(SideRole::Min),
204            Self::BottomLeading | Self::Bottom | Self::BottomTrailing => Some(SideRole::Max),
205            _ => None,
206        }
207    }
208
209    /// The pointer cursor for this handle, in an **unrotated** frame. A rotated
210    /// frame keeps these — the four diagonal / axis cursors do not have enough
211    /// resolution to track an arbitrary angle, and guessing wrong is worse than
212    /// a cursor that is merely approximate.
213    pub const fn cursor(self) -> CursorIcon {
214        match self {
215            Self::Move => CursorIcon::Move,
216            Self::TopLeading | Self::BottomTrailing => CursorIcon::NwseResize,
217            Self::TopTrailing | Self::BottomLeading => CursorIcon::NeswResize,
218            Self::Top | Self::Bottom => CursorIcon::RowResize,
219            Self::Leading | Self::Trailing => CursorIcon::ColResize,
220            Self::Rotate => CursorIcon::Grab,
221        }
222    }
223
224    /// The untranslated English name this handle publishes to assistive
225    /// technology when the app installs no [`TransformLabels`] of its own.
226    pub(crate) const fn default_label(self) -> &'static str {
227        match self {
228            Self::Move => "Move selection",
229            Self::TopLeading => "Resize top leading",
230            Self::Top => "Resize top",
231            Self::TopTrailing => "Resize top trailing",
232            Self::Leading => "Resize leading",
233            Self::Trailing => "Resize trailing",
234            Self::BottomLeading => "Resize bottom leading",
235            Self::Bottom => "Resize bottom",
236            Self::BottomTrailing => "Resize bottom trailing",
237            Self::Rotate => "Rotate selection",
238        }
239    }
240
241    /// Whether this handle's state is **one** number or two.
242    ///
243    /// An edge midpoint carries the coordinate of the edge it drags; the rotate
244    /// puck carries an angle. Both are one number, so both are a `Slider` with
245    /// a `numeric_value`, and `Increment` / `Decrement` mean exactly "make that
246    /// number bigger / smaller".
247    ///
248    /// A corner carries two coordinates and the frame band carries the whole
249    /// selection's position, so neither has a single value to announce. They
250    /// are `Button`s, and their one-dimensional verbs move **both** coordinates
251    /// — see [`at_steps`](Self::at_steps).
252    ///
253    /// Three things read this and must agree: the published role, whether a
254    /// `numeric_value` is set, and what one assistive-technology verb does.
255    pub const fn is_scalar(self) -> bool {
256        matches!(
257            self,
258            Self::Top | Self::Bottom | Self::Leading | Self::Trailing | Self::Rotate
259        )
260    }
261
262    /// The steps one assistive-technology `Increment` (or `Decrement`) is made
263    /// of, as directions the keyboard route already understands.
264    ///
265    /// **One rule covers every handle: a step moves each coordinate the handle
266    /// drives by `+1` (`Increment`) or `-1` (`Decrement`), and leaves the rest
267    /// alone.**
268    ///
269    /// For a [scalar](Self::is_scalar) handle that is the coordinate its
270    /// `numeric_value` announces, which is what makes the slider contract true:
271    /// `Increment` on "Resize bottom" raises the bottom edge's `y`, and the
272    /// announced number goes up by one. Before this existed every verb was a
273    /// horizontal nudge, so `Top` and `Bottom` reported "handled" and moved
274    /// nothing at all — the item's height could not be changed by any
275    /// assistive-technology route.
276    ///
277    /// For a corner the same rule gives the diagonal: `Increment` on "Resize
278    /// top leading" moves that corner's `x` **and** `y` up by one, which is
279    /// exactly `Increment` on "Resize top" plus `Increment` on "Resize
280    /// leading" — the two edges the corner sits between, together. That is the
281    /// only motion a corner can express with a one-dimensional verb, and it
282    /// keeps the corner reachable when a configuration offers corners and
283    /// nothing else. A user who needs one axis at a time reaches it through the
284    /// four named custom actions a non-scalar handle also publishes (see
285    /// [`TransformStep`]); the frame band works the same way.
286    pub const fn at_steps(self, increment: bool) -> &'static [TransformStep] {
287        const IN_X: &[TransformStep] = &[TransformStep::Trailing];
288        const DE_X: &[TransformStep] = &[TransformStep::Leading];
289        const IN_Y: &[TransformStep] = &[TransformStep::Down];
290        const DE_Y: &[TransformStep] = &[TransformStep::Up];
291        const IN_XY: &[TransformStep] = &[TransformStep::Trailing, TransformStep::Down];
292        const DE_XY: &[TransformStep] = &[TransformStep::Leading, TransformStep::Up];
293        match self {
294            // The puck's number is an angle, and the keyboard route reads a
295            // trailing/leading arrow as clockwise/anticlockwise — so the same
296            // pair that raises an `x` raises a rotation.
297            Self::Leading | Self::Trailing | Self::Rotate => {
298                if increment {
299                    IN_X
300                } else {
301                    DE_X
302                }
303            }
304            Self::Top | Self::Bottom => {
305                if increment {
306                    IN_Y
307                } else {
308                    DE_Y
309                }
310            }
311            _ => {
312                if increment {
313                    IN_XY
314                } else {
315                    DE_XY
316                }
317            }
318        }
319    }
320}
321
322/// One directional nudge on a transform handle.
323///
324/// The vocabulary the controller's non-pointer routes are stated in: the
325/// keyboard's four arrows, the two halves of an assistive-technology
326/// `Increment` / `Decrement`, and the four **custom actions** a corner or the
327/// frame band publishes so that a screen-reader user can drive one axis at a
328/// time. Named for the frame's own axes, like every other name in this crate —
329/// `Leading` is `-x` and `Trailing` is `+x`, whatever the reading direction.
330#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
331#[non_exhaustive]
332pub enum TransformStep {
333    /// Decrease the driven vertical coordinate.
334    Up,
335    /// Increase it.
336    Down,
337    /// Decrease the driven horizontal coordinate.
338    Leading,
339    /// Increase it.
340    Trailing,
341}
342
343impl TransformStep {
344    /// Every step, in the order a non-scalar handle publishes its custom
345    /// actions. The position in this array **is** the
346    /// [`accesskit::CustomAction`] id, so it is part of the published
347    /// contract: reorder it and an assistive technology holding a stale tree
348    /// invokes the wrong direction.
349    pub const ALL: [TransformStep; 4] = [Self::Up, Self::Down, Self::Leading, Self::Trailing];
350
351    /// This step's index in [`ALL`](Self::ALL).
352    pub const fn index(self) -> usize {
353        match self {
354            Self::Up => 0,
355            Self::Down => 1,
356            Self::Leading => 2,
357            Self::Trailing => 3,
358        }
359    }
360
361    /// The step at `index`, or `None` — the inverse of
362    /// [`index`](Self::index), used to resolve a custom-action id arriving
363    /// from an assistive technology.
364    pub const fn from_index(index: usize) -> Option<Self> {
365        match index {
366            0 => Some(Self::Up),
367            1 => Some(Self::Down),
368            2 => Some(Self::Leading),
369            3 => Some(Self::Trailing),
370            _ => None,
371        }
372    }
373
374    /// The arrow key this step is the name of. One vocabulary, so the keyboard
375    /// route and the assistive-technology route cannot drift apart.
376    pub(crate) const fn key(self) -> Key {
377        match self {
378            Self::Up => Key::ArrowUp,
379            Self::Down => Key::ArrowDown,
380            Self::Leading => Key::ArrowLeft,
381            Self::Trailing => Key::ArrowRight,
382        }
383    }
384
385    /// This step as a one-element slice, so a custom action and a standard
386    /// verb reach [`TransformDriver::access_action`] through one path.
387    ///
388    /// [`TransformDriver::access_action`]: crate::SceneView
389    pub(crate) const fn as_slice(self) -> &'static [TransformStep] {
390        match self {
391            Self::Up => &[Self::Up],
392            Self::Down => &[Self::Down],
393            Self::Leading => &[Self::Leading],
394            Self::Trailing => &[Self::Trailing],
395        }
396    }
397
398    /// The untranslated English name this step publishes when the app installs
399    /// no [`TransformLabels`] of its own. The handle's own name supplies the
400    /// subject, so a screen reader reads "Resize top leading, Step up".
401    pub(crate) const fn default_label(self) -> &'static str {
402        match self {
403            Self::Up => "Step up",
404            Self::Down => "Step down",
405            Self::Leading => "Step leading",
406            Self::Trailing => "Step trailing",
407        }
408    }
409}
410
411#[derive(Debug, Clone, Copy, PartialEq, Eq)]
412enum SideRole {
413    Min,
414    Max,
415}
416
417/// What one gesture asks of the selection.
418#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
419#[non_exhaustive]
420pub enum TransformOp {
421    /// Translate the selection.
422    Move,
423    /// Change the selection's extent.
424    Resize,
425    /// Turn the selection about its centre.
426    Rotate,
427}
428
429impl TransformOp {
430    /// The per-item flag an item must carry to take part in this operation.
431    pub const fn required_flag(self) -> crate::flags::ItemFlags {
432        match self {
433            Self::Move => crate::flags::ItemFlags::IS_DRAGGABLE,
434            Self::Resize => crate::flags::ItemFlags::IS_RESIZABLE,
435            Self::Rotate => crate::flags::ItemFlags::IS_ROTATABLE,
436        }
437    }
438}
439
440/// A bitset of [`TransformHandle`]s — Konva's `enabledAnchors`, in the house
441/// style of [`ItemFlags`](crate::ItemFlags).
442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
443pub struct TransformHandleSet(u16);
444
445impl TransformHandleSet {
446    /// No handles at all.
447    pub const NONE: Self = Self(0);
448    /// The frame band only.
449    pub const MOVE: Self = Self(1 << 0);
450    /// The four diagonal corners.
451    pub const CORNERS: Self = Self((1 << 1) | (1 << 3) | (1 << 6) | (1 << 8));
452    /// The four edge midpoints.
453    pub const EDGES: Self = Self((1 << 2) | (1 << 4) | (1 << 5) | (1 << 7));
454    /// Corners and edges.
455    pub const ALL_RESIZE: Self = Self(Self::CORNERS.0 | Self::EDGES.0);
456    /// The rotate puck only.
457    pub const ROTATE: Self = Self(1 << 9);
458    /// Everything — the default.
459    pub const ALL: Self = Self(Self::MOVE.0 | Self::ALL_RESIZE.0 | Self::ROTATE.0);
460
461    /// This set plus `h`.
462    pub const fn with(self, h: TransformHandle) -> Self {
463        Self(self.0 | (1 << h.index()))
464    }
465
466    /// This set minus `h`.
467    pub const fn without(self, h: TransformHandle) -> Self {
468        Self(self.0 & !(1 << h.index()))
469    }
470
471    /// Whether `h` is in the set.
472    pub const fn contains(self, h: TransformHandle) -> bool {
473        (self.0 & (1 << h.index())) != 0
474    }
475
476    /// Raw bits (debug / serialization).
477    pub const fn bits(self) -> u16 {
478        self.0
479    }
480
481    /// Construct from raw bits.
482    pub const fn from_bits(bits: u16) -> Self {
483        Self(bits)
484    }
485}
486
487impl Default for TransformHandleSet {
488    fn default() -> Self {
489        Self::ALL
490    }
491}
492
493impl std::ops::BitOr for TransformHandleSet {
494    type Output = Self;
495    fn bitor(self, rhs: Self) -> Self {
496        Self(self.0 | rhs.0)
497    }
498}
499
500/// What one in-flight gesture asks of the selection, stated **once** in scene
501/// coordinates.
502///
503/// The preview paints it, the commit applies it, the scene's geometry
504/// constraint rewrites it and
505/// the announcement describes it — so those four can never disagree.
506///
507/// The affine it denotes is, innermost first: move the pivot to the origin,
508/// rotate into the frame's basis, scale, rotate back out, rotate by
509/// [`rotation`](Self::rotation), move the pivot back, translate. The scale is
510/// therefore taken **along the frame's own axes**, which is what makes a
511/// rotated single-item frame resize exactly rather than shear.
512///
513/// `#[non_exhaustive]`: the crate hands this *to* consumer code — a
514/// [`ProposedChange`](crate::ProposedChange) reads it and
515/// [`TransformConfig::on_end`] records it — and the affine it denotes may grow
516/// a term. Build one with [`new`](Self::new), [`IDENTITY`](Self::IDENTITY) or
517/// [`between`](Self::between); the fields stay public and assignable.
518#[derive(Debug, Clone, Copy, PartialEq)]
519#[non_exhaustive]
520pub struct TransformDelta {
521    /// Scene-space point every scale and rotation is taken about.
522    pub pivot: Point,
523    /// The frame's angle, in radians — the basis the [`scale`](Self::scale) is
524    /// expressed in.
525    pub basis: f32,
526    /// Multiplier along the frame's axes. Never negative: a resize stops at
527    /// the controller's `min_size` rather than mirroring, because a
528    /// negative-extent `Rect` is silently un-hit-testable and un-indexable.
529    pub scale: Vec2,
530    /// Additive rotation, in radians.
531    pub rotation: f32,
532    /// Scene-space translation, applied last.
533    pub translation: Vec2,
534}
535
536impl TransformDelta {
537    /// The delta that changes nothing.
538    pub const IDENTITY: Self = Self {
539        pivot: Point { x: 0.0, y: 0.0 },
540        basis: 0.0,
541        scale: Vec2 { x: 1.0, y: 1.0 },
542        rotation: 0.0,
543        translation: Vec2 { x: 0.0, y: 0.0 },
544    };
545
546    /// A delta stated field by field — the constructor
547    /// [`#[non_exhaustive]`](Self) takes the place of a struct literal for.
548    ///
549    /// The one an app reaches for to apply a programmatic transform through
550    /// [`Scene::apply_transform_delta`](crate::Scene::apply_transform_delta)
551    /// without driving a gesture. See the type's own documentation for the
552    /// order the five terms compose in.
553    pub fn new(pivot: Point, basis: f32, scale: Vec2, rotation: f32, translation: Vec2) -> Self {
554        Self {
555            pivot,
556            basis,
557            scale,
558            rotation,
559            translation,
560        }
561    }
562
563    /// Whether applying this delta would leave every item where it is.
564    pub fn is_identity(&self) -> bool {
565        (self.scale.x - 1.0).abs() < 1e-6
566            && (self.scale.y - 1.0).abs() < 1e-6
567            && self.rotation.abs() < 1e-6
568            && self.translation.x.abs() < 1e-4
569            && self.translation.y.abs() < 1e-4
570    }
571
572    /// The scene-space affine this delta denotes.
573    pub fn to_scene_transform(&self) -> Transform2D {
574        Transform2D::translate(-self.pivot.x, -self.pivot.y)
575            .then(&Transform2D::rotate(-self.basis))
576            .then(&Transform2D::scale(self.scale.x, self.scale.y))
577            .then(&Transform2D::rotate(self.basis))
578            .then(&Transform2D::rotate(self.rotation))
579            .then(&Transform2D::translate(self.pivot.x, self.pivot.y))
580            .then(&Transform2D::translate(
581                self.translation.x,
582                self.translation.y,
583            ))
584    }
585
586    /// The delta that takes `start` to `end` about `pivot`.
587    ///
588    /// The inverse of [`frame_after`](Self::frame_after), and the reason a
589    /// [geometry constraint](crate::ProposedChange) can hand back an adjusted
590    /// *frame* and have the applied delta follow it exactly.
591    pub fn between(start: &TransformFrame, end: &TransformFrame, pivot: Point) -> Self {
592        let sx = if start.rect.width.abs() > 1e-6 {
593            end.rect.width / start.rect.width
594        } else {
595            1.0
596        };
597        let sy = if start.rect.height.abs() > 1e-6 {
598            end.rect.height / start.rect.height
599        } else {
600            1.0
601        };
602        let basis = start.rotation;
603        let mut probe = Self {
604            pivot,
605            basis,
606            scale: Vec2::new(sx, sy),
607            rotation: end.rotation - start.rotation,
608            translation: Vec2::ZERO,
609        };
610        // Whatever the pivot-and-scale part did not account for is the
611        // translation. Measured on the frame's own origin rather than derived
612        // algebraically, so the round-trip is exact by construction.
613        let predicted = probe.frame_after(start);
614        let d = Vec2::new(end.rect.x - predicted.rect.x, end.rect.y - predicted.rect.y);
615        // `d` is in frame coordinates; the delta's translation is in scene
616        // coordinates.
617        let scene_d = Transform2D::rotate(end.rotation).apply_point(Point::new(d.x, d.y));
618        probe.translation = Vec2::new(scene_d.x, scene_d.y);
619        probe
620    }
621
622    /// The frame `start` becomes under this delta.
623    pub fn frame_after(&self, start: &TransformFrame) -> TransformFrame {
624        let new_rotation = start.rotation + self.rotation;
625        // Work in the *start* frame's basis, then restate the result in the
626        // new one. A pure rotation leaves the rectangle alone and only turns
627        // the basis, which is exactly what a rotated selection should do.
628        let pivot_f = Transform2D::rotate(-self.basis).apply_point(self.pivot);
629        let x0 = pivot_f.x + (start.rect.x - pivot_f.x) * self.scale.x;
630        let y0 = pivot_f.y + (start.rect.y - pivot_f.y) * self.scale.y;
631        let w = start.rect.width * self.scale.x;
632        let h = start.rect.height * self.scale.y;
633        // The translation is a scene vector; express it in the *new* basis,
634        // which is the basis `rect` is stated in after the rotation.
635        let t = Transform2D::rotate(-new_rotation)
636            .apply_point(Point::new(self.translation.x, self.translation.y));
637        TransformFrame {
638            rect: Rect::new(x0 + t.x, y0 + t.y, w, h),
639            rotation: new_rotation,
640            count: start.count,
641        }
642    }
643}
644
645/// The box the handles are drawn on.
646///
647/// [`rect`](Self::rect) is stated in the frame's **own** basis: a point `p` of
648/// it sits at `Rot(rotation) · p` in scene coordinates.
649///
650/// `#[non_exhaustive]`: the crate hands this *to* consumer code and asks for it
651/// back — a [`ProposedChange`](crate::ProposedChange) is handed the proposed
652/// frame and may answer with an adjusted one — so it is both a receive type and
653/// a construct type. Build one with [`new`](Self::new).
654#[derive(Debug, Clone, Copy, PartialEq)]
655#[non_exhaustive]
656pub struct TransformFrame {
657    /// The union of the selection, in the frame's own (unrotated) basis.
658    pub rect: Rect,
659    /// The frame's angle, in radians. Non-zero only for a single-item
660    /// selection that carries its own rotation — a multi-item union has no
661    /// well-defined angle, so its frame is axis-aligned.
662    pub rotation: f32,
663    /// How many selection roots the frame encloses.
664    pub count: usize,
665}
666
667impl TransformFrame {
668    /// A frame stated field by field — the constructor
669    /// [`#[non_exhaustive]`](Self) takes the place of a struct literal for.
670    ///
671    /// A geometry constraint that wants to adjust the frame it was handed
672    /// normally derives one from that frame rather than building it here; this
673    /// is for the constraint that states its answer outright, and for a
674    /// consumer's own tests.
675    pub fn new(rect: Rect, rotation: f32, count: usize) -> Self {
676        Self {
677            rect,
678            rotation,
679            count,
680        }
681    }
682
683    /// Map a point of this frame's basis into scene coordinates.
684    pub fn to_scene(&self, p: Point) -> Point {
685        Transform2D::rotate(self.rotation).apply_point(p)
686    }
687
688    /// Map a scene point into this frame's basis.
689    pub fn from_scene(&self, p: Point) -> Point {
690        Transform2D::rotate(-self.rotation).apply_point(p)
691    }
692
693    /// The frame's centre, in scene coordinates.
694    pub fn centre_scene(&self) -> Point {
695        self.to_scene(Point::new(
696            self.rect.x + self.rect.width * 0.5,
697            self.rect.y + self.rect.height * 0.5,
698        ))
699    }
700
701    /// The outline the chrome is drawn on: [`rect`](Self::rect) grown by
702    /// `padding` scene units on every side.
703    pub fn outline(&self, padding: f32) -> Rect {
704        Rect::new(
705            self.rect.x - padding,
706            self.rect.y - padding,
707            self.rect.width + padding * 2.0,
708            self.rect.height + padding * 2.0,
709        )
710    }
711
712    /// Where `handle` sits, in this frame's basis.
713    ///
714    /// `padding` and `rotate_offset` are scene units (a caller converts from
715    /// screen pixels by dividing by the live view scale, so the chrome keeps a
716    /// constant on-screen size at any zoom).
717    pub fn handle_point(&self, handle: TransformHandle, padding: f32, rotate_offset: f32) -> Point {
718        let r = self.outline(padding);
719        let cx = r.x + r.width * 0.5;
720        let cy = r.y + r.height * 0.5;
721        match handle {
722            TransformHandle::Move => Point::new(cx, cy),
723            TransformHandle::TopLeading => Point::new(r.x, r.y),
724            TransformHandle::Top => Point::new(cx, r.y),
725            TransformHandle::TopTrailing => Point::new(r.right(), r.y),
726            TransformHandle::Leading => Point::new(r.x, cy),
727            TransformHandle::Trailing => Point::new(r.right(), cy),
728            TransformHandle::BottomLeading => Point::new(r.x, r.bottom()),
729            TransformHandle::Bottom => Point::new(cx, r.bottom()),
730            TransformHandle::BottomTrailing => Point::new(r.right(), r.bottom()),
731            TransformHandle::Rotate => Point::new(cx, r.y - rotate_offset),
732        }
733    }
734}
735
736/// Whether the items follow the gesture or only the frame does.
737#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
738#[non_exhaustive]
739pub enum LivePreview {
740    /// Items follow the gesture. One relayout of this view per pointer sample.
741    #[default]
742    Live,
743    /// Only the frame moves; the items jump at the commit.
744    Ghost,
745}
746
747/// Which route produced a session.
748#[derive(Debug, Clone, Copy, PartialEq, Eq)]
749#[non_exhaustive]
750pub enum TransformSource {
751    /// A pointer drag on the chrome or on a selected item's body.
752    Pointer,
753    /// The keyboard transform mode, or an assistive-technology action.
754    Keyboard,
755}
756
757/// How a session finished.
758#[derive(Debug, Clone, Copy, PartialEq, Eq)]
759#[non_exhaustive]
760pub enum TransformOutcome {
761    /// The delta was written to the scene.
762    Committed,
763    /// Nothing was written.
764    Cancelled,
765}
766
767/// A snapshot of the live session, handed to the three event hooks and
768/// published by
769/// [`SceneView::transform_session_signal`](crate::SceneView::transform_session_signal).
770///
771/// `#[non_exhaustive]`: the crate hands this *to* consumer code — it is the
772/// argument of all three hooks — and a session may learn to carry more. Build
773/// one with [`new`](Self::new), which is what a consumer's own test of its
774/// `on_end` needs.
775#[derive(Debug, Clone)]
776#[non_exhaustive]
777pub struct TransformSession {
778    /// The selection **roots** this gesture will actually change — pruned of
779    /// any item whose ancestor is also in the set (moving the ancestor already
780    /// moves it) and filtered to those carrying the operation's flag.
781    pub items: Rc<[ItemId]>,
782    /// The handle being driven.
783    pub handle: TransformHandle,
784    /// The frame as it was when the gesture started.
785    pub start_frame: TransformFrame,
786    /// The frame now, **after** the scene's geometry constraint.
787    pub frame: TransformFrame,
788    /// The delta now, in agreement with [`frame`](Self::frame).
789    pub delta: TransformDelta,
790    /// Which route produced it.
791    pub source: TransformSource,
792}
793
794impl TransformSession {
795    /// A session snapshot stated field by field — the constructor
796    /// [`#[non_exhaustive]`](Self) takes the place of a struct literal for.
797    ///
798    /// The controller builds its own; this exists so a consumer can drive its
799    /// `on_start` / `on_change` / `on_end` from a test without a live view.
800    pub fn new(
801        items: Rc<[ItemId]>,
802        handle: TransformHandle,
803        start_frame: TransformFrame,
804        frame: TransformFrame,
805        delta: TransformDelta,
806        source: TransformSource,
807    ) -> Self {
808        Self {
809            items,
810            handle,
811            start_frame,
812            frame,
813            delta,
814            source,
815        }
816    }
817
818    /// The operation this session performs.
819    pub fn op(&self) -> TransformOp {
820        self.handle.op()
821    }
822}
823
824/// Everything the chrome painter is told. Scene coordinates throughout — the
825/// view transform is already on the canvas, exactly as for
826/// [`MagnetismConfig::feedback`](crate::MagnetismConfig::feedback).
827#[derive(Debug, Clone)]
828#[non_exhaustive]
829pub struct TransformChrome {
830    /// The frame to draw (the live preview frame during a gesture).
831    pub frame: TransformFrame,
832    /// The outline rectangle, in the frame's basis — `frame.rect` grown by the
833    /// configured padding.
834    pub outline: Rect,
835    /// Every visible handle and its scene-space position.
836    pub handles: Vec<(TransformHandle, Point)>,
837    /// Half the handle's on-screen size, in **scene** units.
838    pub handle_half: f32,
839    /// The live view scale — divide a screen-pixel constant by it to get a
840    /// scene-unit one.
841    pub view_scale: f32,
842    /// The handle under the pointer, if any.
843    pub hovered: Option<TransformHandle>,
844    /// The handle being dragged, if a gesture is live.
845    pub active: Option<TransformHandle>,
846    /// The handle the keyboard transform mode has roving focus on.
847    pub keyboard_focus: Option<TransformHandle>,
848}
849
850/// The assistive-technology names the controller publishes.
851///
852/// Defaults are the crate's own untranslated English, the same way a magnet's
853/// node is named "Connection point". Every setter takes
854/// `impl Into<Prop<String>>`, so an app hands it `tr!(…)` and the names follow
855/// the locale.
856#[derive(Clone)]
857pub struct TransformLabels {
858    frame: Prop<String>,
859    handles: [Option<Prop<String>>; 10],
860    steps: [Option<Prop<String>>; 4],
861}
862
863impl std::fmt::Debug for TransformLabels {
864    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
865        f.debug_struct("TransformLabels")
866            .field("frame", &self.frame.get())
867            .field(
868                "overridden_handles",
869                &self.handles.iter().filter(|h| h.is_some()).count(),
870            )
871            .field(
872                "overridden_steps",
873                &self.steps.iter().filter(|s| s.is_some()).count(),
874            )
875            .finish()
876    }
877}
878
879impl Default for TransformLabels {
880    fn default() -> Self {
881        Self::new()
882    }
883}
884
885impl TransformLabels {
886    /// English defaults.
887    pub fn new() -> Self {
888        Self {
889            frame: Prop::Static("Selection".to_string()),
890            handles: Default::default(),
891            steps: Default::default(),
892        }
893    }
894
895    /// The name of the frame node itself.
896    pub fn frame(mut self, label: impl Into<Prop<String>>) -> Self {
897        self.frame = label.into();
898        self
899    }
900
901    /// The name of one handle's node.
902    pub fn handle(mut self, handle: TransformHandle, label: impl Into<Prop<String>>) -> Self {
903        self.handles[handle.index() as usize] = Some(label.into());
904        self
905    }
906
907    /// Resolve the frame's name now.
908    pub fn frame_name(&self) -> String {
909        self.frame.get()
910    }
911
912    /// The name of one per-axis custom action, published by every handle whose
913    /// verb is two-dimensional — the four corners and the frame band. The
914    /// handle's own name supplies the subject, so this names only the
915    /// direction ("Step up", not "Move the selection up").
916    pub fn step(mut self, step: TransformStep, label: impl Into<Prop<String>>) -> Self {
917        self.steps[step.index()] = Some(label.into());
918        self
919    }
920
921    /// Resolve one handle's name now.
922    pub fn handle_name(&self, handle: TransformHandle) -> String {
923        match &self.handles[handle.index() as usize] {
924            Some(p) => p.get(),
925            None => handle.default_label().to_string(),
926        }
927    }
928
929    /// Resolve one step's name now.
930    pub fn step_name(&self, step: TransformStep) -> String {
931        match &self.steps[step.index()] {
932            Some(p) => p.get(),
933            None => step.default_label().to_string(),
934        }
935    }
936
937    /// Register every label that is actually bound, so a reactive one reaches
938    /// the published tree.
939    ///
940    /// The names are resolved at **walk** time, which is what keeps a `tr!`
941    /// label locale-reactive without a rebuild — but only because a locale
942    /// switch dirties the accessibility tree by itself. A plain
943    /// `Signal<String>` has nothing doing that for it, so without this a
944    /// renamed handle would go on announcing its old name until something else
945    /// happened to dirty the tree.
946    pub(crate) fn register_bindings(
947        &self,
948        widget_id: WidgetId,
949        registry: &teksilo_core::binding::BindingRegistry,
950        level: teksilo_core::binding::BindingLevel,
951    ) {
952        self.frame.register_if_bound(widget_id, registry, level);
953        for label in self.handles.iter().chain(self.steps.iter()).flatten() {
954            label.register_if_bound(widget_id, registry, level);
955        }
956    }
957}
958
959/// Per-view transform-controller configuration, installed via
960/// [`SceneView::transform_controller`](crate::SceneView::transform_controller).
961///
962/// Built the way [`MagnetismConfig`](crate::MagnetismConfig) is: `Rc`'d
963/// closures so the view can clone it into every handler, `Prop`-accepting
964/// reactive knobs, one `enabled` signal.
965#[derive(Clone)]
966pub struct TransformConfig {
967    pub(crate) handles: TransformHandleSet,
968    pub(crate) keep_ratio: Prop<bool>,
969    pub(crate) centered_scaling: Prop<bool>,
970    pub(crate) rotation_snaps: Rc<[f32]>,
971    pub(crate) rotation_snap_tolerance: f32,
972    pub(crate) padding_px: f32,
973    pub(crate) handle_px: f32,
974    pub(crate) min_size: (f32, f32),
975    pub(crate) enabled: Signal<bool>,
976    pub(crate) body_drag: bool,
977    pub(crate) live_preview: LivePreview,
978    pub(crate) transform_key: Key,
979    pub(crate) edge_pan_px: f32,
980    pub(crate) edge_pan_speed: f32,
981    pub(crate) labels: TransformLabels,
982    pub(crate) on_start: Option<Rc<dyn Fn(&TransformSession, &mut EventContext)>>,
983    pub(crate) on_change: Option<Rc<dyn Fn(&TransformSession, &mut EventContext)>>,
984    pub(crate) on_end: Option<Rc<dyn Fn(&TransformSession, TransformOutcome, &mut EventContext)>>,
985    pub(crate) chrome: Option<Rc<dyn Fn(&mut Canvas, &PaintContext, &TransformChrome)>>,
986}
987
988impl std::fmt::Debug for TransformConfig {
989    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
990        f.debug_struct("TransformConfig")
991            .field("handles", &self.handles)
992            .field("keep_ratio", &self.keep_ratio.get())
993            .field("centered_scaling", &self.centered_scaling.get())
994            .field("rotation_snaps", &self.rotation_snaps.len())
995            .field("padding_px", &self.padding_px)
996            .field("handle_px", &self.handle_px)
997            .field("min_size", &self.min_size)
998            .field("body_drag", &self.body_drag)
999            .field("live_preview", &self.live_preview)
1000            .field("edge_pan_px", &self.edge_pan_px)
1001            .field("has_custom_chrome", &self.chrome.is_some())
1002            .field("enabled", &self.enabled.get())
1003            .finish()
1004    }
1005}
1006
1007impl Default for TransformConfig {
1008    fn default() -> Self {
1009        Self::new()
1010    }
1011}
1012
1013/// Default frame padding, in screen pixels. Chosen so the handle discs clear
1014/// the selection's own bounds: a card never owns the pixels a handle is drawn
1015/// on, which is what makes the chrome grabbable over a heavyweight child.
1016pub const DEFAULT_PADDING_PX: f32 = 6.0;
1017/// Default handle size, in screen pixels.
1018pub const DEFAULT_HANDLE_PX: f32 = 9.0;
1019/// How far above the frame the rotate puck floats, in screen pixels.
1020pub const ROTATE_OFFSET_PX: f32 = 22.0;
1021/// Default distance from the viewport edge at which a pointer gesture starts
1022/// panning the view, in screen pixels.
1023pub const DEFAULT_EDGE_PAN_PX: f32 = 28.0;
1024/// Default auto-pan speed, in screen pixels per second.
1025pub const DEFAULT_EDGE_PAN_SPEED: f32 = 700.0;
1026
1027impl TransformConfig {
1028    /// A controller with every handle, no ratio lock, corner-anchored scaling,
1029    /// 6 px padding, 9 px handles, a 4 × 4 minimum, body drag on, live preview,
1030    /// `t` for the keyboard route and edge auto-pan on.
1031    pub fn new() -> Self {
1032        Self {
1033            handles: TransformHandleSet::ALL,
1034            keep_ratio: Prop::Static(false),
1035            centered_scaling: Prop::Static(false),
1036            rotation_snaps: Rc::from(Vec::new()),
1037            rotation_snap_tolerance: 5.0_f32.to_radians(),
1038            padding_px: DEFAULT_PADDING_PX,
1039            handle_px: DEFAULT_HANDLE_PX,
1040            min_size: (4.0, 4.0),
1041            enabled: Signal::new(true),
1042            body_drag: true,
1043            live_preview: LivePreview::Live,
1044            transform_key: Key::Character('t'),
1045            edge_pan_px: DEFAULT_EDGE_PAN_PX,
1046            edge_pan_speed: DEFAULT_EDGE_PAN_SPEED,
1047            labels: TransformLabels::new(),
1048            on_start: None,
1049            on_change: None,
1050            on_end: None,
1051            chrome: None,
1052        }
1053    }
1054
1055    /// Which handles the frame offers. Konva's `enabledAnchors`.
1056    ///
1057    /// A handle in the set is still hidden when the selection cannot honour
1058    /// it — see
1059    /// [`SceneView::transform_controller`](crate::SceneView::transform_controller)
1060    /// for the rule. What is drawn is exactly what will happen.
1061    pub fn handles(mut self, set: TransformHandleSet) -> Self {
1062        self.handles = set;
1063        self
1064    }
1065
1066    /// Keep the selection's aspect ratio through a resize. Konva's `keepRatio`.
1067    pub fn keep_ratio(mut self, on: impl Into<Prop<bool>>) -> Self {
1068        self.keep_ratio = on.into();
1069        self
1070    }
1071
1072    /// Scale about the frame's centre rather than the opposite anchor.
1073    /// Konva's `centeredScaling`.
1074    pub fn centered_scaling(mut self, on: impl Into<Prop<bool>>) -> Self {
1075        self.centered_scaling = on.into();
1076        self
1077    }
1078
1079    /// Absolute angles, in radians, a rotation snaps to when it comes within
1080    /// [`rotation_snap_tolerance`](Self::rotation_snap_tolerance).
1081    pub fn rotation_snaps(mut self, radians: impl IntoIterator<Item = f32>) -> Self {
1082        self.rotation_snaps = Rc::from(radians.into_iter().collect::<Vec<_>>());
1083        self
1084    }
1085
1086    /// How close a rotation has to get to a snap before it takes it. Default 5°.
1087    pub fn rotation_snap_tolerance(mut self, radians: f32) -> Self {
1088        self.rotation_snap_tolerance = radians.max(0.0);
1089        self
1090    }
1091
1092    /// How far outside the selection the frame is drawn, in screen pixels.
1093    /// Konva's `padding`.
1094    ///
1095    /// This is load-bearing, not decoration: it is what puts the frame band and
1096    /// the handles on pixels a heavyweight card does not own, and therefore
1097    /// what makes them grabbable over one. Setting it below
1098    /// `handle_px / 2` lets a handle overlap a card, and a card that claims its
1099    /// own press will then take the grab.
1100    pub fn padding(mut self, screen_px: f32) -> Self {
1101        self.padding_px = screen_px.max(0.0);
1102        self
1103    }
1104
1105    /// The on-screen size of a handle, in pixels. Default 9.
1106    pub fn handle_px(mut self, px: f32) -> Self {
1107        self.handle_px = px.max(1.0);
1108        self
1109    }
1110
1111    /// The smallest frame a resize will produce, in scene units.
1112    pub fn min_size(mut self, w: f32, h: f32) -> Self {
1113        self.min_size = (w.max(0.01), h.max(0.01));
1114        self
1115    }
1116
1117    /// Whether a press on the body of a selected, movable item starts a group
1118    /// move. Default on.
1119    ///
1120    /// Best-effort over the heavyweight tier by construction: a card that
1121    /// claims its own press — one carrying `capture_pointer` or its own
1122    /// `on_drag` — wins it, and this view never sees the gesture. The frame
1123    /// band is the route that always works.
1124    pub fn body_drag(mut self, on: bool) -> Self {
1125        self.body_drag = on;
1126        self
1127    }
1128
1129    /// Whether the items follow the gesture or only the frame does.
1130    pub fn live_preview(mut self, mode: LivePreview) -> Self {
1131        self.live_preview = mode;
1132        self
1133    }
1134
1135    /// The key that enters the keyboard transform mode while the view is
1136    /// focused. Default `t`, mirroring magnetism's `m`.
1137    pub fn transform_key(mut self, key: Key) -> Self {
1138        self.transform_key = key;
1139        self
1140    }
1141
1142    /// How close to the viewport edge a pointer gesture has to get before the
1143    /// view starts panning, in screen pixels. Zero turns auto-pan off.
1144    pub fn edge_pan_px(mut self, px: f32) -> Self {
1145        self.edge_pan_px = px.max(0.0);
1146        self
1147    }
1148
1149    /// Auto-pan speed, in screen pixels per second.
1150    pub fn edge_pan_speed(mut self, px_per_second: f32) -> Self {
1151        self.edge_pan_speed = px_per_second.max(1.0);
1152        self
1153    }
1154
1155    /// The names the frame and its handles publish to assistive technology.
1156    pub fn labels(mut self, labels: TransformLabels) -> Self {
1157        self.labels = labels;
1158        self
1159    }
1160
1161    /// Set the enabled state, statically or reactively.
1162    pub fn enabled(mut self, on: impl Into<Prop<bool>>) -> Self {
1163        self.enabled = on.into().as_signal();
1164        self
1165    }
1166
1167    /// The reactive enabled signal, for a toolbar to read or bind.
1168    pub fn enabled_signal(&self) -> Signal<bool> {
1169        self.enabled.clone()
1170    }
1171
1172    /// Whether the controller is currently enabled.
1173    pub fn is_enabled(&self) -> bool {
1174        self.enabled.get()
1175    }
1176
1177    /// Fired once when a gesture begins.
1178    pub fn on_start(mut self, f: impl Fn(&TransformSession, &mut EventContext) + 'static) -> Self {
1179        self.on_start = Some(Rc::new(f));
1180        self
1181    }
1182
1183    /// Fired on every pointer sample and every keyboard step.
1184    pub fn on_change(mut self, f: impl Fn(&TransformSession, &mut EventContext) + 'static) -> Self {
1185        self.on_change = Some(Rc::new(f));
1186        self
1187    }
1188
1189    /// Fired once when a gesture finishes, committed or cancelled.
1190    ///
1191    /// The scene has already been written when the outcome is
1192    /// [`TransformOutcome::Committed`], so this is the hook an app records the
1193    /// delta from — a reversible-edit layer lives above this crate, never
1194    /// inside it.
1195    pub fn on_end(
1196        mut self,
1197        f: impl Fn(&TransformSession, TransformOutcome, &mut EventContext) + 'static,
1198    ) -> Self {
1199        self.on_end = Some(Rc::new(f));
1200        self
1201    }
1202
1203    /// Replace the built-in chrome painter. Paints in scene coordinates.
1204    pub fn chrome(
1205        mut self,
1206        f: impl Fn(&mut Canvas, &PaintContext, &TransformChrome) + 'static,
1207    ) -> Self {
1208        self.chrome = Some(Rc::new(f));
1209        self
1210    }
1211
1212    /// Whether the handle is offered at all by this configuration.
1213    pub(crate) fn offers(&self, handle: TransformHandle) -> bool {
1214        self.handles.contains(handle)
1215    }
1216}
1217
1218/// The live, per-view state of one gesture.
1219///
1220/// Deliberately not a snapshot of the numbers: it stores the **screen** point
1221/// the pointer is at and re-projects it through the live view transform every
1222/// time the delta is read. That is what makes edge auto-pan fall out — the view
1223/// slides under a stationary pointer and the selection keeps following the
1224/// scene point beneath it — and it is why nothing here needs a frame tick of
1225/// its own.
1226#[derive(Debug, Clone)]
1227pub(crate) struct LiveSession {
1228    pub roots: Rc<[ItemId]>,
1229    pub handle: TransformHandle,
1230    pub start_frame: TransformFrame,
1231    /// Scene point the gesture was grabbed at. Fixed for the gesture's life.
1232    pub anchor_scene: Point,
1233    /// Screen point the pointer is at now. `None` for a keyboard session.
1234    pub current_screen: Option<Point>,
1235    /// Accumulated keyboard step, in the frame's basis. Ignored for a pointer
1236    /// session.
1237    pub keyboard_step: Vec2,
1238    /// Accumulated keyboard rotation, in radians.
1239    pub keyboard_rotation: f32,
1240    pub source: TransformSource,
1241    /// Whether any root is aspect-locked, resolved once at grab time.
1242    pub aspect_locked: bool,
1243}
1244
1245/// The two numbers a resolved session yields, always in agreement.
1246#[derive(Debug, Clone, Copy, PartialEq)]
1247pub(crate) struct ResolvedSession {
1248    pub frame: TransformFrame,
1249    pub delta: TransformDelta,
1250}
1251
1252impl LiveSession {
1253    /// Resolve the gesture into a frame and a delta, applying the config's
1254    /// constraints and the scene's geometry constraint.
1255    ///
1256    /// `view_scale` converts the screen-pixel padding into scene units;
1257    /// `to_scene` projects the live pointer position; `constraint` is the
1258    /// scene's [`ProposedChange`](crate::ProposedChange) hook, already bundled
1259    /// with the shared scene borrow it reads (`None` when none is installed,
1260    /// which is why an unconstrained scene pays one `Option` test here).
1261    ///
1262    /// **Deterministic and stateless**, which is what keeps the preview and the
1263    /// commit in agreement: the constraint sees the same proposal on the
1264    /// release sample as on the one before it, so the frame the chrome last
1265    /// drew is the frame that gets written. There is no phase to branch on and
1266    /// deliberately so — a hook that could snap loosely while dragging and hard
1267    /// on release would be a hook that guarantees a jump at the release.
1268    pub fn resolve(
1269        &self,
1270        cfg: &TransformConfig,
1271        view_scale: f32,
1272        to_scene: impl Fn(Point) -> Point,
1273        constraint: Option<&crate::constrain::ConstraintCall<'_>>,
1274    ) -> ResolvedSession {
1275        let pad = cfg.padding_px / view_scale.max(1e-3);
1276        let current_scene = match self.current_screen {
1277            Some(p) => to_scene(p),
1278            None => self.anchor_scene,
1279        };
1280        let mut delta = self.raw_delta(cfg, pad, current_scene);
1281        let mut frame = delta.frame_after(&self.start_frame);
1282        if let Some(call) = constraint {
1283            let adjusted = call.frame(
1284                self.handle.op(),
1285                &self.roots,
1286                &self.start_frame,
1287                frame,
1288                self.source,
1289                // Magnetism does not run on the transform-controller route; see
1290                // `ProposedChange::magnet_snapped`.
1291                false,
1292            );
1293            if adjusted != frame {
1294                frame = adjusted;
1295                delta = TransformDelta::between(&self.start_frame, &frame, delta.pivot);
1296            }
1297        }
1298        ResolvedSession { frame, delta }
1299    }
1300
1301    /// The delta before the geometry constraint sees it.
1302    fn raw_delta(&self, cfg: &TransformConfig, pad: f32, current_scene: Point) -> TransformDelta {
1303        let basis = self.start_frame.rotation;
1304        let centre = self.start_frame.centre_scene();
1305        match self.handle {
1306            TransformHandle::Move => {
1307                let t = match self.source {
1308                    TransformSource::Pointer => Vec2::new(
1309                        current_scene.x - self.anchor_scene.x,
1310                        current_scene.y - self.anchor_scene.y,
1311                    ),
1312                    // A keyboard step is stated in the frame's basis, so it
1313                    // moves the selection along its own axes.
1314                    TransformSource::Keyboard => {
1315                        let p = Transform2D::rotate(basis)
1316                            .apply_point(Point::new(self.keyboard_step.x, self.keyboard_step.y));
1317                        Vec2::new(p.x, p.y)
1318                    }
1319                };
1320                TransformDelta {
1321                    pivot: centre,
1322                    basis,
1323                    scale: Vec2::new(1.0, 1.0),
1324                    rotation: 0.0,
1325                    translation: t,
1326                }
1327            }
1328            TransformHandle::Rotate => {
1329                let raw = match self.source {
1330                    TransformSource::Pointer => {
1331                        let a = angle_of(self.anchor_scene, centre);
1332                        let b = angle_of(current_scene, centre);
1333                        normalize_angle(b - a)
1334                    }
1335                    TransformSource::Keyboard => self.keyboard_rotation,
1336                };
1337                let snapped = snap_rotation(cfg, basis, raw);
1338                TransformDelta {
1339                    pivot: centre,
1340                    basis,
1341                    scale: Vec2::new(1.0, 1.0),
1342                    rotation: snapped,
1343                    translation: Vec2::ZERO,
1344                }
1345            }
1346            h => self.resize_delta(cfg, pad, current_scene, h),
1347        }
1348    }
1349
1350    fn resize_delta(
1351        &self,
1352        cfg: &TransformConfig,
1353        pad: f32,
1354        current_scene: Point,
1355        handle: TransformHandle,
1356    ) -> TransformDelta {
1357        let basis = self.start_frame.rotation;
1358        let c0 = self.start_frame.rect;
1359        let to_frame = Transform2D::rotate(-basis);
1360        // The pointer's travel, expressed along the frame's own axes.
1361        let d = match self.source {
1362            TransformSource::Pointer => {
1363                let a = to_frame.apply_point(self.anchor_scene);
1364                let b = to_frame.apply_point(current_scene);
1365                Vec2::new(b.x - a.x, b.y - a.y)
1366            }
1367            TransformSource::Keyboard => self.keyboard_step,
1368        };
1369        let centered = cfg.centered_scaling.get();
1370        let x_role = handle.x_role();
1371        let y_role = handle.y_role();
1372
1373        // The pivot: the frame's centre under centred scaling, otherwise the
1374        // side opposite the one being dragged. The padding does not enter here
1375        // — the pointer is grabbing a padded corner, but what is being scaled
1376        // is the content box, and both corners carry the same padding so the
1377        // offset cancels.
1378        let pivot_f = Point::new(
1379            match (centered, x_role) {
1380                (true, _) | (_, None) => c0.x + c0.width * 0.5,
1381                (false, Some(SideRole::Min)) => c0.right(),
1382                (false, Some(SideRole::Max)) => c0.x,
1383            },
1384            match (centered, y_role) {
1385                (true, _) | (_, None) => c0.y + c0.height * 0.5,
1386                (false, Some(SideRole::Min)) => c0.bottom(),
1387                (false, Some(SideRole::Max)) => c0.y,
1388            },
1389        );
1390        let _ = pad;
1391
1392        let mut sx = axis_scale(x_role, c0.x, c0.right(), pivot_f.x, d.x);
1393        let mut sy = axis_scale(y_role, c0.y, c0.bottom(), pivot_f.y, d.y);
1394
1395        if cfg.keep_ratio.get() || self.aspect_locked {
1396            // Whichever axis the pointer actually pushed hardest decides, and
1397            // the other follows. An edge handle drives both axes, which is
1398            // Konva's behaviour and the only thing that keeps the ratio.
1399            let s = match (x_role, y_role) {
1400                (Some(_), Some(_)) => {
1401                    if d.x.abs() >= d.y.abs() {
1402                        sx
1403                    } else {
1404                        sy
1405                    }
1406                }
1407                (Some(_), None) => sx,
1408                (None, Some(_)) => sy,
1409                (None, None) => 1.0,
1410            };
1411            sx = s;
1412            sy = s;
1413        }
1414
1415        // The floor. A resize stops here rather than mirroring: `Rect::contains`
1416        // is false for every point of a negative-extent rectangle, so a flipped
1417        // box would be silently un-hit-testable and un-indexable.
1418        //
1419        // It applies **only to an axis this handle drives**. Applied to both
1420        // unconditionally it inflated the other one: an item already thinner
1421        // than `min_size` has a floor scale above 1 on that axis, so dragging
1422        // the `Bottom` edge of a 2 x 100 hairline widened it to 4 — a
1423        // horizontal change from a purely vertical gesture, on the first sample,
1424        // silently. `axis_scale` returns exactly 1.0 for an axis with no role,
1425        // so "does this handle drive it" is `role.is_some()` and nothing more.
1426        let min_sx = if c0.width.abs() > 1e-6 {
1427            cfg.min_size.0 / c0.width
1428        } else {
1429            1.0
1430        };
1431        let min_sy = if c0.height.abs() > 1e-6 {
1432            cfg.min_size.1 / c0.height
1433        } else {
1434            1.0
1435        };
1436        if cfg.keep_ratio.get() || self.aspect_locked {
1437            // One scale drives both axes here, so both floors bear on it and
1438            // the tighter wins. Clamping the two apart is the ratio lock
1439            // quietly coming undone at the minimum size.
1440            let floor = min_sx.max(min_sy);
1441            if x_role.is_some() || y_role.is_some() {
1442                sx = sx.max(floor);
1443                sy = sy.max(floor);
1444            }
1445        } else {
1446            if x_role.is_some() {
1447                sx = sx.max(min_sx);
1448            }
1449            if y_role.is_some() {
1450                sy = sy.max(min_sy);
1451            }
1452        }
1453
1454        TransformDelta {
1455            pivot: self.start_frame.to_scene(pivot_f),
1456            basis,
1457            scale: Vec2::new(sx, sy),
1458            rotation: 0.0,
1459            translation: Vec2::ZERO,
1460        }
1461    }
1462
1463    /// Build the public snapshot from a resolution.
1464    pub fn snapshot(&self, resolved: ResolvedSession) -> TransformSession {
1465        TransformSession {
1466            items: self.roots.clone(),
1467            handle: self.handle,
1468            start_frame: self.start_frame,
1469            frame: resolved.frame,
1470            delta: resolved.delta,
1471            source: self.source,
1472        }
1473    }
1474}
1475
1476/// How far one side moves as a multiple of its distance from the pivot.
1477fn axis_scale(role: Option<SideRole>, min: f32, max: f32, pivot: f32, d: f32) -> f32 {
1478    let (side, sign) = match role {
1479        None => return 1.0,
1480        Some(SideRole::Min) => (min, 1.0),
1481        Some(SideRole::Max) => (max, 1.0),
1482    };
1483    let old = side - pivot;
1484    if old.abs() < 1e-6 {
1485        return 1.0;
1486    }
1487    let new = old + d * sign;
1488    new / old
1489}
1490
1491fn angle_of(p: Point, centre: Point) -> f32 {
1492    (p.y - centre.y).atan2(p.x - centre.x)
1493}
1494
1495fn normalize_angle(a: f32) -> f32 {
1496    let tau = std::f32::consts::TAU;
1497    let mut r = a % tau;
1498    if r > std::f32::consts::PI {
1499        r -= tau;
1500    } else if r < -std::f32::consts::PI {
1501        r += tau;
1502    }
1503    r
1504}
1505
1506/// Pull an additive rotation onto the nearest configured absolute snap, when it
1507/// is within tolerance of one.
1508fn snap_rotation(cfg: &TransformConfig, basis: f32, raw: f32) -> f32 {
1509    if cfg.rotation_snaps.is_empty() {
1510        return raw;
1511    }
1512    let absolute = basis + raw;
1513    let mut best: Option<(f32, f32)> = None;
1514    for snap in cfg.rotation_snaps.iter().copied() {
1515        let diff = normalize_angle(snap - absolute).abs();
1516        if diff <= cfg.rotation_snap_tolerance && best.is_none_or(|(_, bd)| diff < bd) {
1517            best = Some((snap, diff));
1518        }
1519    }
1520    match best {
1521        Some((snap, _)) => snap - basis,
1522        None => raw,
1523    }
1524}
1525
1526/// The selection resolved for the chrome: the roots the frame encloses, the
1527/// frame itself, and the handles that frame may offer.
1528///
1529/// One value because those are one question, asked together by everything that
1530/// draws, grabs or announces the frame. Asking them one at a time cost the
1531/// **free hover** - every `PointerMove` an application makes over the view -
1532/// thirteen descendant-pruning passes over the selection per sample: one for
1533/// the frame, six for the handle set, six more for the second handle set the
1534/// band test needed. Each pass is `Scene::selection_roots`, so on a large
1535/// selection the hover was the single most expensive thing a `SceneView` did,
1536/// and no functional test could see it.
1537#[derive(Debug, Clone)]
1538pub(crate) struct SelectionChrome {
1539    /// The box the selection's roots make. Not flag-filtered: the frame shows
1540    /// what is *selected*, and each operation then decides which of those it
1541    /// can honour.
1542    pub frame: TransformFrame,
1543    /// The handles this frame offers: configured **and** honourable.
1544    pub handles: Rc<[TransformHandle]>,
1545    /// Whether the selection can honour each operation at all, in
1546    /// [`HANDLE_ORDER`]'s own op vocabulary: move, resize, rotate.
1547    ///
1548    /// Kept beside `handles` rather than derived from it, because the two
1549    /// answer different questions: a config may decline to *offer* a resize
1550    /// handle on a selection that would happily resize.
1551    pub supports_move: bool,
1552    /// See [`supports_move`](Self::supports_move).
1553    pub supports_resize: bool,
1554    /// See [`supports_move`](Self::supports_move).
1555    pub supports_rotate: bool,
1556}
1557
1558/// What a cached [`SelectionChrome`] was computed against.
1559///
1560/// Three counters, and between them they cover everything the answer reads.
1561/// `model` is [`Scene::item_change_version`](crate::Scene), which every
1562/// geometry, flag and structure mutation advances **under the exclusive borrow
1563/// that made it** - so a value read under a shared borrow describes the model
1564/// as it stands, whether or not the matching notifications have fanned out yet.
1565/// The other two are signal write generations, monotone by contract. The
1566/// remaining inputs - which handles the config offers, its padding - are fixed
1567/// at build time.
1568///
1569/// Nothing invalidates this by hand. A memo with an invalidation call site is a
1570/// memo with a missed invalidation call site; this one is simply wrong-keyed or
1571/// right-keyed.
1572#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1573pub(crate) struct ChromeKey {
1574    /// `Scene::item_change_version`.
1575    pub model: u64,
1576    /// The selection signal's write generation.
1577    pub selection: u64,
1578    /// The `enabled` signal's write generation.
1579    pub enabled: u64,
1580}
1581
1582/// The view-side runtime state of the controller, kept out of `SceneView`'s
1583/// field list so the controller can be reasoned about as one thing.
1584pub(crate) struct TransformRuntime {
1585    /// The live gesture, if one is running.
1586    pub session: std::cell::RefCell<Option<LiveSession>>,
1587    /// Posted at the end of a committed gesture, drained by `build()` where
1588    /// `&mut Scene` is reachable.
1589    pub pending_commit: std::cell::RefCell<Option<(Vec<ItemId>, TransformDelta)>>,
1590    /// Whether the keyboard transform mode is active.
1591    pub keyboard_mode: Cell<bool>,
1592    /// The handle the keyboard mode has roving focus on.
1593    pub keyboard_focus: Cell<Option<TransformHandle>>,
1594    /// Whether an edge auto-pan tween is this controller's to stop.
1595    ///
1596    /// Without it the "stop panning" path would touch `pan_x` / `pan_y` on
1597    /// every sample of every gesture — and those are bound at `Relayout`, so a
1598    /// controller with auto-pan configured but never triggered would schedule a
1599    /// relayout each sample for nothing, and worse, would make the one that
1600    /// *should* come from the controller's own tick indistinguishable from it.
1601    pub edge_panning: Cell<bool>,
1602    /// The handle under the pointer, for the chrome and the cursor.
1603    pub hovered: Cell<Option<TransformHandle>>,
1604    /// Where focus was when a **pointer** gesture took it, so the gesture can
1605    /// give it back.
1606    ///
1607    /// The steal is not cosmetic — `Esc` is delivered to the focused widget, so
1608    /// without it the cancel route would be unreachable for the whole of a
1609    /// mouse or touch gesture. But a caret in a card's `TextInput` is the user's
1610    /// place in their own work, and a drag is not a request to leave it. Set
1611    /// only for [`TransformSource::Pointer`]; a keyboard or AT route already
1612    /// has the focus it needs.
1613    pub focus_before: Cell<Option<WidgetId>>,
1614    /// Bumped on every session change; bound at `BindingLevel::Relayout` so
1615    /// both tiers re-derive their preview from the same value.
1616    ///
1617    /// This is the trigger the design needs and a plain `Cell` cannot be:
1618    /// `place_children` re-runs on a relayout, and the heavyweight tier's
1619    /// preview lives there. Without it the frame and the lightweight items
1620    /// would follow the pointer while the cards sat still until the commit.
1621    pub tick: Signal<u64>,
1622    /// Bumped only on discrete steps — session start, session end, keyboard
1623    /// roving — and bound at `BindingLevel::AccessibilityOnly`.
1624    ///
1625    /// Deliberately **not** bumped per pointer sample: a full accessibility
1626    /// re-walk at pointer rate would cost the live set on every sample to move
1627    /// ten nodes, and no screen-reader user is driving a pointer drag. The
1628    /// commit's own model change re-walks at the end.
1629    pub at_tick: Signal<u64>,
1630    /// The public session signal.
1631    pub published: Signal<Option<TransformSession>>,
1632    /// The last [`SelectionChrome`] and the [`ChromeKey`] it was computed
1633    /// against. `Some(key, None)` memoises "this selection has no frame",
1634    /// which is the commonest state of all - nothing selected - and the one a
1635    /// naive memo would recompute on every sample.
1636    pub chrome_memo: std::cell::RefCell<Option<(ChromeKey, Option<SelectionChrome>)>>,
1637    /// How many times the chrome was actually *computed* rather than reused.
1638    ///
1639    /// The memo's own witness, and the only thing that can tell a working memo
1640    /// from one whose key is stale on every read. A hover costs no model work
1641    /// at all now, so nothing else a test can observe changes when the memo is
1642    /// deleted.
1643    #[cfg(test)]
1644    pub chrome_computes: Cell<u64>,
1645}
1646
1647impl std::fmt::Debug for TransformRuntime {
1648    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1649        f.debug_struct("TransformRuntime")
1650            .field("has_session", &self.session.borrow().is_some())
1651            .field("keyboard_mode", &self.keyboard_mode.get())
1652            .field("keyboard_focus", &self.keyboard_focus.get())
1653            .finish()
1654    }
1655}
1656
1657impl Default for TransformRuntime {
1658    fn default() -> Self {
1659        Self {
1660            session: std::cell::RefCell::new(None),
1661            pending_commit: std::cell::RefCell::new(None),
1662            keyboard_mode: Cell::new(false),
1663            keyboard_focus: Cell::new(None),
1664            edge_panning: Cell::new(false),
1665            hovered: Cell::new(None),
1666            focus_before: Cell::new(None),
1667            tick: Signal::new(0),
1668            at_tick: Signal::new(0),
1669            published: Signal::new(None),
1670            chrome_memo: std::cell::RefCell::new(None),
1671            #[cfg(test)]
1672            chrome_computes: Cell::new(0),
1673        }
1674    }
1675}
1676
1677impl TransformRuntime {
1678    /// Drop the live gesture and repaint. There is nothing to roll back — that
1679    /// is the whole point of the model-free session.
1680    ///
1681    /// Returns the session that was dropped, if any.
1682    pub fn abort(&self) -> Option<LiveSession> {
1683        let had = self.session.borrow_mut().take();
1684        if had.is_some() {
1685            self.published.set(None);
1686            self.bump_all();
1687        }
1688        had
1689    }
1690
1691    pub fn bump(&self) {
1692        self.tick.set(self.tick.get().wrapping_add(1));
1693    }
1694
1695    pub fn bump_all(&self) {
1696        self.bump();
1697        self.at_tick.set(self.at_tick.get().wrapping_add(1));
1698    }
1699}
1700
1701#[cfg(test)]
1702mod tests {
1703    use super::*;
1704
1705    fn frame(x: f32, y: f32, w: f32, h: f32) -> TransformFrame {
1706        TransformFrame {
1707            rect: Rect::new(x, y, w, h),
1708            rotation: 0.0,
1709            count: 1,
1710        }
1711    }
1712
1713    #[test]
1714    fn identity_delta_is_identity() {
1715        assert!(TransformDelta::IDENTITY.is_identity());
1716        assert!(TransformDelta::IDENTITY.to_scene_transform().is_identity());
1717    }
1718
1719    #[test]
1720    fn a_translation_moves_a_point_by_itself() {
1721        let d = TransformDelta {
1722            translation: Vec2::new(10.0, -4.0),
1723            ..TransformDelta::IDENTITY
1724        };
1725        let p = d.to_scene_transform().apply_point(Point::new(1.0, 1.0));
1726        assert!((p.x - 11.0).abs() < 1e-4 && (p.y + 3.0).abs() < 1e-4);
1727    }
1728
1729    #[test]
1730    fn a_scale_about_a_pivot_leaves_the_pivot_alone() {
1731        let d = TransformDelta {
1732            pivot: Point::new(5.0, 5.0),
1733            scale: Vec2::new(2.0, 3.0),
1734            ..TransformDelta::IDENTITY
1735        };
1736        let t = d.to_scene_transform();
1737        let p = t.apply_point(Point::new(5.0, 5.0));
1738        assert!((p.x - 5.0).abs() < 1e-4 && (p.y - 5.0).abs() < 1e-4);
1739        let q = t.apply_point(Point::new(7.0, 6.0));
1740        assert!((q.x - 9.0).abs() < 1e-4, "{q:?}");
1741        assert!((q.y - 8.0).abs() < 1e-4, "{q:?}");
1742    }
1743
1744    #[test]
1745    fn scale_is_taken_along_the_frames_own_axes() {
1746        // A frame rotated 90°: scaling its "width" must stretch along scene y.
1747        let d = TransformDelta {
1748            pivot: Point::ZERO,
1749            basis: std::f32::consts::FRAC_PI_2,
1750            scale: Vec2::new(2.0, 1.0),
1751            ..TransformDelta::IDENTITY
1752        };
1753        let p = d.to_scene_transform().apply_point(Point::new(0.0, 10.0));
1754        assert!(p.x.abs() < 1e-3, "{p:?}");
1755        assert!((p.y - 20.0).abs() < 1e-3, "{p:?}");
1756    }
1757
1758    #[test]
1759    fn frame_after_and_between_round_trip() {
1760        let start = frame(10.0, 20.0, 100.0, 50.0);
1761        let d = TransformDelta {
1762            pivot: Point::new(10.0, 20.0),
1763            basis: 0.0,
1764            scale: Vec2::new(1.5, 0.5),
1765            rotation: 0.0,
1766            translation: Vec2::new(7.0, -3.0),
1767        };
1768        let end = d.frame_after(&start);
1769        let back = TransformDelta::between(&start, &end, d.pivot);
1770        let end2 = back.frame_after(&start);
1771        assert!((end2.rect.x - end.rect.x).abs() < 1e-3, "{end2:?} {end:?}");
1772        assert!((end2.rect.y - end.rect.y).abs() < 1e-3);
1773        assert!((end2.rect.width - end.rect.width).abs() < 1e-3);
1774        assert!((end2.rect.height - end.rect.height).abs() < 1e-3);
1775    }
1776
1777    #[test]
1778    fn round_trip_holds_for_a_rotated_frame() {
1779        let start = TransformFrame {
1780            rect: Rect::new(-50.0, -25.0, 100.0, 50.0),
1781            rotation: 0.6,
1782            count: 1,
1783        };
1784        let d = TransformDelta {
1785            pivot: Point::new(3.0, -2.0),
1786            basis: 0.6,
1787            scale: Vec2::new(1.25, 0.8),
1788            rotation: 0.0,
1789            translation: Vec2::new(4.0, 9.0),
1790        };
1791        let end = d.frame_after(&start);
1792        let back = TransformDelta::between(&start, &end, d.pivot);
1793        let end2 = back.frame_after(&start);
1794        assert!((end2.rect.x - end.rect.x).abs() < 1e-2, "{end2:?} {end:?}");
1795        assert!((end2.rect.y - end.rect.y).abs() < 1e-2);
1796        assert!((end2.rect.width - end.rect.width).abs() < 1e-2);
1797        assert!((end2.rect.height - end.rect.height).abs() < 1e-2);
1798    }
1799
1800    fn session(handle: TransformHandle, start: TransformFrame, anchor: Point) -> LiveSession {
1801        LiveSession {
1802            roots: Rc::from(Vec::new()),
1803            handle,
1804            start_frame: start,
1805            anchor_scene: anchor,
1806            current_screen: Some(anchor),
1807            keyboard_step: Vec2::ZERO,
1808            keyboard_rotation: 0.0,
1809            source: TransformSource::Pointer,
1810            aspect_locked: false,
1811        }
1812    }
1813
1814    #[test]
1815    fn dragging_the_bottom_trailing_corner_scales_from_the_top_leading_one() {
1816        let cfg = TransformConfig::new();
1817        let start = frame(0.0, 0.0, 100.0, 100.0);
1818        let mut s = session(
1819            TransformHandle::BottomTrailing,
1820            start,
1821            Point::new(100.0, 100.0),
1822        );
1823        s.current_screen = Some(Point::new(150.0, 200.0));
1824        let r = s.resolve(&cfg, 1.0, |p| p, None);
1825        assert!((r.delta.scale.x - 1.5).abs() < 1e-4, "{:?}", r.delta);
1826        assert!((r.delta.scale.y - 2.0).abs() < 1e-4, "{:?}", r.delta);
1827        assert!((r.delta.pivot.x).abs() < 1e-4 && (r.delta.pivot.y).abs() < 1e-4);
1828        assert!((r.frame.rect.width - 150.0).abs() < 1e-3);
1829        assert!((r.frame.rect.height - 200.0).abs() < 1e-3);
1830        assert!(r.frame.rect.x.abs() < 1e-3 && r.frame.rect.y.abs() < 1e-3);
1831    }
1832
1833    #[test]
1834    fn dragging_the_leading_edge_holds_the_trailing_one() {
1835        let cfg = TransformConfig::new();
1836        let start = frame(0.0, 0.0, 100.0, 100.0);
1837        let mut s = session(TransformHandle::Leading, start, Point::new(0.0, 50.0));
1838        s.current_screen = Some(Point::new(40.0, 50.0));
1839        let r = s.resolve(&cfg, 1.0, |p| p, None);
1840        assert!((r.frame.rect.x - 40.0).abs() < 1e-3, "{:?}", r.frame);
1841        assert!((r.frame.rect.right() - 100.0).abs() < 1e-3, "{:?}", r.frame);
1842        assert!((r.frame.rect.height - 100.0).abs() < 1e-3);
1843    }
1844
1845    #[test]
1846    fn a_resize_stops_at_min_size_instead_of_mirroring() {
1847        let cfg = TransformConfig::new().min_size(10.0, 10.0);
1848        let start = frame(0.0, 0.0, 100.0, 100.0);
1849        let mut s = session(
1850            TransformHandle::BottomTrailing,
1851            start,
1852            Point::new(100.0, 100.0),
1853        );
1854        // Dragged far past the opposite corner.
1855        s.current_screen = Some(Point::new(-400.0, -400.0));
1856        let r = s.resolve(&cfg, 1.0, |p| p, None);
1857        assert!(r.frame.rect.width >= 10.0 - 1e-3, "{:?}", r.frame);
1858        assert!(r.frame.rect.height >= 10.0 - 1e-3);
1859        assert!(r.delta.scale.x > 0.0 && r.delta.scale.y > 0.0);
1860        assert!((r.frame.rect.width - 10.0).abs() < 1e-3);
1861    }
1862
1863    #[test]
1864    fn a_min_size_never_inflates_an_axis_the_handle_does_not_drive() {
1865        // A 2 x 100 hairline under the default 4 x 4 floor. Its floor scale on
1866        // x is 2.0, and `Bottom` has no x role at all — so clamping both axes
1867        // unconditionally doubled the width on the first sample of a purely
1868        // vertical drag, silently, before the pointer had moved 20 units.
1869        let cfg = TransformConfig::new().min_size(4.0, 4.0);
1870        let start = frame(0.0, 0.0, 2.0, 100.0);
1871        let mut s = session(TransformHandle::Bottom, start, Point::new(1.0, 100.0));
1872        s.current_screen = Some(Point::new(1.0, 120.0));
1873        let r = s.resolve(&cfg, 1.0, |p| p, None);
1874        assert!(
1875            (r.delta.scale.x - 1.0).abs() < 1e-6,
1876            "a vertical handle must not scale x: {:?}",
1877            r.delta
1878        );
1879        assert!(
1880            (r.frame.rect.width - 2.0).abs() < 1e-3,
1881            "the width is the gesture's business only if the handle drives it: {:?}",
1882            r.frame
1883        );
1884        assert!((r.frame.rect.height - 120.0).abs() < 1e-3, "{:?}", r.frame);
1885    }
1886
1887    #[test]
1888    fn the_min_size_floor_still_bites_on_the_axis_the_handle_drives() {
1889        // The other half of the same rule: the axis with a role is clamped,
1890        // including on an item that starts under the floor.
1891        let cfg = TransformConfig::new().min_size(4.0, 4.0);
1892        let start = frame(0.0, 0.0, 100.0, 2.0);
1893        let mut s = session(TransformHandle::Bottom, start, Point::new(50.0, 2.0));
1894        s.current_screen = Some(Point::new(50.0, -50.0));
1895        let r = s.resolve(&cfg, 1.0, |p| p, None);
1896        assert!(
1897            (r.frame.rect.height - 4.0).abs() < 1e-3,
1898            "a driven axis stops at the floor rather than mirroring: {:?}",
1899            r.frame
1900        );
1901        assert!((r.frame.rect.width - 100.0).abs() < 1e-3, "{:?}", r.frame);
1902    }
1903
1904    #[test]
1905    fn a_ratio_locked_resize_takes_the_tighter_of_the_two_floors() {
1906        // Under a ratio lock one scale drives both axes, so both floors bear on
1907        // it and the tighter wins — clamping them apart is the lock coming
1908        // undone exactly at the minimum size.
1909        let cfg = TransformConfig::new().min_size(10.0, 10.0).keep_ratio(true);
1910        let start = frame(0.0, 0.0, 100.0, 50.0);
1911        let mut s = session(
1912            TransformHandle::BottomTrailing,
1913            start,
1914            Point::new(100.0, 50.0),
1915        );
1916        s.current_screen = Some(Point::new(-400.0, -400.0));
1917        let r = s.resolve(&cfg, 1.0, |p| p, None);
1918        assert!(
1919            (r.delta.scale.x - r.delta.scale.y).abs() < 1e-6,
1920            "the ratio survives the floor: {:?}",
1921            r.delta
1922        );
1923        assert!(
1924            r.frame.rect.width >= 10.0 - 1e-3 && r.frame.rect.height >= 10.0 - 1e-3,
1925            "{:?}",
1926            r.frame
1927        );
1928    }
1929
1930    #[test]
1931    fn centered_scaling_holds_the_centre() {
1932        let cfg = TransformConfig::new().centered_scaling(true);
1933        let start = frame(0.0, 0.0, 100.0, 100.0);
1934        let mut s = session(TransformHandle::Trailing, start, Point::new(100.0, 50.0));
1935        s.current_screen = Some(Point::new(125.0, 50.0));
1936        let r = s.resolve(&cfg, 1.0, |p| p, None);
1937        let cx = r.frame.rect.x + r.frame.rect.width * 0.5;
1938        assert!((cx - 50.0).abs() < 1e-3, "{:?}", r.frame);
1939        assert!((r.frame.rect.width - 150.0).abs() < 1e-3, "{:?}", r.frame);
1940    }
1941
1942    #[test]
1943    fn keep_ratio_drives_both_axes_from_one_edge() {
1944        let cfg = TransformConfig::new().keep_ratio(true);
1945        let start = frame(0.0, 0.0, 100.0, 50.0);
1946        let mut s = session(TransformHandle::Trailing, start, Point::new(100.0, 25.0));
1947        s.current_screen = Some(Point::new(150.0, 25.0));
1948        let r = s.resolve(&cfg, 1.0, |p| p, None);
1949        assert!((r.delta.scale.x - 1.5).abs() < 1e-4);
1950        assert!((r.delta.scale.y - 1.5).abs() < 1e-4);
1951    }
1952
1953    #[test]
1954    fn rotation_snaps_only_within_tolerance() {
1955        let quarter = std::f32::consts::FRAC_PI_2;
1956        let cfg = TransformConfig::new()
1957            .rotation_snaps([0.0, quarter])
1958            .rotation_snap_tolerance(0.15);
1959        let start = frame(-50.0, -50.0, 100.0, 100.0);
1960        // Grab at 0 rad from the centre, release ~0.102 rad shy of 90°.
1961        let mut s = session(TransformHandle::Rotate, start, Point::new(50.0, 0.0));
1962        s.current_screen = Some(Point::new(5.0, 49.0));
1963        let r = s.resolve(&cfg, 1.0, |p| p, None);
1964        assert!(
1965            (r.delta.rotation - quarter).abs() < 1e-3,
1966            "should snap: {:?}",
1967            r.delta
1968        );
1969
1970        let cfg_far = TransformConfig::new()
1971            .rotation_snaps([0.0, quarter])
1972            .rotation_snap_tolerance(0.001);
1973        let r2 = s.resolve(&cfg_far, 1.0, |p| p, None);
1974        assert!(
1975            (r2.delta.rotation - quarter).abs() > 1e-3,
1976            "should not snap: {:?}",
1977            r2.delta
1978        );
1979    }
1980
1981    #[test]
1982    fn the_geometry_constraint_rewrites_the_delta_not_just_the_drawing() {
1983        let cfg = TransformConfig::new();
1984        let mut scene = crate::scene::Scene::new();
1985        scene.set_geometry_constraint(|c| {
1986            let mut f = c.proposed;
1987            f.rect.width = f.rect.width.min(120.0);
1988            crate::constrain::ChangeVerdict::Adjust(f)
1989        });
1990        let call = crate::constrain::ConstraintCall::new(&scene).expect("installed");
1991        let start = frame(0.0, 0.0, 100.0, 100.0);
1992        let mut s = session(
1993            TransformHandle::BottomTrailing,
1994            start,
1995            Point::new(100.0, 100.0),
1996        );
1997        s.current_screen = Some(Point::new(300.0, 100.0));
1998        let r = s.resolve(&cfg, 1.0, |p| p, Some(&call));
1999        assert!((r.frame.rect.width - 120.0).abs() < 1e-3, "{:?}", r.frame);
2000        // …and the delta agrees, which is what stops the chrome and the commit
2001        // from telling two different stories.
2002        let applied = r.delta.frame_after(&start);
2003        assert!((applied.rect.width - 120.0).abs() < 1e-3, "{applied:?}");
2004    }
2005
2006    #[test]
2007    fn the_constraint_is_told_the_op_the_handle_performs() {
2008        use std::cell::RefCell;
2009        let seen: Rc<RefCell<Vec<TransformOp>>> = Rc::new(RefCell::new(Vec::new()));
2010        let sink = seen.clone();
2011        let cfg = TransformConfig::new();
2012        let mut scene = crate::scene::Scene::new();
2013        scene.set_geometry_constraint(move |c| {
2014            sink.borrow_mut().push(c.op);
2015            crate::constrain::ChangeVerdict::Accept
2016        });
2017        let call = crate::constrain::ConstraintCall::new(&scene).expect("installed");
2018        let start = frame(0.0, 0.0, 100.0, 100.0);
2019        for (handle, expect) in [
2020            (TransformHandle::Move, TransformOp::Move),
2021            (TransformHandle::BottomTrailing, TransformOp::Resize),
2022            (TransformHandle::Rotate, TransformOp::Rotate),
2023        ] {
2024            let mut s = session(handle, start, Point::new(100.0, 100.0));
2025            s.current_screen = Some(Point::new(140.0, 160.0));
2026            let _ = s.resolve(&cfg, 1.0, |p| p, Some(&call));
2027            assert_eq!(seen.borrow().last().copied(), Some(expect), "{handle:?}");
2028        }
2029    }
2030
2031    #[test]
2032    fn resolving_twice_gives_the_same_answer_so_preview_and_commit_agree() {
2033        let cfg = TransformConfig::new();
2034        let mut scene = crate::scene::Scene::new();
2035        // A constraint that would be tempted to behave differently over time.
2036        let calls = std::cell::Cell::new(0u32);
2037        scene.set_geometry_constraint(move |c| {
2038            calls.set(calls.get() + 1);
2039            let mut f = c.proposed;
2040            f.rect.x = (f.rect.x / 25.0).round() * 25.0;
2041            f.rect.y = (f.rect.y / 25.0).round() * 25.0;
2042            crate::constrain::ChangeVerdict::Adjust(f)
2043        });
2044        let call = crate::constrain::ConstraintCall::new(&scene).expect("installed");
2045        let start = frame(0.0, 0.0, 100.0, 100.0);
2046        let mut s = session(TransformHandle::Move, start, Point::ZERO);
2047        s.current_screen = Some(Point::new(37.0, 61.0));
2048        let preview = s.resolve(&cfg, 1.0, |p| p, Some(&call));
2049        let commit = s.resolve(&cfg, 1.0, |p| p, Some(&call));
2050        assert_eq!(preview.frame, commit.frame);
2051        assert_eq!(preview.delta, commit.delta);
2052        assert!(
2053            (commit.frame.rect.x - 25.0).abs() < 1e-3,
2054            "{:?}",
2055            commit.frame
2056        );
2057        assert!(
2058            (commit.frame.rect.y - 50.0).abs() < 1e-3,
2059            "{:?}",
2060            commit.frame
2061        );
2062    }
2063
2064    #[test]
2065    fn a_rejected_sample_resolves_to_the_identity() {
2066        let cfg = TransformConfig::new();
2067        let mut scene = crate::scene::Scene::new();
2068        scene.set_geometry_constraint(|_| crate::constrain::ChangeVerdict::Reject);
2069        let call = crate::constrain::ConstraintCall::new(&scene).expect("installed");
2070        let start = frame(0.0, 0.0, 100.0, 100.0);
2071        let mut s = session(TransformHandle::Move, start, Point::ZERO);
2072        s.current_screen = Some(Point::new(400.0, 400.0));
2073        let r = s.resolve(&cfg, 1.0, |p| p, Some(&call));
2074        assert_eq!(r.frame, start);
2075        assert!(r.delta.is_identity(), "{:?}", r.delta);
2076    }
2077
2078    #[test]
2079    fn a_handle_set_round_trips_its_bits() {
2080        let s = TransformHandleSet::CORNERS.with(TransformHandle::Rotate);
2081        assert!(s.contains(TransformHandle::TopLeading));
2082        assert!(s.contains(TransformHandle::Rotate));
2083        assert!(!s.contains(TransformHandle::Top));
2084        assert_eq!(TransformHandleSet::from_bits(s.bits()), s);
2085        assert!(
2086            !s.without(TransformHandle::Rotate)
2087                .contains(TransformHandle::Rotate)
2088        );
2089    }
2090
2091    #[test]
2092    fn every_handle_has_a_distinct_index_and_a_name() {
2093        let mut seen = std::collections::HashSet::new();
2094        for h in HANDLE_ORDER {
2095            assert!(seen.insert(h.index()), "duplicate index for {h:?}");
2096            assert!(!h.default_label().is_empty());
2097        }
2098        assert_eq!(seen.len(), 10);
2099    }
2100
2101    #[test]
2102    fn a_keyboard_move_steps_along_the_frames_axes() {
2103        let cfg = TransformConfig::new();
2104        let start = TransformFrame {
2105            rect: Rect::new(0.0, 0.0, 10.0, 10.0),
2106            rotation: std::f32::consts::FRAC_PI_2,
2107            count: 1,
2108        };
2109        let mut s = session(TransformHandle::Move, start, Point::ZERO);
2110        s.source = TransformSource::Keyboard;
2111        s.current_screen = None;
2112        s.keyboard_step = Vec2::new(5.0, 0.0);
2113        let r = s.resolve(&cfg, 1.0, |p| p, None);
2114        // The frame's x axis points along scene +y at 90°.
2115        assert!(r.delta.translation.x.abs() < 1e-3, "{:?}", r.delta);
2116        assert!((r.delta.translation.y - 5.0).abs() < 1e-3, "{:?}", r.delta);
2117    }
2118}