Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

TransformSession

The selection transform controller: group move, group resize and group rotate, with a frame and handles drawn over the selection.

The one design choice everything else follows from

A transform is a per-view, model-free session. It reads SceneSelection, it paints a frame, and it mutates the scene exactly once — at the end of the gesture — through a single Scene::apply_transform_delta. Nothing is written while the pointer moves.

Three things fall out of that and none of them had to be built:

  • Cancel is session = None. There is no rollback, so there is no rollback to get wrong — and the crate has already shipped one drag whose cancel arm left a stranded translation behind.
  • One gesture is one reversible step. apply_transform_delta is the only write, so the transaction boundary is structural rather than a convention someone has to remember. (The history that makes use of it belongs to the data layer, not here — this crate builds the mechanism and stops.)
  • A pointer sample costs a relayout, not N model writes. A ten-item selection emits zero ItemChanges until the release.

What a resize does, and why there is only one answer

The commit writes local_bounds, and the item reflows into the new box. Not a visual scale that is later baked: a heavyweight card relayouts, a RectItem redraws, a PathItem fits its geometry to the rectangle. A heavyweight entry's local_bounds is its layout size, so there is no second route for it, and since PathItem grew a real fit there is no second route for the lightweight tier either.

The preview is not that, on the lightweight tier

While the pointer is down nothing is written, so a resize can only be shown, and the two tiers show it differently:

tierhow the preview is shownwhat the user sees
heavyweight cardthe placement rectangle is the previewed one, so the widget lays out at the new sizea live reflow; text re-wraps as the handle moves
lightweight itemthe preview affine is composed into the item's local → scenea visual scale; strokes thicken, glyphs stretch

Both land on the same local_bounds write at the release, and both preview the same outline — the box never jumps — but a wrapped TextItem re-wraps at the instant the handle is let go, and a stroked item's hairline snaps back to its real width. That is the honest description of the current mechanism and it is pinned by a_lightweight_resize_previews_the_box_it_commits.

Making the lightweight preview a reflow too needs a channel the paint pass does not have: an item reflows through SceneItem::set_local_bounds, which takes &mut self, and the preview must not write the model — that is what makes "cancel is session = None" true. A previewed-bounds field on SceneItemPaintContext would close it; until then the translation and the rotation halves of the preview are exact on both tiers and the scale half is exact only on the heavyweight one.

What a rotate does, and what it refuses

It post-rotates the item's own Transform2D and orbits its local_pos about the pivot. It is refused for a heavyweight entry, and the reason is not that the framework cannot hit-test a rotated widget — it can, a self transform scope inverse-transforms before testing. It is that SceneView sizes a card from the AABB of its transformed bounds, so a rotation there inflates the layout box and rotates nothing visible. Giving cards a real per-child rotation scope is a change to how they are placed and to how their accessibility rectangles are derived; it is not a line in this module.

Where the gesture is picked up, and the one honest limitation

