Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/saf/surface/01-motion.md
Fimeg bde961c6f2 saf: one spine — device, state, and work under the index
PAF becomes saf/device (history kept), STATE.md dissolves
into saf/state.md with the dated era archived, the substrate
SAF moves up from souveraine, and every agreement points at
saf/INDEX.md and nowhere else. one map, nothing to remember
2026-08-18 09:47:30 -04:00

6.4 KiB

Motion

How the shell moves, and who is allowed to move a window. The code is surfaces/quickshell/services/ZoneTransition.qml, modules/souveraine/navigation/, and crates/compositor/src/transition.rs in viewtop.

One clock, two strategies

The rail measures a thumb and reports one number: how far it has climbed. It derives nothing. ZoneTransition.pullTo(travel, detent) takes it, and clock is travel / detent — 0 at rest, 1 at the multitasking detent, and it keeps counting past that, because the climb toward home is more of the same motion and not a second gesture.

Everything visible is a curve over that one number, and every curve lives in that one file.

shift is what the compositor carries the window on. presence is how present the destination is. They are genuinely different: the window keeps travelling as the climb continues toward home, while the destination has to recede, or a release would arrive somewhere it was never going to take you. Two curves is correct. Two places is the fault — a scale on the rail and a scale on the card, tuned by hand to agree, which is how the original was built and what TASK-60 was written to remove.

The card rect is one number, not two that agree

A card is the usable zone drawn small. One scale factor, origin included, because Viewtop::carried takes its scale from the width alone and applies it uniformly — a destination of any other aspect would letterbox the live window inside the card it is supposed to be.

ZoneTransition owns the rect. ZoneOverview lays its card out at cardInset*; the carry names cardX/cardY as the destination. They are the same rectangle in two coordinate spaces, not two rectangles kept in agreement.

contentTop comes off a window's at.y before scaling. The card is a picture of the usable zone, not of the whole output, and a window tiled below the bar's reservation starts at the card's top edge. Miss the subtraction in one of the two places and the carried window lands a bar's height off its own picture.

Releasing is arriving

Every swipe resolves to exactly one end target: home, overview, last_zone, or a named zone. The transform is released by commit or cancel inside the compositor and there is no third way for a carry to end, so a window cannot outlive the gesture that carried it.

That is structural, not careful. The old shape was a transform one surface applied and a different surface had to remember to undo — so tapping a card, which never touches the rail, left a window shrunk with no way back.

last_zone is a destination with a name, not an abandon branch, for the same reason.

The settle is a spring, not a duration

A lift at 60% of the climb used to commit at 60%: the window jumped from where the thumb left it to its final state. Two motions with a cut between them.

A duration and an easing curve cannot carry release velocity, so a flick and a drift settled identically. The settle is a critically damped spring integrated on FrameAnimation — the render clock, so the carry advances once per painted frame — seeded with the velocity the rail measures over the last 100 ms of the gesture. Critically damped and never underdamped: a navigation surface that rings reads as a toy.

Hard-capped at 600 ms. The commit is what releases the carry, so a settle that never converged has to arrive anyway.

Known and not yet fixed: on the last_zone path travel clamps at 0 while the exit test still waits for |velocity| < 0.02, so the commit can lag the visual arrival by up to that cap on the commonest gesture there is.

The transport is fast and lossy, and only the second one matters

Measured on blueline, 2026-08-16, 200 samples, full connect → write → event-loop hop → reply → close: workspaces 0.47 ms median, 1.04 ms worst. Under 3% of a frame. A quantiser protecting against that cost is protecting against nothing, and _lastShift's 0.01 bought a visible staircase — one step per two logical pixels across 275 px of travel. It is 0.002 now.

What the transport is is lossy: 52 failures and 113 PeerClosedErrors in one 90-minute boot, each of which used to execute _queue = []. A lost overview_commit leaves the compositor carrying windows the shell believes it released and nothing in the system will ever put them back. overview_commit, overview_cancel and workspace are idempotent and are redelivered once. overview_progress is a level and still drops.

Rate is bounded by the render clock, not by a quantiser. WindowResize pumps its place the same way.

The curve bank

Appearance.animationCurves is Material 3 Expressive and it has been in the tree since the ii days. Every spatial curve in it overshoots — expressiveFastSpatial peaks at 1.67, expressiveDefaultSpatial at 1.21 — and there is a clickBounce beside them.

Audited 2026-08-16: twelve souveraine surfaces, 31 hand-typed animations, three calls into that bank from two files. Every navigation surface was animating on a flat linear ramp with a hand-typed duration. That was the whole of "the reference compositors have more whoosh."

Use the bank. A hand-typed NumberAnimation { duration: N } in a new surface is a defect unless it says in a comment why the bank is wrong for it.

The rule under all of it

The shell does not transform a window. It names a destination and the compositor carries it there.

Any shell-side scale over a window's geometry is a second writer, and a transform one surface applies and another must remember to undo always has a path that forgets. Where a card must have motion of its own — depth, the entry rise — it is gated on ZoneTransition.inFlight so that during a carry every card sits at its true rect and the motion eases in on the commit frame.

Not yet true

transition.rs:79 clamps shift_to to 0..=1, so the resistance past the detent is written, shipped and inert. Raising it to maxOvershoot (1.22) and updating shift_is_clamped is a compositor change. Safe to ship the shell ahead of it: an old compositor clamps and the gesture behaves exactly as it did.

The control socket also serves one request per connection. The QML client is correct to make commit/cancel/workspace idempotent and retry once today. A persistent connection needs the compositor handler to loop to EOF with one BufReader; rebuilding the reader for each request can discard bytes it read past the first newline. Persistence changes transport cost and failure shape, not the authority or acknowledgement rules above.