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}