A SceneView sees a press over a heavyweight child only when that child does not claim it — a card that calls capture_pointer or carries its own on_drag (a Splitter handle, a SpinBox step button, a TextInput's selection drag) wins its own press, by design. So body drag over a card is best-effort, and the reliable route is the controller's own chrome: the frame outline is drawn padding outside the selection, and both the frame band and every handle therefore sit on pixels the card does not own. Grab the frame, not the card.

The three routes, and what a screen reader can reach

The pointer drags a handle. The keyboard enters a roving mode with TransformConfig::transform_key (t by default), Tabs between handles and drives the focused one with the arrows. Assistive technology has neither a pointer nor arrow keys — it has verbs — so each handle publishes the verbs that suit what it is:

handleAccessKit rolevalueIncrement / Decrementcustom actions
an edge midpointSliderthat edge's coordinatemoves that coordinate by one
the rotate puckSliderthe angle in degreesturns it by one degree
a cornerButtonnone — it is two numbersmoves both of its coordinates by oneone per direction
the frame bandButtonnonemoves the whole selection on both axesone per direction

One rule covers the whole table: a verb moves every coordinate the handle drives, and leaves the rest alone. See TransformHandle::at_steps for why a corner answers a one-dimensional verb with a diagonal, and TransformStep for the four named actions that give one axis at a time.

Every route ends in the same LiveSession and the same commit, so docs/a11y/non-drag-alternatives.md's "the alternative must make the same model change the drag makes" is structural rather than asserted. The names are TransformLabels, which takes tr!.

Builder methods at a glance

op

API reference

📖 Full rustdoc API for this module

pub enum TransformHandle

One grab affordance on the selection frame.

Move is the frame band itself (and the body of a selected item); Rotate is the detached puck above the top edge; the other eight are the resize anchors.

#![allow(unused)]
fn main() {
pub enum TransformHandle { /* variants */ }
}

Variants

  • Move — The frame band — drag it to move the whole selection.
  • TopLeading — Top-leading corner.
  • Top — Top edge midpoint.
  • TopTrailing — Top-trailing corner.
  • Leading — Leading edge midpoint.
  • Trailing — Trailing edge midpoint.
  • BottomLeading — Bottom-leading corner.
  • Bottom — Bottom edge midpoint.
  • BottomTrailing — Bottom-trailing corner.
  • Rotate — The rotate puck, above the top edge.

Methods

pub const fn op(self) -> TransformOp

Which operation dragging this handle performs.

pub const fn cursor(self) -> CursorIcon

The pointer cursor for this handle, in an unrotated frame. A rotated frame keeps these — the four diagonal / axis cursors do not have enough resolution to track an arbitrary angle, and guessing wrong is worse than a cursor that is merely approximate.

pub const fn is_scalar(self) -> bool

Whether this handle's state is one number or two.

An edge midpoint carries the coordinate of the edge it drags; the rotate puck carries an angle. Both are one number, so both are a Slider with a numeric_value, and Increment / Decrement mean exactly "make that number bigger / smaller".

A corner carries two coordinates and the frame band carries the whole selection's position, so neither has a single value to announce. They are Buttons, and their one-dimensional verbs move both coordinates — see at_steps.

Three things read this and must agree: the published role, whether a numeric_value is set, and what one assistive-technology verb does.

pub const fn at_steps(self, increment: bool) -> &'static [TransformStep]

The steps one assistive-technology Increment (or Decrement) is made of, as directions the keyboard route already understands.

One rule covers every handle: a step moves each coordinate the handle drives by +1 (Increment) or -1 (Decrement), and leaves the rest alone.

For a scalar handle that is the coordinate its numeric_value announces, which is what makes the slider contract true: Increment on "Resize bottom" raises the bottom edge's y, and the announced number goes up by one. Before this existed every verb was a horizontal nudge, so Top and Bottom reported "handled" and moved nothing at all — the item's height could not be changed by any assistive-technology route.

For a corner the same rule gives the diagonal: Increment on "Resize top leading" moves that corner's x and y up by one, which is exactly Increment on "Resize top" plus Increment on "Resize leading" — the two edges the corner sits between, together. That is the only motion a corner can express with a one-dimensional verb, and it keeps the corner reachable when a configuration offers corners and nothing else. A user who needs one axis at a time reaches it through the four named custom actions a non-scalar handle also publishes (see TransformStep); the frame band works the same way.

pub enum TransformStep

One directional nudge on a transform handle.

The vocabulary the controller's non-pointer routes are stated in: the keyboard's four arrows, the two halves of an assistive-technology Increment / Decrement, and the four custom actions a corner or the frame band publishes so that a screen-reader user can drive one axis at a time. Named for the frame's own axes, like every other name in this crate — Leading is -x and Trailing is +x, whatever the reading direction.

#![allow(unused)]
fn main() {
pub enum TransformStep { /* variants */ }
}

Variants

  • Up — Decrease the driven vertical coordinate.
  • Down — Increase it.
  • Leading — Decrease the driven horizontal coordinate.
  • Trailing — Increase it.

Methods

pub const ALL: [TransformStep;

Every step, in the order a non-scalar handle publishes its custom actions. The position in this array is the accesskit::CustomAction id, so it is part of the published contract: reorder it and an assistive technology holding a stale tree invokes the wrong direction.

pub const fn index(self) -> usize

This step's index in ALL.

pub const fn from_index(index: usize) -> Option<Self>

The step at index, or None — the inverse of index, used to resolve a custom-action id arriving from an assistive technology.

pub enum TransformOp

What one gesture asks of the selection.

#![allow(unused)]
fn main() {
pub enum TransformOp { /* variants */ }
}

Variants

  • Move — Translate the selection.
  • Resize — Change the selection's extent.
  • Rotate — Turn the selection about its centre.

Methods

pub const fn required_flag(self) -> crate::flags::ItemFlags

The per-item flag an item must carry to take part in this operation.

pub struct TransformHandleSet

A bitset of TransformHandles — Konva's enabledAnchors, in the house style of ItemFlags.

#![allow(unused)]
fn main() {
pub struct TransformHandleSet(u16);
}

Methods

pub const NONE: Self = Self(0);

No handles at all.

pub const MOVE: Self = Self(1 << 0);

The frame band only.

pub const CORNERS: Self = Self((1 << 1) | (1 << 3) | (1 << 6) | (1 << 8));

The four diagonal corners.

pub const EDGES: Self = Self((1 << 2) | (1 << 4) | (1 << 5) | (1 << 7));

The four edge midpoints.

pub const ALL_RESIZE: Self = Self(Self::CORNERS.0 | Self::EDGES.0);

Corners and edges.

pub const ROTATE: Self = Self(1 << 9);

The rotate puck only.

pub const ALL: Self = Self(Self::MOVE.0 | Self::ALL_RESIZE.0 | Self::ROTATE.0);

Everything — the default.

pub const fn with(self, h: TransformHandle) -> Self

This set plus h.

pub const fn without(self, h: TransformHandle) -> Self

This set minus h.

pub const fn contains(self, h: TransformHandle) -> bool

Whether h is in the set.

pub const fn bits(self) -> u16

Raw bits (debug / serialization).

pub const fn from_bits(bits: u16) -> Self

Construct from raw bits.

pub struct TransformDelta

What one in-flight gesture asks of the selection, stated once in scene coordinates.

The preview paints it, the commit applies it, the scene's geometry constraint rewrites it and the announcement describes it — so those four can never disagree.

The affine it denotes is, innermost first: move the pivot to the origin, rotate into the frame's basis, scale, rotate back out, rotate by rotation, move the pivot back, translate. The scale is therefore taken along the frame's own axes, which is what makes a rotated single-item frame resize exactly rather than shear.

#[non_exhaustive]: the crate hands this to consumer code — a ProposedChange reads it and TransformConfig::on_end records it — and the affine it denotes may grow a term. Build one with new, IDENTITY or between; the fields stay public and assignable.

#![allow(unused)]
fn main() {
pub struct TransformDelta { /* fields */ }
}

Methods

pub const IDENTITY: Self = Self { pivot: Point { x: 0.0, y: 0.0 }, basis: 0.0, scale: Vec2 { x: 1.0, y: 1.0 }, rotation: 0.0, translation: Vec2 { x: 0.0, y: 0.0 }, };

The delta that changes nothing.

pub fn new(pivot: Point, basis: f32, scale: Vec2, rotation: f32, translation: Vec2) -> Self

A delta stated field by field — the constructor #[non_exhaustive] takes the place of a struct literal for.

The one an app reaches for to apply a programmatic transform through Scene::apply_transform_delta without driving a gesture. See the type's own documentation for the order the five terms compose in.

pub fn is_identity(&self) -> bool

Whether applying this delta would leave every item where it is.

pub fn to_scene_transform(&self) -> Transform2D

The scene-space affine this delta denotes.

pub fn between(start: &TransformFrame, end: &TransformFrame, pivot: Point) -> Self

The delta that takes start to end about pivot.

The inverse of frame_after, and the reason a geometry constraint can hand back an adjusted frame and have the applied delta follow it exactly.

pub fn frame_after(&self, start: &TransformFrame) -> TransformFrame

The frame start becomes under this delta.

pub struct TransformFrame

The box the handles are drawn on.

rect is stated in the frame's own basis: a point p of it sits at Rot(rotation) · p in scene coordinates.

#[non_exhaustive]: the crate hands this to consumer code and asks for it back — a ProposedChange is handed the proposed frame and may answer with an adjusted one — so it is both a receive type and a construct type. Build one with new.

#![allow(unused)]
fn main() {
pub struct TransformFrame { /* fields */ }
}

Methods

pub fn new(rect: Rect, rotation: f32, count: usize) -> Self

A frame stated field by field — the constructor #[non_exhaustive] takes the place of a struct literal for.

A geometry constraint that wants to adjust the frame it was handed normally derives one from that frame rather than building it here; this is for the constraint that states its answer outright, and for a consumer's own tests.

pub fn to_scene(&self, p: Point) -> Point

Map a point of this frame's basis into scene coordinates.

pub fn from_scene(&self, p: Point) -> Point

Map a scene point into this frame's basis.

pub fn centre_scene(&self) -> Point

The frame's centre, in scene coordinates.

pub fn outline(&self, padding: f32) -> Rect

The outline the chrome is drawn on: rect grown by padding scene units on every side.

pub fn handle_point(&self, handle: TransformHandle, padding: f32, rotate_offset: f32) -> Point

Where handle sits, in this frame's basis.

padding and rotate_offset are scene units (a caller converts from screen pixels by dividing by the live view scale, so the chrome keeps a constant on-screen size at any zoom).

pub enum LivePreview

Whether the items follow the gesture or only the frame does.

#![allow(unused)]
fn main() {
pub enum LivePreview { /* variants */ }
}

Variants

  • Live — Items follow the gesture. One relayout of this view per pointer sample.
  • Ghost — Only the frame moves; the items jump at the commit.

pub enum TransformSource

Which route produced a session.

#![allow(unused)]
fn main() {
pub enum TransformSource { /* variants */ }
}

Variants

  • Pointer — A pointer drag on the chrome or on a selected item's body.
  • Keyboard — The keyboard transform mode, or an assistive-technology action.

pub enum TransformOutcome

How a session finished.

#![allow(unused)]
fn main() {
pub enum TransformOutcome { /* variants */ }
}

Variants

  • Committed — The delta was written to the scene.
  • Cancelled — Nothing was written.

pub struct TransformSession

A snapshot of the live session, handed to the three event hooks and published by SceneView::transform_session_signal.

#[non_exhaustive]: the crate hands this to consumer code — it is the argument of all three hooks — and a session may learn to carry more. Build one with new, which is what a consumer's own test of its on_end needs.

#![allow(unused)]
fn main() {
pub struct TransformSession { /* fields */ }
}

Methods

pub fn new( items: Rc<[ItemId]>, handle: TransformHandle, start_frame: TransformFrame, frame: TransformFrame, delta: TransformDelta, source: TransformSource, ) -> Self

A session snapshot stated field by field — the constructor #[non_exhaustive] takes the place of a struct literal for.

The controller builds its own; this exists so a consumer can drive its on_start / on_change / on_end from a test without a live view.

pub fn op(&self) -> TransformOp

The operation this session performs.

pub struct TransformChrome

Everything the chrome painter is told. Scene coordinates throughout — the view transform is already on the canvas, exactly as for MagnetismConfig::feedback.

#![allow(unused)]
fn main() {
pub struct TransformChrome { /* fields */ }
}

pub struct TransformLabels

The assistive-technology names the controller publishes.

Defaults are the crate's own untranslated English, the same way a magnet's node is named "Connection point". Every setter takes impl Into<Prop<String>>, so an app hands it tr!(…) and the names follow the locale.

#![allow(unused)]
fn main() {
pub struct TransformLabels { /* fields */ }
}

Methods

pub fn new() -> Self

English defaults.

pub fn frame(mut self, label: impl Into<Prop<String>>) -> Self

The name of the frame node itself.

pub fn handle(mut self, handle: TransformHandle, label: impl Into<Prop<String>>) -> Self

The name of one handle's node.

pub fn frame_name(&self) -> String

Resolve the frame's name now.

pub fn step(mut self, step: TransformStep, label: impl Into<Prop<String>>) -> Self

The name of one per-axis custom action, published by every handle whose verb is two-dimensional — the four corners and the frame band. The handle's own name supplies the subject, so this names only the direction ("Step up", not "Move the selection up").

pub fn handle_name(&self, handle: TransformHandle) -> String

Resolve one handle's name now.

pub fn step_name(&self, step: TransformStep) -> String

Resolve one step's name now.

pub struct TransformConfig

Per-view transform-controller configuration, installed via SceneView::transform_controller.

Built the way MagnetismConfig is: Rc'd closures so the view can clone it into every handler, Prop-accepting reactive knobs, one enabled signal.

#![allow(unused)]
fn main() {
pub struct TransformConfig { /* fields */ }
}

Methods

pub fn new() -> Self

A controller with every handle, no ratio lock, corner-anchored scaling, 6 px padding, 9 px handles, a 4 × 4 minimum, body drag on, live preview, t for the keyboard route and edge auto-pan on.

pub fn handles(mut self, set: TransformHandleSet) -> Self

Which handles the frame offers. Konva's enabledAnchors.

A handle in the set is still hidden when the selection cannot honour it — see SceneView::transform_controller for the rule. What is drawn is exactly what will happen.

pub fn keep_ratio(mut self, on: impl Into<Prop<bool>>) -> Self

Keep the selection's aspect ratio through a resize. Konva's keepRatio.

pub fn centered_scaling(mut self, on: impl Into<Prop<bool>>) -> Self

Scale about the frame's centre rather than the opposite anchor. Konva's centeredScaling.

pub fn rotation_snaps(mut self, radians: impl IntoIterator<Item = f32>) -> Self

Absolute angles, in radians, a rotation snaps to when it comes within rotation_snap_tolerance.

pub fn rotation_snap_tolerance(mut self, radians: f32) -> Self

How close a rotation has to get to a snap before it takes it. Default 5°.

pub fn padding(mut self, screen_px: f32) -> Self

How far outside the selection the frame is drawn, in screen pixels. Konva's padding.

This is load-bearing, not decoration: it is what puts the frame band and the handles on pixels a heavyweight card does not own, and therefore what makes them grabbable over one. Setting it below handle_px / 2 lets a handle overlap a card, and a card that claims its own press will then take the grab.

pub fn handle_px(mut self, px: f32) -> Self

The on-screen size of a handle, in pixels. Default 9.

pub fn min_size(mut self, w: f32, h: f32) -> Self

The smallest frame a resize will produce, in scene units.

pub fn body_drag(mut self, on: bool) -> Self

Whether a press on the body of a selected, movable item starts a group move. Default on.

Best-effort over the heavyweight tier by construction: a card that claims its own press — one carrying capture_pointer or its own on_drag — wins it, and this view never sees the gesture. The frame band is the route that always works.

pub fn live_preview(mut self, mode: LivePreview) -> Self

Whether the items follow the gesture or only the frame does.

pub fn transform_key(mut self, key: Key) -> Self

The key that enters the keyboard transform mode while the view is focused. Default t, mirroring magnetism's m.

pub fn edge_pan_px(mut self, px: f32) -> Self

How close to the viewport edge a pointer gesture has to get before the view starts panning, in screen pixels. Zero turns auto-pan off.

pub fn edge_pan_speed(mut self, px_per_second: f32) -> Self

Auto-pan speed, in screen pixels per second.

pub fn labels(mut self, labels: TransformLabels) -> Self

The names the frame and its handles publish to assistive technology.

pub fn enabled(mut self, on: impl Into<Prop<bool>>) -> Self

Set the enabled state, statically or reactively.

pub fn enabled_signal(&self) -> Signal<bool>

The reactive enabled signal, for a toolbar to read or bind.

pub fn is_enabled(&self) -> bool

Whether the controller is currently enabled.

pub fn on_start(mut self, f: impl Fn(&TransformSession, &mut EventContext) + 'static) -> Self

Fired once when a gesture begins.

pub fn on_change(mut self, f: impl Fn(&TransformSession, &mut EventContext) + 'static) -> Self

Fired on every pointer sample and every keyboard step.

pub fn on_end( mut self, f: impl Fn(&TransformSession, TransformOutcome, &mut EventContext) + 'static, ) -> Self

Fired once when a gesture finishes, committed or cancelled.

The scene has already been written when the outcome is TransformOutcome::Committed, so this is the hook an app records the delta from — a reversible-edit layer lives above this crate, never inside it.

pub fn chrome( mut self, f: impl Fn(&mut Canvas, &PaintContext, &TransformChrome) + 'static, ) -> Self

Replace the built-in chrome painter. Paints in scene coordinates.

pub const DEFAULT_PADDING_PX

Default frame padding, in screen pixels. Chosen so the handle discs clear the selection's own bounds: a card never owns the pixels a handle is drawn on, which is what makes the chrome grabbable over a heavyweight child.

#![allow(unused)]
fn main() {
pub const DEFAULT_PADDING_PX: f32 = 6.0;
}

pub const DEFAULT_HANDLE_PX

Default handle size, in screen pixels.

#![allow(unused)]
fn main() {
pub const DEFAULT_HANDLE_PX: f32 = 9.0;
}

pub const ROTATE_OFFSET_PX

How far above the frame the rotate puck floats, in screen pixels.

#![allow(unused)]
fn main() {
pub const ROTATE_OFFSET_PX: f32 = 22.0;
}

pub const DEFAULT_EDGE_PAN_PX

Default distance from the viewport edge at which a pointer gesture starts panning the view, in screen pixels.

#![allow(unused)]
fn main() {
pub const DEFAULT_EDGE_PAN_PX: f32 = 28.0;
}

pub const DEFAULT_EDGE_PAN_SPEED

Default auto-pan speed, in screen pixels per second.

#![allow(unused)]
fn main() {
pub const DEFAULT_EDGE_PAN_SPEED: f32 = 700.0;
}

impl LiveSession (methods defined in this file)

Methods

pub fn resolve( &self, cfg: &TransformConfig, view_scale: f32, to_scene: impl Fn(Point) -> Point, constraint: Option<&crate::constrain::ConstraintCall<'_>>, ) -> ResolvedSession

Resolve the gesture into a frame and a delta, applying the config's constraints and the scene's geometry constraint.

view_scale converts the screen-pixel padding into scene units; to_scene projects the live pointer position; constraint is the scene's ProposedChange hook, already bundled with the shared scene borrow it reads (None when none is installed, which is why an unconstrained scene pays one Option test here).

Deterministic and stateless, which is what keeps the preview and the commit in agreement: the constraint sees the same proposal on the release sample as on the one before it, so the frame the chrome last drew is the frame that gets written. There is no phase to branch on and deliberately so — a hook that could snap loosely while dragging and hard on release would be a hook that guarantees a jump at the release.

pub fn snapshot(&self, resolved: ResolvedSession) -> TransformSession

Build the public snapshot from a resolution.

impl TransformRuntime (methods defined in this file)

Methods

pub fn abort(&self) -> Option<LiveSession>

Drop the live gesture and repaint. There is nothing to roll back — that is the whole point of the model-free session.

Returns the session that was dropped, if any.

pub fn bump(&self)

pub fn bump_all(&self)