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
|
|
@ -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.
|
||||
// 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.
|
||||
//
|
||||
// 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 {
|
||||
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.
|
||||
//
|
||||
// `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)
|
||||
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)
|
||||
Persistent.states.navigation.missionControlDiscovered = true
|
||||
//
|
||||
// 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,77 +257,83 @@ 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
|
||||
|
||||
Repeater {
|
||||
model: zoneCard.modelData.windows
|
||||
delegate: Item {
|
||||
id: pane
|
||||
required property var modelData
|
||||
|
||||
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.
|
||||
readonly property bool floating: pane.modelData.floating === true
|
||||
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.
|
||||
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.
|
||||
captureSource: root.progress > 0 ? pane.toplevel : null
|
||||
live: root.progress > 0 && zoneCard.resting
|
||||
visible: pane.toplevel !== null
|
||||
}
|
||||
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.
|
||||
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.
|
||||
StyledText {
|
||||
// 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
|
||||
text: pane.modelData.app_id || qsTr("window")
|
||||
opacity: 0.6
|
||||
}
|
||||
|
||||
StyledText {
|
||||
anchors.left: parent.left
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: 8
|
||||
visible: pane.floating
|
||||
text: qsTr("(float)")
|
||||
opacity: 0.75
|
||||
}
|
||||
|
||||
RippleButton {
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 8
|
||||
implicitWidth: 34
|
||||
implicitHeight: 34
|
||||
buttonRadius: 17
|
||||
onClicked: root.closed(pane.modelData.id)
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
visible: pane.toplevel === null
|
||||
text: pane.modelData.app_id || qsTr("window")
|
||||
opacity: 0.6
|
||||
}
|
||||
|
||||
StyledText {
|
||||
anchors.left: parent.left
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: 8
|
||||
visible: pane.floating
|
||||
text: qsTr("(float)")
|
||||
opacity: 0.75
|
||||
}
|
||||
|
||||
RippleButton {
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 8
|
||||
implicitWidth: 34
|
||||
implicitHeight: 34
|
||||
buttonRadius: 17
|
||||
onClicked: root.closed(pane.modelData.id)
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
text: "close"
|
||||
iconSize: 18
|
||||
}
|
||||
text: "close"
|
||||
iconSize: 18
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue