Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/services/ZoneTransition.qml
Fimeg 16c6ee1033 multitasking: one owner for the transition, and the release travels
ZoneTransition owns the card rect, the four overview verbs, the end-target
table and the gesture clock. ZoneOverview and the rail read it; neither
derives a geometry.

Deletes poseActiveZone/clearPose/_posed, zoneCard.scale and
zonePullProgress. The card's ColumnLayout went too — its margin was a second
opinion about where a window sits in its zone; panes are the compositor's
tiling scaled.

The swipe felt wrong because the release committed where the thumb left it.
settleTo() travels to the destination first and commits on arrival, with
quickstep's numbers (350ms cap, min(1/0.7, 1/0.3)). Abandoned half-swipes
settle to last_zone instead of snapping. Dwell gate restored — quickstep
gates the same way and "ever paused" is a latch.

Card box is measured from the surface, not assumed from the screen: the
overview panel respects exclusive zones and its height moves.

Strand repro passes on hardware.
2026-08-07 12:26:12 -04:00

427 lines
20 KiB
QML

// The multitasking transition, and the one thing that owns it.
//
// TASK-60. The fault this replaces was **two writers over one geometry**: the
// rail scaled the real windows through `pose` while `ZoneOverview` scaled its
// cards through its own `scale`, both reading `zonePullProgress` and running
// opposite curves. At the handoff the window was at 0.6 of the glass and the
// card arrived near full bleed. Every symptom — the size pop, the doubled
// frame, and the strand — was that one fault.
//
// So there is one owner and it is this file. It holds:
//
// 1. **The card rect**, computed once here and read by both ends. `ZoneOverview`
// lays its card out at `cardRect`; the carry names `cardRect` as the
// destination. They cannot disagree, because there is nothing to keep in
// agreement — it is the same number.
// 2. **The four verbs**, so the compositor carries the *real* window onto that
// rect. Nothing in the shell transforms a window any more.
// 3. **The end-target table**, so arriving somewhere is one function with one
// branch per destination rather than a set of booleans each caller sets in
// its own order.
//
// ## Releasing is arriving
//
// quickstep's `GestureState` resolves every swipe to exactly one of
// `HOME / RECENTS / NEW_TASK / LAST_TASK / ALL_APPS`, and that is structurally
// why Android cannot strand a window and we could. 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 (Casey's strand repro: btop, multitasking, Home,
// multitasking, tap btop, and btop is a small card in the middle of the
// screen).
//
// Here the transform is released by `commit`/`cancel` inside the compositor,
// and *every* exit is one of those two. `LastZone` is a destination with a
// name, not an abandon branch, for the same reason.
//
// ## Why the geometry is a scaled zone and not a rectangle that looks nice
//
// `Viewtop::carried` takes its scale from the width alone
// (`leg.size.width / size.width`) and applies it uniformly. A destination rect
// of some other aspect would therefore letterbox the real window inside the
// card it is supposed to *be*. So a card is the whole zone drawn small — one
// scale factor, origin included — and a window's target is its own position
// and size run through that same factor. The window lands exactly where the
// card's picture of it is, by construction rather than by tuning.
//
// ## It is a verb, because she gives the tour
//
// Casey, 2026-08-07: an agent asked for a tour of the phone means *"even these
// little gesture steps will be possible."* `shift` and the end target are both
// reachable from the compositor's verb table, and `Overview.qml`'s `swipe` IPC
// drives this whole file without a finger. An agent-driven run stays
// `Origin::Agent` throughout — the compositor never sees a contact — so it
// raises no evidence and buys no idle budget, which is the rule TASK-60 sets
// for the tour and `input.rs` already enforces for her hand.
pragma Singleton
import QtQuick
import Quickshell
import qs
import qs.services
Singleton {
id: root
// --- The geometry, defined once ------------------------------------------
//
// Logical pixels, in the panel's coordinates — which are the output's,
// because the overview's `PanelWindow` is anchored to all four edges. The
// compositor's `at`/`size` are in the same space, so a rect computed here
// is directly a `TransitionTarget` with no conversion to get wrong.
//
// `Quickshell.screens[0]`, not a window's `screen`: this is a singleton and
// has no window, and the phone has one output. The fallbacks are the
// Pixel's logical size so a first frame before the screen list populates
// lays out at the right scale rather than at zero.
readonly property var screen: Quickshell.screens.length > 0 ? Quickshell.screens[0] : null
readonly property real panelWidth: root.screen ? root.screen.width : 540
readonly property real panelHeight: root.screen ? root.screen.height : 1080
// The overview surface's *measured* geometry, reported by the surface
// itself. Assuming it equalled the screen was wrong and wrong in the way
// that hides: the overview `PanelWindow` respects exclusive zones, so the
// status bar's 40 px makes it 1040 tall on a 1080 screen and puts its origin
// 40 px down the output. Computing the card against the raw screen made it
// about 4% too large and 40 px low — close enough to look right in a still
// frame and wrong in exactly the way that reads as a bad hand-off in motion.
//
// Measured 2026-08-07: `loaderPos.h = 811.2`, which is `1040 * 0.78`, not
// `1080 * 0.78`. The surface knows; nothing here should guess.
//
// `surfaceTop` is the output-space y of the surface's origin. Derived from
// what the surface lost to exclusive zones, which is top-anchored here (the
// bar reserves, the pill reserves nothing, the dock is on Overlay). It
// should equal the `at.y` the compositor reports for any tiled window — a
// free cross-check if this is ever suspected.
property real surfaceWidth: root.panelWidth
property real surfaceHeight: root.panelHeight
property real surfaceTop: 0
property real surfaceLeft: 0
// Report the overview surface's real geometry. Called by the surface; the
// only writer of these four.
function measuredAt(left, top, width, height) {
root.surfaceLeft = left;
root.surfaceTop = top;
root.surfaceWidth = Math.max(1, width);
root.surfaceHeight = Math.max(1, height);
}
// `ZoneOverview`'s own frame, restated here because the destination and the
// layout have to be the same arithmetic. Changing one of these moves the
// card and the window it carries together; that is the point.
//
// `listMargin` is the `ListView`'s inset, `labelHeight` the strip under the
// card that names the zone. The loader's height is reported rather than
// derived from a share, for the reason above.
readonly property real listMargin: 12
readonly property real labelHeight: 34
// The box a card is laid out inside, in surface-local coordinates.
readonly property real _availX: root.listMargin
readonly property real _availY: root.listMargin
readonly property real _availW: root.surfaceWidth - 2 * root.listMargin
readonly property real _availH: root.surfaceHeight
- 2 * root.listMargin - root.labelHeight
// One factor. `min` so the card keeps the *zone's* aspect — the zone being
// the whole output, which is what a window's reported rect is measured
// against — and the carried window fills it exactly. See the header on why
// a uniform scale is not a choice here but a consequence of how `carried`
// works.
readonly property real cardScale: Math.max(0.05, Math.min(
root._availW / Math.max(1, root.panelWidth),
root._availH / Math.max(1, root.panelHeight)))
// Where the card the strip has come to rest on actually sits. The
// `ListView` enforces its current item at x = 0 of the list
// (`StrictlyEnforceRange` with `preferredHighlightBegin: 0`), so the
// resting card is the list's origin plus the centring inset.
readonly property real cardWidth: root.panelWidth * root.cardScale
readonly property real cardHeight: root.panelHeight * root.cardScale
readonly property real cardX: root._availX + (root._availW - root.cardWidth) / 2
readonly property real cardY: root._availY + (root._availH - root.cardHeight) / 2
// Where one window goes: its own place in the zone, drawn small.
//
// `cardX`/`cardY` are surface-local because that is what `ZoneOverview` lays
// out in; the compositor speaks output coordinates, so the surface's origin
// is added here. One conversion, at the one boundary where the two spaces
// meet — the alternative is every caller remembering an offset, which is
// the shape of bug this file exists to stop.
function targetFor(w) {
return {
id: w.id,
at: {
x: root.surfaceLeft + root.cardX + w.at.x * root.cardScale,
y: root.surfaceTop + root.cardY + w.at.y * root.cardScale
},
size: {
width: w.size.width * root.cardScale,
height: w.size.height * root.cardScale
}
};
}
// --- One clock, two strategies -------------------------------------------
//
// The gesture reports one number: how far the thumb has climbed. Everything
// visible is a curve over it, and every curve lives here.
//
// This is TASK-52's rule — *one attention model, one clock, effects as
// strategies over it* — and it is the same rule as TASK-60's "one owner",
// one level up. The carry's `shift` and the destination's `presence` are
// genuinely different curves: the window keeps travelling as the climb
// continues past the multitasking detent toward home, while the destination
// has to *recede*, because a preview that stayed would be showing somewhere
// the release is no longer going to take you. Two curves is correct. Two
// *places* is the bug, and it is exactly how the original was built — a
// scale on the rail and a scale on the card, tuned by hand to agree.
//
// `pose` is a gravity well borrowed from the atmosphere primitives, so this
// is her felt environment and not merely navigation chrome. It answers to
// the same discipline.
// How far the thumb has climbed, and where the multitasking detent sits.
// The rail owns the gesture's geometry and reports both; nothing here
// measures a finger.
property real travel: 0
property real detent: 1
// The clock. 0 at rest, 1 at the multitasking detent, and it keeps counting
// past it — the climb toward home is more of the same motion, not a second
// gesture.
readonly property real clock: root.travel / Math.max(1, root.detent)
// Strategy one: what the compositor carries the window on. Clamped, because
// a window's destination is its card and there is nothing beyond it.
readonly property real shift: Math.max(0, Math.min(1, root.clock))
// Strategy two: how present the destination is. Rises to the detent, then
// falls away over the same distance, so the two halves of the climb are
// symmetric and a home-bound pull never arrives at a multitasking view the
// release would not commit.
readonly property real presence: root.clock <= 1
? Math.max(0, root.clock)
: Math.max(0, 1 - (root.clock - 1))
// Advance the gesture. One call per motion event.
function pullTo(travel, detent) {
root.detent = Math.max(1, detent);
root.travel = Math.max(0, travel);
}
// Back to rest. The chrome settles through its own Behavior; the carry is
// released by whatever destination the gesture arrived at.
function rest() {
root.travel = 0;
}
// The compositor is written from the derived value, not from the callers.
//
// A level, not an edge: `travel` moves under a thumb *and* under the settle
// animation below, and both must reach the glass. Pushing from each caller
// instead would mean the settle silently did nothing — which is precisely
// the class of bug where a transform is applied by one path and undone by
// another that forgot.
onShiftChanged: root.progress(root.shift)
// --- The settle ----------------------------------------------------------
//
// **Releasing continues the motion.** This is the piece that was missing,
// and it is why the swipe into multitasking read as awkward no matter how
// the fades were tuned: a lift at 60% of the climb committed *at* 60%, so
// the window jumped from wherever the thumb left it to its final state with
// no travel in between. Two motions with a cut between them.
//
// quickstep does not do that. `AbsSwipeUpHandler.handleNormalGestureEnd`:
//
// float endShift = endTarget.isLauncher ? 1 : 0;
// long expectedDuration = Math.abs(Math.round((endShift - currentShift)
// * MAX_SWIPE_DURATION * SWIPE_DURATION_MULTIPLIER));
// duration = Math.min(MAX_SWIPE_DURATION, expectedDuration);
// startShift = currentShift;
//
// The shift animates from where the thumb left it to where the destination
// is, over a duration proportional to the distance still to cover, and the
// gesture lands only when it gets there. Same numbers here: 350 ms cap, and
// the multiplier is `min(1/0.7, 1/0.3)` from `MIN_PROGRESS_FOR_OVERVIEW`.
//
// `last_zone` is the one target whose end shift is 0 — going back to the app
// means the window travels *down* to full size rather than the view coming
// up. It animates like every other destination instead of being the branch
// that snaps.
readonly property int maxSwipeDuration: 350
readonly property real minProgressForOverview: 0.7
readonly property real swipeDurationMultiplier: Math.min(
1 / root.minProgressForOverview, 1 / (1 - root.minProgressForOverview))
property bool settling: false
property var _settleTarget: null
NumberAnimation {
id: settleAnim
target: root
property: "travel"
easing.type: Easing.OutCubic
onFinished: {
root.settling = false;
const t = root._settleTarget;
root._settleTarget = null;
// Commit *before* zeroing, and the order is load-bearing. Zeroing
// first would drive `shift` to 0 with the carry still in flight —
// the window snapping back to full size for a frame, which is the
// very pop this whole task exists to remove. After `commit` the
// carry is released, so `progress` refuses and the zero is inert
// bookkeeping for the next gesture.
root.commit(t);
root.travel = 0;
}
}
// Release the gesture at a named destination, travelling there first.
function settleTo(target) {
const endShift = (target === "last_zone") ? 0 : 1;
const from = root.shift;
settleAnim.stop();
root._settleTarget = target;
root.settling = true;
settleAnim.from = root.travel;
settleAnim.to = endShift * root.detent;
settleAnim.duration = Math.min(root.maxSwipeDuration,
Math.abs(Math.round((endShift - from)
* root.maxSwipeDuration * root.swipeDurationMultiplier)));
settleAnim.start();
}
// --- The carry -----------------------------------------------------------
// True between `begin` and the `commit`/`cancel` that releases it. Held so
// a progress update with nothing in flight is silence rather than a refusal
// per motion event — the compositor is right to refuse it, and a drag emits
// one of those per frame.
property bool inFlight: false
// Last shift actually written. A drag emits a motion event per frame and
// each one is a socket round-trip; anything under a percent is invisible.
// Same throttle `poseActiveZone` used, kept because the reason was the
// socket and not the pose.
property real _lastShift: -1
// Take the windows on the zone in front and name where each is going.
//
// The active zone only, which is quickstep's model too: `TaskViewSimulator`
// transforms the *live* window of the running task onto its card, and every
// other task in the strip is a snapshot. Carrying all of them would also be
// wrong here for a concrete reason — the rects are named once at `begin`,
// and the strip scrolls, so a window carried onto a card that then slides
// sideways would come adrift from it.
//
// An empty zone carries nothing and says so: home has no windows, and the
// compositor refuses an empty `begin` on purpose ("a carry with no windows
// would release nothing on commit"). The gesture still works — the overview
// is drawn, there is simply no window to bring with it.
function begin() {
const targets = [];
for (const w of ViewtopControl.windows) {
if (w.workspace === ViewtopControl.activeZone && w.at && w.size)
targets.push(root.targetFor(w));
}
if (targets.length === 0)
return false;
root.inFlight = true;
root._lastShift = -1;
ViewtopControl.overviewBegin(targets);
return true;
}
// 0 is where the windows live, 1 is each on its card.
function progress(shift) {
if (!root.inFlight)
return;
const s = Math.max(0, Math.min(1, shift));
if (Math.abs(s - root._lastShift) < 0.01)
return;
root._lastShift = s;
ViewtopControl.overviewProgress(s);
}
// Finish at a named destination: release the carry, then go there.
//
// Both halves, always, and in that order. The compositor releases the
// transforms and reports back where it was told to arrive, but it does not
// navigate — performing the arrival is the caller's, which is this. Doing
// it here rather than at each call site is what makes a card tap and a pill
// release the same machinery, which is precisely what the strand repro
// broke.
//
// Safe with nothing in flight: a card tapped from an already-open overview
// has no carry to release, and still has somewhere to go.
function commit(target) {
if (root.inFlight) {
root.inFlight = false;
root._lastShift = -1;
ViewtopControl.overviewCommit(target);
}
root.arrive(target);
}
// Give up. Named apart from `commit("last_zone")` so the caller says which
// one it meant, matching the compositor's own `cancel`.
function cancel() {
if (root.inFlight) {
root.inFlight = false;
root._lastShift = -1;
ViewtopControl.overviewCancel();
}
root.arrive("last_zone");
}
// The end-target table: one branch per destination, and every gesture
// resolves to exactly one of them.
//
// `target` is the wire's own shape — `"home"`, `"overview"`, `"last_zone"`,
// or `{zone: {zone: N}}` — so the thing sent to the compositor and the
// thing branched on here cannot drift apart into two vocabularies.
function arrive(target) {
if (target === "home") {
// Square one, with the last screen's furniture cleared before the
// strip moves rather than arrived at still wearing it.
GlobalStates.missionControlOpen = false;
GlobalStates.overviewOpen = false;
GlobalStates.dockRevealed = false;
GlobalStates.oskOpen = false;
// `homeZone`, not a config lookup and not the literal 1: home is
// zone 0 (`workspace::HOME_ZONE`), and pointing this at 1 put Home
// on the first *app* zone — invisible while only home existed,
// because the compositor clamps `to` against `count - 1`.
ViewtopControl.zone(ViewtopControl.homeZone);
return;
}
if (target === "overview") {
GlobalStates.missionControlOpen = true;
return;
}
if (target === "last_zone") {
// Already there. The windows went back where they live when the
// compositor released them, and that is the whole of it — this
// branch exists so that "the swipe was given up" is a destination
// with a name rather than the one path nobody wrote.
return;
}
if (target && target.zone !== undefined) {
ViewtopControl.zone(target.zone.zone);
GlobalStates.missionControlOpen = false;
GlobalStates.overviewOpen = false;
return;
}
console.log("[ZoneTransition] arrival with no destination:", JSON.stringify(target));
}
// A zone, in the wire's shape. Spelled once so no call site hand-builds the
// nesting and gets it subtly wrong.
function zoneTarget(zone) {
return { zone: { zone: zone } };
}
}