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.
This commit is contained in:
parent
72171accec
commit
16c6ee1033
8 changed files with 914 additions and 225 deletions
|
|
@ -164,30 +164,31 @@ Singleton {
|
|||
// (app launcher/search) — this is the running-work view.
|
||||
property bool missionControlOpen: false
|
||||
|
||||
// WRITER: navigation rail, during an upward drag only. 0 at rest, 1 at the
|
||||
// multitasking detent.
|
||||
// GONE: zonePullProgress. Its job moved to `ZoneTransition` (TASK-60).
|
||||
//
|
||||
// The one progress value the transition and the destination share. The rail
|
||||
// shrinks the *real* windows through `pose` as the thumb climbs, and
|
||||
// `ZoneOverview` enters against this same number, so the card the drag was
|
||||
// pulling toward is already the size the window had reached when it takes
|
||||
// over. Two surfaces animating the same motion from two clocks is the thing
|
||||
// that made the first attempt read as a scale effect with a view bolted
|
||||
// after it — TASK-60's acceptance names it: *"no frame where a window is
|
||||
// scaled by one and laid out by the other."*
|
||||
// It was the right idea — one level the transition and the destination
|
||||
// share — and the wrong home. The motion needs *two* curves over one clock:
|
||||
// the carry keeps travelling as the thumb climbs past the multitasking
|
||||
// detent toward home, while the destination has to recede, or the preview
|
||||
// shows somewhere the release will not take you. Two curves derived in two
|
||||
// files is how this task's original bug was built, so both now live with
|
||||
// the owner: `ZoneTransition.clock`, `.shift`, `.presence`.
|
||||
//
|
||||
// A plain number rather than a signal because it is a level: a surface that
|
||||
// appears mid-drag needs to know where the drag *is*, not to have missed
|
||||
// the edge where it started.
|
||||
property real zonePullProgress: 0
|
||||
// Not left here as a mirror. A state property nobody writes is a shadow
|
||||
// copy waiting for a reader, and this file has a rule about those.
|
||||
|
||||
// WRITER: navigation rail, while a pull dwells past the multitasking
|
||||
// detent. The cards are DRAWN, nothing is committed — release still decides.
|
||||
// Gates presentation only (visibility, mask, which body the loader builds);
|
||||
// never focus, dismissal or state, or a preview would be a decision.
|
||||
// WRITER: navigation rail, for the whole of an upward pull. The cards are
|
||||
// DRAWN, nothing is committed — release still decides. Gates presentation
|
||||
// only (visibility, mask, which body the loader builds); never focus,
|
||||
// dismissal or state, or a preview would be a decision.
|
||||
//
|
||||
// Dwell rather than any pull, so a fast flick to Home never flashes the
|
||||
// blur on its way past.
|
||||
// Any pull, not a dwell. It used to wait 140 ms of holding still, which
|
||||
// meant an ordinary swipe shrank the real window over the live app and then
|
||||
// the entire destination appeared in one frame — the "swipe to this swap is
|
||||
// awkward" complaint, and TASK-60's unmet acceptance. The old worry, that a
|
||||
// fast flick to Home would flash the blur, is answered by the fade being
|
||||
// continuous and receding past the detent rather than by hiding the surface
|
||||
// until the thumb stops.
|
||||
property bool missionPeek: false
|
||||
|
||||
// WRITER: Dock.qml IPC (swipeDown). A rail swipe-down on a visible
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ modules/souveraine/windowSheet/PowerMenu.qml souveraine/modules/souveraine/windo
|
|||
modules/souveraine/windowSheet/qmldir souveraine/modules/souveraine/windowSheet/qmldir
|
||||
services/Face.qml souveraine/services/Face.qml
|
||||
services/ViewtopControl.qml souveraine/services/ViewtopControl.qml
|
||||
services/ZoneTransition.qml souveraine/services/ZoneTransition.qml
|
||||
services/Hyprsunset.qml souveraine/services/Hyprsunset.qml
|
||||
modules/souveraine/subconscious/SubconsciousTicker.qml souveraine/modules/souveraine/subconscious/SubconsciousTicker.qml
|
||||
modules/souveraine/subconscious/SubconsciousEventPanel.qml souveraine/modules/souveraine/subconscious/SubconsciousEventPanel.qml
|
||||
|
|
|
|||
|
|
@ -247,6 +247,31 @@ Scope {
|
|||
anchors.fill: parent
|
||||
z: -1
|
||||
|
||||
// The backdrop arrives with the climb rather than at the end of it.
|
||||
//
|
||||
// This was a hard on/off gated on a 140 ms dwell, so an ordinary
|
||||
// swipe shrank the real window over the live app and then the whole
|
||||
// destination — blur, sheet, cards — appeared in one frame. That
|
||||
// discontinuity is the "swipe to this swap is awkward" Casey has
|
||||
// named repeatedly, and it is TASK-60's own acceptance: *"the
|
||||
// scale-on-drag either continues into the view or is gone. Not
|
||||
// both."*
|
||||
//
|
||||
// Same curve as the cards, from the same owner, so at the commit
|
||||
// frame the backdrop is already opaque and the card's picture is
|
||||
// already exactly under the real window — releasing the carry
|
||||
// changes nothing on the glass. A fast flick to Home shows a faint
|
||||
// wash rather than a hard flash, because `presence` recedes past
|
||||
// the multitasking detent instead of latching on.
|
||||
opacity: GlobalStates.missionControlOpen ? 1 : ZoneTransition.presence
|
||||
Behavior on opacity {
|
||||
enabled: GlobalStates.missionControlOpen || ZoneTransition.travel === 0
|
||||
NumberAnimation {
|
||||
duration: 260
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Image {
|
||||
id: homeWall
|
||||
anchors.fill: parent
|
||||
|
|
@ -366,6 +391,28 @@ Scope {
|
|||
// things, and could not show a zone you were not on. A zone is
|
||||
// the destination; its windows live inside its card.
|
||||
ZoneOverview {
|
||||
id: zoneOverview
|
||||
|
||||
// Tell the owner where this surface actually is, rather than
|
||||
// letting it assume the screen. The panel respects exclusive
|
||||
// zones, so the bar's 40 px makes it 1040 tall on a 1080
|
||||
// screen and puts its origin 40 px down the output — and the
|
||||
// compositor's carry targets are in output coordinates.
|
||||
//
|
||||
// The inset is what was lost to reservations, which are
|
||||
// top-anchored for this surface. It should equal the `at.y`
|
||||
// the compositor reports for any tiled window; if a future
|
||||
// surface reserves along the bottom this becomes wrong, and
|
||||
// the cross-check is how it would be caught.
|
||||
function report() {
|
||||
ZoneTransition.measuredAt(0,
|
||||
(panelWindow.screen?.height ?? panelWindow.height) - panelWindow.height,
|
||||
width, height);
|
||||
}
|
||||
onWidthChanged: zoneOverview.report()
|
||||
onHeightChanged: zoneOverview.report()
|
||||
Component.onCompleted: zoneOverview.report()
|
||||
|
||||
// The panel's width, NOT the column's.
|
||||
//
|
||||
// `overviewLoader.parent` is the Column, and a Column is
|
||||
|
|
@ -393,9 +440,17 @@ Scope {
|
|||
// Going to a zone is not "activate a toplevel" — that
|
||||
// was the old model's verb and it could not express
|
||||
// "this place, which happens to hold two windows".
|
||||
ViewtopControl.zone(zone);
|
||||
GlobalStates.overviewOpen = false;
|
||||
GlobalStates.missionControlOpen = false;
|
||||
//
|
||||
// Through the end-target table, which is what makes the
|
||||
// strand repro pass (TASK-60). This used to move the
|
||||
// canvas and clear two flags by hand — three steps, none
|
||||
// of which released a transform, and this tap never
|
||||
// touches the rail where every release-the-pose path had
|
||||
// been built. So btop came back as a small card in the
|
||||
// middle of the screen. A tapped card is now the same
|
||||
// machinery as a released pill: one destination, and
|
||||
// arriving at it is what lets go.
|
||||
ZoneTransition.commit(ZoneTransition.zoneTarget(zone));
|
||||
}
|
||||
onClosed: id => ViewtopControl.close(id)
|
||||
}
|
||||
|
|
@ -524,6 +579,78 @@ Scope {
|
|||
GlobalStates.missionControlOpen = !GlobalStates.missionControlOpen;
|
||||
}
|
||||
|
||||
// The multitasking gesture, without a finger.
|
||||
//
|
||||
// TASK-60's acceptance: *"The gesture is reachable as a verb. An agent
|
||||
// can drive `shift` and pick an end target well enough to demo
|
||||
// multitasking."* Casey, 2026-08-07 — there is a point where she is
|
||||
// asked for a tour of the phone, *"and that does mean even these little
|
||||
// gesture steps will be possible."*
|
||||
//
|
||||
// Three steps rather than one opaque call, because a tour narrates: she
|
||||
// can hold the windows half-carried while she says what multitasking
|
||||
// is, and then land them. `swipe` is the whole motion for when the
|
||||
// narration is not the point.
|
||||
//
|
||||
// No contact is synthesized, so the compositor never sees input at all
|
||||
// and the run cannot raise `observed_confidence` or feed the idle
|
||||
// budget — the machine must not believe a human is present because she
|
||||
// moved a window. Step-up stays human-only by the same absence:
|
||||
// `input.rs:432` routes `Origin::Agent` to `Route::Withheld`, and this
|
||||
// path does not go near it.
|
||||
function carryBegin(): string {
|
||||
return ZoneTransition.begin() ? "carrying"
|
||||
: "nothing on this zone to carry";
|
||||
}
|
||||
|
||||
function carryShift(shift: real): void {
|
||||
ZoneTransition.progress(shift);
|
||||
}
|
||||
|
||||
// `target` is an end target by name: home, overview, last_zone, or a
|
||||
// zone number. Same four the pill commits, same table.
|
||||
function carryCommit(target: string): void {
|
||||
const n = parseInt(target, 10);
|
||||
ZoneTransition.commit(isNaN(n) ? target : ZoneTransition.zoneTarget(n));
|
||||
}
|
||||
|
||||
// Begin, travel, and land — the whole gesture in one call.
|
||||
function swipe(target: string): void {
|
||||
ZoneTransition.begin();
|
||||
tour.target = target;
|
||||
tour.step = 0;
|
||||
tour.restart();
|
||||
}
|
||||
|
||||
function carryState(): string {
|
||||
return JSON.stringify({
|
||||
inFlight: ZoneTransition.inFlight,
|
||||
cardScale: ZoneTransition.cardScale,
|
||||
card: {
|
||||
x: ZoneTransition.cardX,
|
||||
y: ZoneTransition.cardY,
|
||||
w: ZoneTransition.cardWidth,
|
||||
h: ZoneTransition.cardHeight
|
||||
},
|
||||
panel: { w: ZoneTransition.panelWidth, h: ZoneTransition.panelHeight },
|
||||
// What the surface reported, beside what the panel actually is.
|
||||
// The card rect is derived from these, so a card in the wrong
|
||||
// place is diagnosed by reading them rather than by theorising —
|
||||
// the same reason `state` exists at all (TASK-60: `item.w: 0`
|
||||
// beside `column.w: 0` located the zero-width card in one step).
|
||||
surface: {
|
||||
left: ZoneTransition.surfaceLeft,
|
||||
top: ZoneTransition.surfaceTop,
|
||||
w: ZoneTransition.surfaceWidth,
|
||||
h: ZoneTransition.surfaceHeight
|
||||
},
|
||||
panelWindow: { w: panelWindow.width, h: panelWindow.height },
|
||||
loaderItem: overviewLoader.item
|
||||
? { w: overviewLoader.item.width, h: overviewLoader.item.height }
|
||||
: null
|
||||
});
|
||||
}
|
||||
|
||||
function state(): string {
|
||||
return JSON.stringify({
|
||||
overview: GlobalStates.overviewOpen,
|
||||
|
|
@ -549,6 +676,38 @@ Scope {
|
|||
}
|
||||
}
|
||||
|
||||
// The travel half of `overview swipe`. Sixteen steps over ~320 ms is the
|
||||
// settle `ZoneOverview` already animates against, so an agent's swipe and a
|
||||
// thumb's arrive at the same speed — a demo that moved at a different rate
|
||||
// than the real gesture would be showing something the phone does not do.
|
||||
Timer {
|
||||
id: tour
|
||||
property string target: "overview"
|
||||
property int step: 0
|
||||
readonly property int steps: 16
|
||||
interval: 20
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
tour.step += 1;
|
||||
if (tour.step >= tour.steps) {
|
||||
tour.stop();
|
||||
const n = parseInt(tour.target, 10);
|
||||
ZoneTransition.commit(isNaN(n) ? tour.target
|
||||
: ZoneTransition.zoneTarget(n));
|
||||
ZoneTransition.rest();
|
||||
return;
|
||||
}
|
||||
// Drives the clock, exactly as the rail does — so the carry, the
|
||||
// backdrop and the cards all move on the demo for the same reason
|
||||
// they move under a thumb. A tour that ran its own animation would
|
||||
// be showing something the phone does not actually do.
|
||||
//
|
||||
// A detent of `steps` makes one step one unit of travel, so the
|
||||
// demo lands precisely on the multitasking detent at the last step.
|
||||
ZoneTransition.pullTo(tour.step, tour.steps);
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "search"
|
||||
|
||||
|
|
|
|||
|
|
@ -20,11 +20,12 @@
|
|||
// it presented, and the swap detent has to cost a deliberate reach, not a
|
||||
// near miss. Keyboard down, the two swipe modes are the whole of it.
|
||||
//
|
||||
// The upward gesture is progressive, not a single threshold, and the progress
|
||||
// is *published* (`GlobalStates.zonePullProgress`) rather than kept: the real
|
||||
// windows shrink through `pose` as the thumb climbs, and `ZoneOverview` enters
|
||||
// against the same number, so the transition and the destination are one
|
||||
// motion. The handle tracks it too — it grows, brightens and lifts toward
|
||||
// The upward gesture is progressive, not a single threshold, and the travel is
|
||||
// *reported* to `ZoneTransition` rather than interpreted here: the compositor
|
||||
// carries the real windows onto their cards, and the destination fades in over
|
||||
// the same climb, so the transition and the destination are one motion driven
|
||||
// by one clock. This file measures a thumb and derives nothing from it.
|
||||
// The handle tracks it too — it grows, brightens and lifts toward
|
||||
// whichever stage the travel has reached, so the pill reads as "on top" of the
|
||||
// motion rather than a passive strip. Release commits the stage the drag last
|
||||
// held; a flick past the mission line commits multitasking even from a
|
||||
|
|
@ -170,21 +171,21 @@ PanelWindow {
|
|||
property real dragTravel: 0
|
||||
property bool dragging: false
|
||||
|
||||
// The pose is a function of `dragging`, not a thing each exit path
|
||||
// remembers to undo. DUMP-bugs 8: a partial slide left the app scaled small
|
||||
// and only another pull brought it back, and the cause was never found
|
||||
// because every path that was *looked at* did call `endPull` — release,
|
||||
// cancel and the swap all did. Something ended a drag without going through
|
||||
// any of them, and searching for which one is the wrong shape of fix: as
|
||||
// long as restoring is a step, there is a path that skips the step.
|
||||
// One edge, and everything rides on it. A drag ends by setting `dragging`
|
||||
// false and by nothing else. DUMP-bugs 8: a partial slide left the app
|
||||
// scaled small and only another pull brought it back, and the cause was
|
||||
// never found because every path that was *looked at* did call `endPull` —
|
||||
// release, cancel and the swap all did. Something ended a drag without
|
||||
// going through any of them, and searching for which one is the wrong shape
|
||||
// of fix: as long as restoring is a step, there is a path that skips it.
|
||||
//
|
||||
// So there is one edge and everything rides on it. A drag ends by setting
|
||||
// `dragging` false and by nothing else; this restores. `endPull` is
|
||||
// idempotent, so a path that also calls it directly costs a redundant write
|
||||
// and cannot strand. Ordering is preserved by the release handler setting
|
||||
// `dragging` false *last*, after it has committed — clearing the pose first
|
||||
// would pop the app back to full size for a frame before Mission Control
|
||||
// took the screen.
|
||||
// Restoring is no longer a step (TASK-60). The compositor holds the
|
||||
// transform and releases it on `commit`/`cancel`, so what this edge cleans
|
||||
// up is the shell's own chrome plus a carry that no destination claimed —
|
||||
// an abandoned drag, or one the compositor took away mid-flight. The
|
||||
// release handler still sets `dragging` false *last*, after it has
|
||||
// committed, so `ZoneTransition.inFlight` is already clear by then and the
|
||||
// cancel here is a no-op rather than a second decision.
|
||||
onDraggingChanged: if (!rail.dragging) gestureArea.endPull()
|
||||
|
||||
// The thumb has stopped climbing without letting go. iOS/Android both read
|
||||
|
|
@ -201,15 +202,67 @@ PanelWindow {
|
|||
onTriggered: rail.dwelled = true
|
||||
}
|
||||
|
||||
// Only in multitasking range. Climb on toward home and the cards recede, so
|
||||
// the preview never shows a destination the release would not commit.
|
||||
readonly property bool peekWanted: rail.dragging && rail.dwelled && rail.dragStage === 1
|
||||
// The destination is drawn for the whole climb, not only after a dwell.
|
||||
//
|
||||
// Casey has said this six times and it was the acceptance criterion left
|
||||
// unmet: *"the visual style from swiping up into this mode… we lost the
|
||||
// consistency… the swipe to this swap is awkward."* The cause was here. The
|
||||
// surface used to require `dwelled` — 140 ms of holding still — so an
|
||||
// ordinary pull shrank the real window over the live app with no backdrop
|
||||
// and no cards behind it, and then the entire destination appeared at once
|
||||
// on commit. Two visual events for one motion.
|
||||
//
|
||||
// Now it draws from the first climbing pixel and fades in against
|
||||
// `zonePullProgress` — the same number the compositor is carrying the window
|
||||
// on. At the commit frame the backdrop is already opaque and the card's
|
||||
// picture is already exactly under the real window, so releasing the carry
|
||||
// changes nothing on the glass. That is TASK-60's *"no frame where a window
|
||||
// is scaled by one and laid out by the other"*, and its *"the scale-on-drag
|
||||
// either continues into the view or is gone. Not both."*
|
||||
//
|
||||
// The dwell has not been deleted, it has been demoted: it still fires the
|
||||
// haptic that says "release here and you land in multitasking", which is
|
||||
// what it was actually good for. What it must not do is gate whether the
|
||||
// destination exists.
|
||||
//
|
||||
// The old worry — *"a fast flick to Home never flashes the blur on its way
|
||||
// past"* — is answered by the fade being continuous rather than by hiding
|
||||
// the surface. A flick spends a few frames at low progress, so it shows a
|
||||
// faint wash instead of a hard flash; and past the multitasking detent the
|
||||
// presence recedes (see `overviewPresence`), so a home-bound pull never
|
||||
// arrives at a destination the release would not commit.
|
||||
// The rail reports the clock — how far the thumb has climbed — and reads
|
||||
// none of the curves derived from it. `ZoneTransition` turns one travel into
|
||||
// the carry's shift and the destination's presence, because they are two
|
||||
// strategies over one clock and putting them in two files is how this task's
|
||||
// original bug was built (TASK-52: one attention model, one clock, effects
|
||||
// as strategies over it).
|
||||
//
|
||||
// The destination draws once the motion has *ever* paused, or once the climb
|
||||
// is deep enough to be heading there. Straight from quickstep, which decides
|
||||
// the same thing the same way (`AbsSwipeUpHandler`):
|
||||
//
|
||||
// recentsAttachedToAppWindow = mHasMotionEverBeenPaused
|
||||
// || mIsLikelyToStartNewTask;
|
||||
//
|
||||
// "Ever", not "currently" — `dwelled` latches for the rest of the drag, so a
|
||||
// thumb that trembles after pausing does not strobe the blur. This was
|
||||
// briefly removed here on the theory that the dwell gate caused the awkward
|
||||
// hand-off; it did not. The cut was the release committing *at* wherever the
|
||||
// thumb left it, which `ZoneTransition.settleTo` now travels through. A fast
|
||||
// unpaused flick still shows no destination on its way past, which is both
|
||||
// what Android does and what the original comment here wanted.
|
||||
readonly property bool peekWanted: rail.dragging && !GlobalStates.oskOpen
|
||||
&& (rail.dwelled || rail.dragStage >= 1)
|
||||
|
||||
// The haptic the dwell is still for: a tick when the thumb settles in
|
||||
// multitasking range, so the detent can be found without looking.
|
||||
onDwelledChanged: if (rail.dwelled && rail.dragStage === 1) Haptics.tick()
|
||||
|
||||
onPeekWantedChanged: {
|
||||
if (rail.peekWanted) {
|
||||
peekFade.stop()
|
||||
GlobalStates.missionPeek = true
|
||||
Haptics.tick()
|
||||
} else if (GlobalStates.missionPeek) {
|
||||
// `endPull` zeroes the progress the cards are laid out against, and
|
||||
// that settle is animated. Dropping the surface on the release frame
|
||||
|
|
@ -338,31 +391,19 @@ PanelWindow {
|
|||
// A single tap is Home. Delay it by the double-tap window so the
|
||||
// existing fullscreen gesture remains unambiguous; the second tap
|
||||
// cancels this timer before toggling fullscreen.
|
||||
function goHome(): void {
|
||||
GlobalStates.missionControlOpen = false
|
||||
GlobalStates.overviewOpen = false
|
||||
GlobalStates.dockRevealed = false
|
||||
GlobalStates.oskOpen = false
|
||||
// The compositor's own verb, not `hyprctl dispatch` — that binary
|
||||
// is not running under viewtop, so this call went nowhere and Home
|
||||
// silently stopped existing. Square one has to be reachable: it is
|
||||
// the state you get back to when everything else is confusing, and
|
||||
// a phone whose Home does nothing is one wrong gesture from being
|
||||
// stuck. `workspace` is served on the control socket and takes the
|
||||
// zone to go to.
|
||||
// Square one has to be reachable: it is the state you get back to when
|
||||
// everything else is confusing, and a phone whose Home does nothing is
|
||||
// one wrong gesture from being stuck.
|
||||
//
|
||||
// `ViewtopControl.homeZone`, not a config lookup. This read
|
||||
// `Config.options.navigation?.homeZone ?? 1`, and there is no
|
||||
// `navigation` block in `Config.qml` — so it was always 1, while
|
||||
// home is 0 (`workspace::HOME_ZONE`, and the same constant the
|
||||
// dock's pinned rule and the overview's "Home" label both test
|
||||
// against). Home therefore landed on the first *app* zone, and the
|
||||
// dock — whose whole new rule is "pinned on home" — correctly
|
||||
// refused to appear there. It looked like it worked because the
|
||||
// compositor clamps `to` against `count - 1`, so with only home
|
||||
// open, 1 becomes 0; it broke the moment a second zone existed.
|
||||
// Casey's "zone one" is the strip's first zone, which is index 0.
|
||||
ViewtopControl.zone(ViewtopControl.homeZone)
|
||||
// The body moved to `ZoneTransition.arrive("home")` (TASK-60), which is
|
||||
// the one table where an end target becomes an action. It clears the
|
||||
// furniture and calls the compositor's own `workspace` verb — not
|
||||
// `hyprctl dispatch`, which is not running under viewtop, so it went
|
||||
// nowhere and Home silently stopped existing — with `homeZone` rather
|
||||
// than a config lookup that resolved to 1 and put Home on the first
|
||||
// *app* zone.
|
||||
function goHome(): void {
|
||||
ZoneTransition.settleTo("home")
|
||||
}
|
||||
|
||||
// Commit an upward gesture. `stage` is the stage the drag settled on
|
||||
|
|
@ -381,12 +422,14 @@ PanelWindow {
|
|||
// Short *toggles*. Reaching multitasking with a gesture that cannot
|
||||
// also leave it means the way out is a different gesture than the way
|
||||
// in, which is the thing that makes a phone feel stuck.
|
||||
// Every branch here is now a named end target, and each one releases the
|
||||
// carry by *arriving* — which is what makes a stranded window
|
||||
// impossible rather than merely unlikely (TASK-60).
|
||||
function commitUp(stage: int): void {
|
||||
if (stage >= 2) {
|
||||
// Square one. `goHome` clears multitasking, the overview, the
|
||||
// dock and the OSK before moving, so the zone is not arrived at
|
||||
// with the last screen's furniture still up.
|
||||
//
|
||||
// Square one. The arrival clears multitasking, the overview,
|
||||
// the dock and the OSK before moving, so the zone is not
|
||||
// arrived at with the last screen's furniture still up.
|
||||
goHome()
|
||||
shortSwipes = 0
|
||||
return
|
||||
|
|
@ -395,10 +438,20 @@ PanelWindow {
|
|||
// Multitasking only. The dock is not touched here any more: it
|
||||
// belongs to zone one and is shown by being *there*, not by a
|
||||
// gesture that reveals it over whatever else is on screen.
|
||||
GlobalStates.missionControlOpen = !GlobalStates.missionControlOpen
|
||||
if (GlobalStates.missionControlOpen
|
||||
&& !Persistent.states.navigation.missionControlDiscovered)
|
||||
//
|
||||
// Short *toggles*: a way in that cannot also be the way out is
|
||||
// what makes a phone feel stuck. Leaving is `last_zone` — a
|
||||
// destination with a name, so the release runs the same
|
||||
// machinery as every other exit rather than being the one path
|
||||
// that only unsets a flag.
|
||||
if (GlobalStates.missionControlOpen) {
|
||||
ZoneTransition.settleTo("last_zone")
|
||||
GlobalStates.missionControlOpen = false
|
||||
} else {
|
||||
ZoneTransition.settleTo("overview")
|
||||
if (!Persistent.states.navigation.missionControlDiscovered)
|
||||
Persistent.states.navigation.missionControlDiscovered = true
|
||||
}
|
||||
shortSwipes = 0
|
||||
}
|
||||
}
|
||||
|
|
@ -458,9 +511,30 @@ PanelWindow {
|
|||
// which is exactly the wrong read (observed 2026-08-06). The
|
||||
// keyboard's climb touches nothing; only the swap commits.
|
||||
if (!GlobalStates.oskOpen) {
|
||||
const progress = Math.min(1.0, rail.dragTravel / Math.max(1, rail.missionAt))
|
||||
GlobalStates.zonePullProgress = progress
|
||||
ViewtopControl.poseActiveZone(1.0 - 0.4 * progress)
|
||||
// Name the destination once, at the moment this becomes an
|
||||
// upward pull — quickstep's `getSwipeUpDestinationAndLength`
|
||||
// returns the rect and the length together for the same reason.
|
||||
// From here the compositor owns these windows' geometry until
|
||||
// something commits.
|
||||
//
|
||||
// On the first climbing pixels rather than on `onPressed`,
|
||||
// which is where this started. `focus_border` yields while a
|
||||
// window is carried, so beginning on every press flashed the
|
||||
// focus frame off and back on for a tap or a swipe *down* —
|
||||
// gestures that are not this one and must cost nothing. Eight
|
||||
// pixels is well under the first detent at 48, so the carry is
|
||||
// running long before anything could commit and the motion is
|
||||
// still continuous from the start of the climb.
|
||||
//
|
||||
// Not with a keyboard up: there the climb is the swap pull and
|
||||
// touches nothing. An app shrinking under a thumb that was
|
||||
// reaching for a different keyboard is the wrong read, and was
|
||||
// the keyboard-swap complaint as it presented (2026-08-06).
|
||||
if (!ZoneTransition.inFlight && rail.dragTravel >= 8)
|
||||
ZoneTransition.begin()
|
||||
// The clock, and nothing derived from it. `pullTo` turns this
|
||||
// into the carry's shift and the destination's presence.
|
||||
ZoneTransition.pullTo(rail.dragTravel, rail.missionAt)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -468,23 +542,31 @@ PanelWindow {
|
|||
// — because every destination wants the same thing and the difference
|
||||
// was never in the action.
|
||||
//
|
||||
// It used to take a `committed` flag it did not read. Committed means
|
||||
// the multitasking view is taking over and draws the zone the pose was
|
||||
// shrinking, so a pose left on puts two shrunken pictures of one app on
|
||||
// the glass. Abandoned means the windows have to spring back or a
|
||||
// half-swipe leaves a permanently smaller app with nothing on screen to
|
||||
// explain it. Home is the same again: `goHome` moves the strip, and a
|
||||
// window left at 0.6 on the zone behind you is one you find shrunken
|
||||
// next time you swipe back to it. Three reasons, one action — so the
|
||||
// parameter only ever documented a distinction the body did not make.
|
||||
// It used to take a `committed` flag it did not read, and it used to
|
||||
// restore the windows. Neither is true now: the destinations differ,
|
||||
// but the *cleanup* never did, and the windows are the compositor's to
|
||||
// put back.
|
||||
//
|
||||
// What is left is the shell's own chrome, plus `cancel` as the safety
|
||||
// net for a drag that ended without reaching any destination — a
|
||||
// sideways smudge, or the compositor taking the sequence away after a
|
||||
// wake. Every real commit has already cleared `inFlight`, so this is a
|
||||
// no-op on those paths rather than a second writer racing the first.
|
||||
//
|
||||
// Idempotent on purpose: it is the safety net, and a net that cannot be
|
||||
// touched twice is not one.
|
||||
function endPull(): void {
|
||||
dwellTimer.stop()
|
||||
rail.dwelled = false
|
||||
GlobalStates.zonePullProgress = 0
|
||||
ViewtopControl.clearPose()
|
||||
// A settle is a release that is still travelling. Zeroing the clock
|
||||
// or cancelling under it would be the second writer again — the
|
||||
// animation is already carrying the window to a named destination
|
||||
// and will commit when it arrives.
|
||||
if (ZoneTransition.settling)
|
||||
return
|
||||
ZoneTransition.rest()
|
||||
if (ZoneTransition.inFlight)
|
||||
ZoneTransition.cancel()
|
||||
}
|
||||
|
||||
onReleased: mouse => {
|
||||
|
|
@ -542,9 +624,18 @@ PanelWindow {
|
|||
}
|
||||
|
||||
// Short of the first detent the destination is the app you were
|
||||
// already in — a commit like the others, not a pending state.
|
||||
// Restoring rides the `dragging` edge, so it happens whether or not
|
||||
// this handler is the thing that ends the drag.
|
||||
// already in — a commit like the others, not a pending state. And
|
||||
// it *travels* there: an abandoned half-swipe used to snap the
|
||||
// window back from wherever the thumb left it, which is the same cut
|
||||
// the commit paths had and the one felt most often, because a pull
|
||||
// that changes its mind is the commonest gesture of all. `last_zone`
|
||||
// settles to shift 0, so the window walks back down to full size.
|
||||
//
|
||||
// Only when there was something to carry. A tap, a sideways smudge
|
||||
// or a swipe down never began one, and `settleTo` on an idle
|
||||
// transition would animate a clock nothing is reading.
|
||||
if (!GlobalStates.oskOpen && ZoneTransition.inFlight)
|
||||
ZoneTransition.settleTo("last_zone")
|
||||
rail.dragging = false
|
||||
|
||||
// Downward: peel the nearest surface (keyboard, then dock).
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@
|
|||
// group cards and is deliberately written down as approximate rather than
|
||||
// presented as correct.
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs
|
||||
|
|
@ -128,14 +127,12 @@ Item {
|
|||
// reason: *"no frame where a window is scaled by one and laid out by the
|
||||
// other."*
|
||||
readonly property bool open: GlobalStates.overviewOpen || GlobalStates.missionControlOpen
|
||||
property real progress: root.open
|
||||
? 1
|
||||
: Math.min(1, GlobalStates.zonePullProgress)
|
||||
property real progress: root.open ? 1 : ZoneTransition.presence
|
||||
Behavior on progress {
|
||||
// Only the settle is animated. Following the thumb is not an animation
|
||||
// and must not be smoothed, or the cards lag the fingers that are
|
||||
// moving them.
|
||||
enabled: root.open || GlobalStates.zonePullProgress === 0
|
||||
enabled: root.open || ZoneTransition.travel === 0
|
||||
NumberAnimation {
|
||||
duration: 260
|
||||
easing.type: Easing.OutCubic
|
||||
|
|
@ -208,31 +205,47 @@ Item {
|
|||
return Math.max(0, Math.min(1, (root.progress - start) / (1 - start)));
|
||||
}
|
||||
opacity: zoneCard.share
|
||||
// The card is the window's size, at every point in the pull.
|
||||
|
||||
// No `scale` here, and that absence is the point of TASK-60.
|
||||
//
|
||||
// This read `0.6 + 0.4 * share`, which grows as the window shrinks:
|
||||
// `pose` runs 1.0 -> 0.6 against the same progress, so at the handoff
|
||||
// the window was at 0.6 of the glass and the card arrived near
|
||||
// full-bleed. Two sizes for one object, which is the pop.
|
||||
// This used to read `1.0 - 0.4 * root.progress`, tuned to match the
|
||||
// curve the rail was running through `pose` on the real windows.
|
||||
// Two writers over one geometry: the numbers were made to agree by
|
||||
// hand, and at the handoff they did not — the window sat at 0.6 of
|
||||
// the glass while the card arrived near full bleed. A transform one
|
||||
// surface applies and another must remember to undo always has a
|
||||
// path that forgets, and tapping a card was that path.
|
||||
//
|
||||
// Same curve as the rail's pose (`1.0 - 0.4 * progress`), so the
|
||||
// card and the real window are the same rectangle throughout and the
|
||||
// swap is invisible. It rests at 0.6 because that is where the pose
|
||||
// ends — nothing settles to a different size afterwards.
|
||||
scale: 1.0 - 0.4 * root.progress
|
||||
// The compositor now carries the real window onto `frame`'s rect
|
||||
// (`ZoneTransition`). A card that also scaled would be the second
|
||||
// writer all over again, so the card is chrome — the frame, the
|
||||
// label, the close control, the neighbours either side — and never
|
||||
// a competing transform.
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
// Room for the zone label; the sides and top are not inset.
|
||||
anchors.bottomMargin: 34
|
||||
// The scale above is the inset now.
|
||||
id: frame
|
||||
// The zone, drawn small — the same rect the compositor is
|
||||
// carrying this zone's windows onto, from the same arithmetic,
|
||||
// because it *is* the same arithmetic. `ZoneTransition` owns it;
|
||||
// nothing here recomputes a card size.
|
||||
//
|
||||
// 26/12 of hard margin on top of a 0.6 scale insets the card
|
||||
// twice, and the second one is what stopped it lining up with
|
||||
// the window — it also squashed the aspect, since the bottom
|
||||
// label margin has no counterpart at the top. A card at 0.6 of
|
||||
// the glass already leaves both neighbours showing, which is the
|
||||
// only thing the inset was ever for.
|
||||
// Keeping the zone's aspect is not a taste call: `carried` takes
|
||||
// its scale from the width alone and applies it uniformly, so a
|
||||
// card of any other shape would letterbox the live window inside
|
||||
// the card it is supposed to be.
|
||||
//
|
||||
// Centred in the delegate, which leaves both neighbours showing
|
||||
// through — full bleed reads as "you are looking at that app"
|
||||
// rather than "here are the places you can go".
|
||||
// The list is inset by `listMargin` inside this surface, so the
|
||||
// delegate's origin is that much in from the panel's. Both
|
||||
// offsets come off the shared rect rather than being re-derived,
|
||||
// so the card and the carry stay the same rectangle even if the
|
||||
// frame constants move.
|
||||
x: ZoneTransition.cardX - ZoneTransition.listMargin
|
||||
y: ZoneTransition.cardY - ZoneTransition.listMargin
|
||||
width: ZoneTransition.cardWidth
|
||||
height: ZoneTransition.cardHeight
|
||||
radius: 18
|
||||
color: Appearance.colors.colLayer1
|
||||
// The zone you are on is named by its frame rather than by
|
||||
|
|
@ -244,48 +257,55 @@ Item {
|
|||
: Appearance.colors.colLayer1Active
|
||||
clip: true
|
||||
|
||||
// The windows of this zone, laid out the way the compositor
|
||||
// tiles them: one fills, two split top and bottom. Not a
|
||||
// guess — `placement.rs` splits on the short axis for a phone,
|
||||
// and a preview that disagreed with the real layout would make
|
||||
// the overview a picture of a desktop that does not exist.
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 8
|
||||
visible: zoneCard.modelData.windows.length > 0
|
||||
|
||||
// The windows of this zone, at the compositor's own tiling
|
||||
// scaled by the card's own factor — not a layout that guesses
|
||||
// at it.
|
||||
//
|
||||
// This was a `ColumnLayout` with an 8 px margin, which was a
|
||||
// second opinion about where a window sits inside its zone: one
|
||||
// fills, two split, and a margin nudges both off. Now each pane
|
||||
// is the window's reported rect through `cardScale`, so the card
|
||||
// is a photograph of the zone rather than a reconstruction of
|
||||
// it — and the live window the compositor carries onto this same
|
||||
// rect lands exactly on its own picture.
|
||||
//
|
||||
// `at` is zone-local (measured 2026-08-07: two windows on
|
||||
// different zones both report `x: 0` with the second zone in
|
||||
// front), so this one formula is right for every card, not just
|
||||
// the one in front.
|
||||
Repeater {
|
||||
model: zoneCard.modelData.windows
|
||||
|
||||
delegate: Item {
|
||||
id: pane
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
x: (pane.modelData.at?.x ?? 0) * ZoneTransition.cardScale
|
||||
y: (pane.modelData.at?.y ?? 0) * ZoneTransition.cardScale
|
||||
width: (pane.modelData.size?.width ?? 0) * ZoneTransition.cardScale
|
||||
height: (pane.modelData.size?.height ?? 0) * ZoneTransition.cardScale
|
||||
|
||||
readonly property var toplevel: root.toplevelFor(pane.modelData)
|
||||
// A window that has left its zone's confinement.
|
||||
// Said on the card because a float is precisely the
|
||||
// window you can no longer find by remembering
|
||||
// which zone you put it on.
|
||||
// A window that has left its zone's confinement. Said
|
||||
// on the card because a float is precisely the window
|
||||
// you can no longer find by remembering which zone you
|
||||
// put it on.
|
||||
readonly property bool floating: pane.modelData.floating === true
|
||||
|
||||
ScreencopyView {
|
||||
id: shot
|
||||
anchors.fill: parent
|
||||
// Never captures behind a closed overview, and
|
||||
// only *keeps* capturing on the card being
|
||||
// looked at. The others hold the frame they
|
||||
// arrived with, which is what a card off to the
|
||||
// side is worth.
|
||||
// Never captures behind a closed overview, and only
|
||||
// *keeps* capturing on the card being looked at. The
|
||||
// others hold the frame they arrived with, which is
|
||||
// what a card off to the side is worth.
|
||||
captureSource: root.progress > 0 ? pane.toplevel : null
|
||||
live: root.progress > 0 && zoneCard.resting
|
||||
visible: pane.toplevel !== null
|
||||
}
|
||||
|
||||
// The join failed. Said out loud, because a blank
|
||||
// card and a broken overview must not look alike.
|
||||
// The join failed. Said out loud, because a blank card
|
||||
// and a broken overview must not look alike.
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
visible: pane.toplevel === null
|
||||
|
|
@ -318,7 +338,6 @@ Item {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Home, and any zone that is empty. Home is empty on purpose —
|
||||
// it is the widget space — so this is a destination, not a
|
||||
|
|
@ -341,8 +360,11 @@ Item {
|
|||
}
|
||||
}
|
||||
|
||||
// Under the card, not under the delegate: the frame is a fixed rect
|
||||
// now rather than the delegate less a bottom margin, so the label
|
||||
// follows the thing it names.
|
||||
StyledText {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.top: frame.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 6
|
||||
|
|
|
|||
|
|
@ -342,52 +342,39 @@ Singleton {
|
|||
root.scene("drop", {});
|
||||
}
|
||||
|
||||
// Scale every window on the zone in front, live, as a drag progresses.
|
||||
// The overview carry: the compositor takes the real windows and puts them
|
||||
// on their cards.
|
||||
//
|
||||
// This is the iOS/Android "the app shrinks into a card while your thumb
|
||||
// climbs" feel, and it is the first thing in the tree to actually drive
|
||||
// `pose`. The verb and its inverse input-mapping have been implemented and
|
||||
// tested in the compositor for weeks with nothing calling them, which is
|
||||
// the whole reason the behaviour has never been seen on the device.
|
||||
// These replace `poseActiveZone` / `clearPose` / `_posed`, which were the
|
||||
// shell's half of TASK-60's two-writer bug — the rail scaled the windows
|
||||
// here while `ZoneOverview` scaled its cards independently, and a transform
|
||||
// the shell applied is a transform some path out of the gesture has to
|
||||
// remember to undo. There was always a path that forgot, and tapping a card
|
||||
// was it.
|
||||
//
|
||||
// Throttled on the value, not on a timer: a drag emits a motion event per
|
||||
// frame and each one would otherwise be a socket round-trip. Anything
|
||||
// smaller than a percent of scale is invisible and not worth a write.
|
||||
property real _lastPose: 1.0
|
||||
|
||||
// Every id this has actually posed, so the restore is not at the mercy of
|
||||
// what the 2 s poll happens to be reporting when the drag ends.
|
||||
property var _posed: ({})
|
||||
|
||||
function poseActiveZone(scale) {
|
||||
if (Math.abs(scale - root._lastPose) < 0.01)
|
||||
return;
|
||||
root._lastPose = scale;
|
||||
for (const w of root.windows) {
|
||||
if (w.workspace === root.activeZone) {
|
||||
root.pose(w.id, scale, 0.0, 0.5, 0.5);
|
||||
root._posed[w.id] = true;
|
||||
}
|
||||
}
|
||||
// What makes this different is not that it is tidier: the transform is
|
||||
// released *by the compositor*, on `commit` or `cancel`, and there is no
|
||||
// third way for a carry to end. A window cannot outlive the gesture that
|
||||
// carried it.
|
||||
//
|
||||
// `ZoneTransition` is the one caller. It owns the rects, the in-flight
|
||||
// flag, and the end-target table; this is only the door.
|
||||
function overviewBegin(targets) {
|
||||
root._send({ op: "scene", intent: "overview_begin", to: targets });
|
||||
}
|
||||
|
||||
// Put them back. Called on every path out of a drag — commit, abandon and
|
||||
// cancel — because a window left posed by a gesture that ended is a window
|
||||
// the user cannot restore without knowing a verb exists.
|
||||
// Restore what was posed, plus whatever is currently known. Going home from
|
||||
// a pull left the app shrunk until the pill was dragged back down: the zone
|
||||
// change moves the strip, and a window the poll no longer names was never
|
||||
// sent its 1.0 — a window nobody can restore without knowing a verb exists.
|
||||
function clearPose() {
|
||||
root._lastPose = 1.0;
|
||||
const ids = new Set();
|
||||
for (const id of Object.keys(root._posed))
|
||||
ids.add(Number(id));
|
||||
for (const w of root.windows)
|
||||
ids.add(w.id);
|
||||
for (const id of ids)
|
||||
root.pose(id, 1.0, 0.0, 0.5, 0.5);
|
||||
root._posed = ({});
|
||||
function overviewProgress(shift) {
|
||||
root._send({ op: "scene", intent: "overview_progress", progress: shift });
|
||||
}
|
||||
|
||||
// `target` is the wire's `EndTarget`: "home", "overview", "last_zone", or
|
||||
// {zone: {zone: N}}.
|
||||
function overviewCommit(target) {
|
||||
root._send({ op: "scene", intent: "overview_commit", target: target });
|
||||
}
|
||||
|
||||
function overviewCancel() {
|
||||
root._send({ op: "scene", intent: "overview_cancel" });
|
||||
}
|
||||
|
||||
function raise(id) {
|
||||
|
|
|
|||
427
surfaces/quickshell/services/ZoneTransition.qml
Normal file
427
surfaces/quickshell/services/ZoneTransition.qml
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
// 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 } };
|
||||
}
|
||||
}
|
||||
|
|
@ -69,3 +69,4 @@ singleton WallpaperDownload 1.0 WallpaperDownload.qml
|
|||
singleton Wallpapers 1.0 Wallpapers.qml
|
||||
singleton Weather 1.0 Weather.qml
|
||||
singleton Ydotool 1.0 Ydotool.qml
|
||||
singleton ZoneTransition 1.0 ZoneTransition.qml
|
||||
|
|
|
|||
Loading…
Reference in a new issue