Watch
1
0
Fork
You've already forked souveraine
0

rail: the pose rides the drag, not each exit path

A partial slide left the app scaled small until another pull brought it
back (DUMP-bugs 8). Every path anyone checked did call endPull, so the
hunt for the one that didn't was the wrong shape: while restoring is a
step, some path skips it. It is now a function of `dragging`.

Release gates on speed and length as one projected number instead of a
distance test with a flick promotion bolted on. That promotion had never
fired anyway — it read rail.tapSlop, which is undefined, so the test was
`travel >= undefined`. Every commit on this rail has been distance-only.
This commit is contained in:
Fimeg 2026-08-07 08:41:54 -04:00
commit 57208c886a

View file

@ -158,12 +158,74 @@ PanelWindow {
// reach rather than a near-miss of Mission Control.
readonly property int swapAt: 300
// Speed and length become one number: how far the thumb would have carried
// had it kept going. 130 ms is where the old rule sat "1.1 px/ms promotes
// one stage" over a 146 px stage gap so the feel is the same minus the
// cliff between promoted and not.
readonly property int projectMs: 130
// Live upward travel of the in-flight drag (0 while idle, grows as the
// finger climbs). Drives the handle's appearance so the pill tracks the
// motion. Only ever set by the MouseArea below.
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.
//
// 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.
onDraggingChanged: if (!rail.dragging) gestureArea.endPull()
// The thumb has stopped climbing without letting go. iOS/Android both read
// that as "show me where I'd land" and slide the switcher in before the
// release; distance still decides what commits, this only draws it.
//
// 140 ms: past a 60 Hz stutter, under the 260 ms settle, so the preview
// leads the commit instead of racing it. Latching once dwelled it stays
// for the rest of the drag, or a thumb that trembles would strobe the blur.
property bool dwelled: false
Timer {
id: dwellTimer
interval: 140
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
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
// would pop them off mid-animation, so it outlives the gesture just
// long enough to shrink away. On a commit `missionControlOpen` has
// already taken over and this expires unnoticed.
peekFade.restart()
}
}
Timer {
id: peekFade
interval: 300
onTriggered: GlobalStates.missionPeek = false
}
// Stage the current travel has reached: 0 none, 1 reveal, 2 mission.
// With a keyboard up the navigation stages do not exist the climb means
// only the swap, and an app mid-sentence has no business shrinking under
@ -256,9 +318,6 @@ PanelWindow {
// reaching Mission Control feeds the discovery nudge.
property int shortSwipes: 0
readonly property int tapSlop: 24
// Fast throws commit the stage above their travel: a short-but-quick
// flick past this speed (px/ms) escalates one stage.
readonly property real flickSpeed: 1.1
// Double-tap used to toggle fullscreen through `hyprctl dispatch`, and
// has therefore done nothing since the viewtop move START-HERE §3's
@ -349,6 +408,8 @@ PanelWindow {
startAt = Date.now()
rail.dragging = true
rail.dragTravel = 0
rail.dwelled = false
dwellTimer.stop()
}
@ -359,6 +420,11 @@ PanelWindow {
// the handle doesn't chase a dismiss gesture.
rail.dragTravel = Math.max(0, startY - mouse.y)
// Restart while still moving: the timer only reaches its interval
// once the thumb holds still.
if (!rail.dwelled)
dwellTimer.restart()
// The app shrinks under the thumb, and it is the *real* app.
// Casey, 2026-08-05: *"swipe up just a bit makes the app sorta
// scale and you fall into a multi tasking area"*, and on the
@ -398,31 +464,37 @@ PanelWindow {
}
}
// End an upward pull, one way or the other.
// End an upward pull. Called from one place `rail.onDraggingChanged`
// because every destination wants the same thing and the difference
// was never in the action.
//
// `committed` means the multitasking view is taking over: it draws the
// zone the pose was shrinking, so leaving the pose on would put two
// shrunken pictures of the same app on the glass the card and the
// window behind it. Not committed means the gesture was abandoned and
// the windows have to spring back, or a half-swipe leaves a permanently
// smaller app with nothing on screen to explain it.
// 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.
//
// Either way the pose is cleared, and the *reason* differs rather than
// the action. Home takes the same path: `goHome` moves the strip, and a
// window left at 0.6 on the zone behind you is one you would find
// shrunken next time you swiped back to it.
function endPull(committed: bool): void {
// 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()
}
onReleased: mouse => {
const wasDragging = rail.dragging
rail.dragging = false
const travel = Math.max(0, startY - mouse.y)
const deltaY = mouse.y - startY
const elapsed = Math.max(1, Date.now() - startAt)
const speed = travel / elapsed // px/ms, upward only
// The handle lights by raw travel where the thumb is and the
// commit reads the projection where it was going.
const projected = travel + speed * rail.projectMs
rail.dragTravel = 0
// TASK-38: past the swap detent with a keyboard up, this gesture IS
@ -443,7 +515,7 @@ PanelWindow {
// the new exclusive zone lands, so the height nobody measures
// is the height that cannot be wrong.
Gestures.deliver("osk-swap", Gestures.oskSwapAction)
endPull(false)
rail.dragging = false
return
}
@ -457,23 +529,23 @@ PanelWindow {
// flipped the app into multitasking was the keyboard-swap complaint
// as it actually presented (observed 2026-08-06) the swap path
// above is the *only* thing a keyboard-up climb can commit.
if (!GlobalStates.oskOpen
&& (travel >= rail.revealAt || (speed >= flickSpeed && travel >= rail.tapSlop))) {
let stage = travel >= rail.missionAt ? 2 : travel >= rail.revealAt ? 1 : 0
if (speed >= flickSpeed && stage < 2)
stage += 1
commitUp(stage)
endPull(true)
// `travel` floors it: projection alone would turn a 10 px smudge
// flicked in 5 ms into a 270 px throw, i.e. Home. Bare `tapSlop`
// it is the MouseArea's. The old flick rule read `rail.tapSlop`,
// which is undefined, so `travel >= undefined` was always false and
// that whole promotion path had never once fired.
if (!GlobalStates.oskOpen && travel >= tapSlop
&& projected >= rail.revealAt) {
commitUp(projected >= rail.missionAt ? 2 : 1)
rail.dragging = false
return
}
// An abandoned climb: the app was shrinking under the thumb and
// has to spring back. Only ever after a drag *this rail started*
// geometry is the agent's outright (doctrine §13), and a rail that
// reset poses as a general safety net would be the shell overruling
// what she had composed because a finger moved.
if (wasDragging)
endPull(false)
// 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.
rail.dragging = false
// Downward: peel the nearest surface (keyboard, then dock).
if (deltaY >= rail.revealAt) {
@ -519,14 +591,11 @@ PanelWindow {
}
}
// The compositor taking the sequence away mid-drag the FTS controller
// does this after a wake. Same edge, same restore.
onCanceled: {
rail.dragging = false
rail.dragTravel = 0
// A cancel is the compositor taking the sequence away mid-drag
// the FTS controller does exactly this after a wake. The windows
// are really scaled at this point, so something has to bring them
// home; nothing else will.
endPull(false)
rail.dragging = false
}
}
}