zone: take the detent from the card, not from 18%
The card's bottom sits at 805 of 1080 logical, so the travel is 275px and the detent fired at 194 — every release committed with the window short of its card. Latched at the press. Velocity is the last 100ms rather than the whole-drag mean, and the settle is a critically damped spring on the frame clock, seeded with it.
This commit is contained in:
parent
2b4c827b66
commit
46d2601908
2 changed files with 332 additions and 99 deletions
|
|
@ -98,6 +98,9 @@ Singleton {
|
|||
property real surfaceHeight: root.panelHeight
|
||||
property real surfaceTop: 0
|
||||
property real surfaceLeft: 0
|
||||
// Whether any of the four above came from the surface rather than from the
|
||||
// defaults. `dragLength` is the one consumer that must know the difference.
|
||||
property bool surfaceReported: false
|
||||
|
||||
// Report the overview surface's real geometry. Called by the surface; the
|
||||
// only writer of these four.
|
||||
|
|
@ -106,6 +109,7 @@ Singleton {
|
|||
root.surfaceTop = top;
|
||||
root.surfaceWidth = Math.max(1, width);
|
||||
root.surfaceHeight = Math.max(1, height);
|
||||
root.surfaceReported = true;
|
||||
}
|
||||
|
||||
// `ZoneOverview`'s own frame, restated here because the destination and the
|
||||
|
|
@ -117,31 +121,115 @@ Singleton {
|
|||
// derived from a share, for the reason above.
|
||||
readonly property real listMargin: 12
|
||||
readonly property real labelHeight: 34
|
||||
// The band under the strip: verbs that act on the card in front, and the
|
||||
// line she speaks on. Reserved here rather than overlaid, so the cards
|
||||
// shrink to make room and `dragLength` re-derives itself — the tray moves
|
||||
// the destination, and the gesture has to know that.
|
||||
readonly property real trayHeight: 132
|
||||
|
||||
// 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
|
||||
- 2 * root.listMargin - root.labelHeight - root.trayHeight
|
||||
|
||||
// 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)))
|
||||
// --- What a card is a picture *of* ---------------------------------------
|
||||
//
|
||||
// The zone's usable area, not the whole output. A card drawn as the full
|
||||
// panel carries the bar's reserved strip inside it as dead space, and a
|
||||
// tiled window — whose `at.y` starts below that reservation — therefore
|
||||
// sits with a band of empty card above it. Casey, 2026-08-16: *"each window
|
||||
// currently has a gap on the top for where the top bar section would go."*
|
||||
// That gap is `surfaceTop * cardScale` and it was drawn faithfully; the
|
||||
// card was just a picture of the wrong rectangle.
|
||||
//
|
||||
// `surfaceTop` is what this surface lost to exclusive zones, and the header
|
||||
// above already states the invariant that makes it the right number: it
|
||||
// equals the `at.y` the compositor reports for any tiled window. Assumes
|
||||
// reservations stay top-anchored, which is true today (the bar reserves,
|
||||
// the pill reserves nothing, the dock is on Overlay). A bottom reservation
|
||||
// would need its own term, and the cross-check that would catch it is the
|
||||
// same one — a card whose windows no longer reach its bottom edge.
|
||||
readonly property real contentTop: root.surfaceTop
|
||||
readonly property real contentWidth: root.panelWidth
|
||||
readonly property real contentHeight: Math.max(1, root.panelHeight - root.contentTop)
|
||||
|
||||
// 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
|
||||
// How much of the space a card is allowed to fill.
|
||||
//
|
||||
// Fit-to-box made a card that nearly touched its neighbours, so the strip
|
||||
// read as one thing at a time with slivers either side. Casey, 2026-08-16:
|
||||
// *"scaled a little bit more / at least tighter together… we should be able
|
||||
// to smoothly scroll between them, floating cards and such."* Under one is
|
||||
// what makes them cards floating in a strip rather than a stack of screens.
|
||||
readonly property real cardFill: 0.84
|
||||
// Between neighbours. The ListView's own `spacing` is 0 so this is the one
|
||||
// number that says how far apart cards sit.
|
||||
readonly property real cardGutter: 16
|
||||
|
||||
// One factor. `min` so the card keeps the *content's* aspect — 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, root.cardFill * Math.min(
|
||||
root._availW / Math.max(1, root.contentWidth),
|
||||
root._availH / Math.max(1, root.contentHeight)))
|
||||
|
||||
readonly property real cardWidth: root.contentWidth * root.cardScale
|
||||
readonly property real cardHeight: root.contentHeight * root.cardScale
|
||||
|
||||
// --- Where the resting card sits -----------------------------------------
|
||||
//
|
||||
// The delegate is the card plus its gutter, and the `ListView` holds the
|
||||
// current one centred (`StrictlyEnforceRange`, highlight range below), so a
|
||||
// neighbour shows on each side and the strip scrolls between them. It used
|
||||
// to be a full-width delegate pinned at x = 0 with the card centred inside
|
||||
// it, which is why the cards sat a screen apart.
|
||||
//
|
||||
// All four numbers live here and nowhere else. `ZoneOverview` lays the card
|
||||
// out at `cardInset*` and the carry names `cardX`/`cardY`; they are the same
|
||||
// rectangle in two coordinate spaces, not two rectangles kept in agreement.
|
||||
readonly property real delegateWidth: root.cardWidth + root.cardGutter
|
||||
readonly property real highlightBegin: Math.max(0,
|
||||
(root._availW - root.delegateWidth) / 2)
|
||||
|
||||
// Delegate-local: what `ZoneOverview` positions the frame at.
|
||||
readonly property real cardInsetX: (root.delegateWidth - root.cardWidth) / 2
|
||||
readonly property real cardInsetY: (root._availH - root.cardHeight) / 2
|
||||
|
||||
// Surface-local: what the carry's destination is measured in.
|
||||
readonly property real cardX: root._availX + root.highlightBegin + root.cardInsetX
|
||||
readonly property real cardY: root._availY + root.cardInsetY
|
||||
|
||||
// How far the thumb has to climb for the window to *become* its card.
|
||||
//
|
||||
// The destination and the distance to it are one question, and quickstep
|
||||
// answers them in one call — `getSwipeUpDestinationAndLength(dp, ctx,
|
||||
// TEMP_RECT, …)` returns the task rect and the drag length together, and
|
||||
// `LauncherActivityInterface` computes that length as `dp.heightPx -
|
||||
// outRect.bottom`: how far the window's bottom edge travels.
|
||||
//
|
||||
// The rail asked `screen.height * 0.18` instead — TASK-60 named it *"a
|
||||
// number with no relationship to where the card actually is"* and left it.
|
||||
// Measured on blueline 2026-08-16: the card's bottom sits at 805 logical px
|
||||
// of 1080, so the true travel is 275 px and the old detent fired at 194.
|
||||
// The gesture committed with the window 40% short of its card and the
|
||||
// hand-off covered the rest in one frame — Casey's *"the swipe to this swap
|
||||
// is awkward"*, reported six times and tuned at from every direction except
|
||||
// this one.
|
||||
//
|
||||
// Until the surface has reported, this is the old 18% — not a floor, and
|
||||
// not a guess dressed up as arithmetic. `ZoneOverview` lives inside a gated
|
||||
// Loader, so nothing has measured anything until the overview has been
|
||||
// realised once, and computing the card against the *defaults* yields a
|
||||
// card the size of the panel and a drag length near zero. Behaving exactly
|
||||
// as yesterday until the real number exists is the honest fallback; the
|
||||
// rail latches whichever it got at the press, so no gesture ever changes
|
||||
// detent halfway through.
|
||||
readonly property real dragLength: root.surfaceReported
|
||||
? Math.max(120, root.panelHeight
|
||||
- (root.surfaceTop + root.cardY + root.cardHeight))
|
||||
: root.panelHeight * 0.18
|
||||
|
||||
// Where one window goes: its own place in the zone, drawn small.
|
||||
//
|
||||
|
|
@ -150,12 +238,17 @@ Singleton {
|
|||
// 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.
|
||||
// `contentTop` comes off the window's own `at.y` before scaling, because the
|
||||
// card is a picture of the usable zone and not of the whole output — the
|
||||
// same subtraction `ZoneOverview` makes when it lays the pane out. Miss it
|
||||
// in one place and the carried window lands a bar's height off its picture.
|
||||
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
|
||||
y: root.surfaceTop + root.cardY
|
||||
+ (w.at.y - root.contentTop) * root.cardScale
|
||||
},
|
||||
size: {
|
||||
width: w.size.width * root.cardScale,
|
||||
|
|
@ -194,9 +287,34 @@ Singleton {
|
|||
// 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 one: what the compositor carries the window on.
|
||||
//
|
||||
// Past the detent this used to be a hard `Math.min(1, …)` — the thumb kept
|
||||
// travelling toward home and the window stopped dead. Android never goes
|
||||
// dead there, it goes *heavy*: `AnimatorControllerWithResistance` is
|
||||
// literally two playback controllers, one running 0→1 and a second that
|
||||
// *"seamlessly continues that animation but starts applying resistance"*,
|
||||
// with `DECELERATE` on scale (`RECENTS_SCALE_RESIST_INTERPOLATOR`) and
|
||||
// `FROM_APP(0.75f, 0.5f, 1f, false)` bounding how far it can go.
|
||||
//
|
||||
// Same shape. Past 1 the carry keeps extrapolating beyond the card rect —
|
||||
// the window shrinks further as the pull heads for home — but on a curve
|
||||
// that gives back less and less, so the last of the travel is felt as
|
||||
// weight rather than as a wall.
|
||||
//
|
||||
// A compositor older than the overshoot clamps this to 1 and the gesture is
|
||||
// exactly what it is today, which is what makes it safe to ship ahead of
|
||||
// the package (TASK-28).
|
||||
readonly property real maxOvershoot: 1.22
|
||||
readonly property real shift: root.clock <= 1
|
||||
? Math.max(0, root.clock)
|
||||
: 1 + (root.maxOvershoot - 1) * root._decelerate(Math.min(1, root.clock - 1))
|
||||
|
||||
// Android's DECELERATE, which is `1 - (1-t)²`.
|
||||
function _decelerate(t) {
|
||||
const inv = 1 - t;
|
||||
return 1 - inv * inv;
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -252,61 +370,109 @@ Singleton {
|
|||
// 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))
|
||||
// What was still missing after the travel landed: a duration and an
|
||||
// easing curve cannot know how fast the thumb was going when it let go, so
|
||||
// a flick and a drift settled identically. Android's home path is
|
||||
// `RectFSpringAnim` with `DefaultSpringConfig` (`SwipeUpAnimationLogic:364`)
|
||||
// — a spring takes the release velocity as an *initial condition*, which is
|
||||
// the whole difference between a window that travels and one that was
|
||||
// thrown.
|
||||
//
|
||||
// So: a critically damped spring, integrated per frame, seeded with the
|
||||
// velocity the rail measures over the last 100 ms of the gesture.
|
||||
//
|
||||
// Per *frame*. `FrameAnimation` ticks on the render clock, so the carry
|
||||
// advances once per painted frame instead of once per whatever the socket
|
||||
// managed — which is what the old `NumberAnimation` on `travel` amounted
|
||||
// to, since every step of it went out through `progress()`.
|
||||
//
|
||||
// Critically damped, never underdamped: a navigation surface that rings
|
||||
// reads as a toy. It still overshoots once on a hard throw, and that part
|
||||
// is the point.
|
||||
|
||||
// ω in 1/ms. Critical damping settles in about 6.6/ω, so 0.026 is ~250 ms.
|
||||
readonly property real settleOmega: 0.026
|
||||
// Nothing may outlive this. The commit is what releases the carry, so a
|
||||
// settle that never converged has to arrive anyway or the window strands —
|
||||
// which is the one failure TASK-60 exists to make impossible.
|
||||
readonly property int maxSettleMs: 600
|
||||
// px/ms. A thrown release helps; a wild one does not get to launch the
|
||||
// window off the glass.
|
||||
readonly property real maxSeedVelocity: 8
|
||||
|
||||
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;
|
||||
FrameAnimation {
|
||||
id: settleTick
|
||||
running: false
|
||||
property real velocity: 0
|
||||
property real to: 0
|
||||
property real elapsed: 0
|
||||
|
||||
onTriggered: {
|
||||
// Clamped: a dropped frame must not integrate a large step and
|
||||
// fling the window across the panel.
|
||||
const dt = Math.min(48, settleTick.frameTime * 1000);
|
||||
settleTick.elapsed += dt;
|
||||
|
||||
const w = root.settleOmega;
|
||||
const offset = root.travel - settleTick.to;
|
||||
settleTick.velocity += (-2 * w * settleTick.velocity - w * w * offset) * dt;
|
||||
root.travel = Math.max(0, root.travel + settleTick.velocity * dt);
|
||||
|
||||
if ((Math.abs(root.travel - settleTick.to) < 0.5
|
||||
&& Math.abs(settleTick.velocity) < 0.02)
|
||||
|| settleTick.elapsed >= root.maxSettleMs) {
|
||||
settleTick.running = false;
|
||||
root.travel = settleTick.to;
|
||||
root._finishSettle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _finishSettle() {
|
||||
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) {
|
||||
// `velocity` is px/ms along the rail, upward positive, as the rail measured
|
||||
// it over the tail of the gesture. Omitted means a release with no throw in
|
||||
// it, which is a legitimate way to let go.
|
||||
function settleTo(target, velocity) {
|
||||
// Where on the *clock* each destination lives — not on `shift`, which
|
||||
// saturates at 1 and cannot express home at all.
|
||||
// saturates at the card and cannot express home at all.
|
||||
//
|
||||
// `presence` two properties up is written to rise to the detent and then
|
||||
// fall away "over the same distance", which puts home at clock 2. This
|
||||
// settled it at 1 for both home and overview, so a long swipe ran the
|
||||
// short swipe's motion, held the multitasking view fully present, and
|
||||
// then teleported — the release never travelled to its end target, which
|
||||
// is the same defect `lineage-trebuchet`'s quickstep is cited for.
|
||||
// Measured on the glass 2026-08-15: *"the long full swipe up is
|
||||
// completely fucked, only the short swipe partially works."*
|
||||
// `presence` above is written to rise to the detent and then fall away
|
||||
// "over the same distance", which puts home at clock 2. This settled it
|
||||
// at 1 for both home and overview, so a long swipe ran the short
|
||||
// swipe's motion, held the multitasking view fully present, and then
|
||||
// teleported. Measured on the glass 2026-08-15: *"the long full swipe up
|
||||
// is completely fucked, only the short swipe partially works."*
|
||||
const endClock = target === "last_zone" ? 0 : target === "home" ? 2 : 1;
|
||||
// The clock, not the clamped carry: past the detent `shift` is pinned at
|
||||
// 1, so a release from a home-bound pull computed a duration for travel
|
||||
// it had already done.
|
||||
const from = root.clock;
|
||||
settleAnim.stop();
|
||||
settleTick.running = false;
|
||||
root._settleTarget = target;
|
||||
root.settling = true;
|
||||
settleAnim.from = root.travel;
|
||||
settleAnim.to = endClock * root.detent;
|
||||
settleAnim.duration = Math.min(root.maxSwipeDuration,
|
||||
Math.abs(Math.round((endClock - from)
|
||||
* root.maxSwipeDuration * root.swipeDurationMultiplier)));
|
||||
settleAnim.start();
|
||||
settleTick.to = endClock * root.detent;
|
||||
settleTick.elapsed = 0;
|
||||
// Projected onto the way out, not applied raw. The gesture has already
|
||||
// resolved to one end target, so a hard release means "get there", not
|
||||
// "carry on past it and be pulled back" — which is what a raw upward
|
||||
// seed would do to a `last_zone` return and would read as a bounce.
|
||||
const toward = settleTick.to >= root.travel ? 1 : -1;
|
||||
settleTick.velocity = toward * Math.min(root.maxSeedVelocity,
|
||||
Math.max(0, velocity ?? 0));
|
||||
settleTick.running = true;
|
||||
}
|
||||
|
||||
// --- The carry -----------------------------------------------------------
|
||||
|
|
@ -317,10 +483,20 @@ Singleton {
|
|||
// 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.
|
||||
// Last shift actually written, and how little a change has to be before it
|
||||
// is not worth sending.
|
||||
//
|
||||
// This was 0.01 — a hundred steps for the whole carry, inherited from
|
||||
// `poseActiveZone` on the reasoning that a round-trip per motion event was
|
||||
// expensive. **Measured on blueline 2026-08-16, it is not:** 200 samples of
|
||||
// connect → write → event-loop hop → reply → close, `{"op":"workspaces"}`,
|
||||
// came back at 0.47 ms median and 1.04 ms worst — under 3% of a 16.7 ms
|
||||
// frame. A hundred steps across 275 px of travel is a step every 2 logical
|
||||
// pixels, and on a scaling window that staircase is visible.
|
||||
//
|
||||
// 500 steps costs the same 0.5 ms per frame, because it is still one send
|
||||
// per motion event — the quantiser was never what bounded the rate.
|
||||
readonly property real shiftEpsilon: 0.002
|
||||
property real _lastShift: -1
|
||||
|
||||
// Take the windows on the zone in front and name where each is going.
|
||||
|
|
@ -350,12 +526,14 @@ Singleton {
|
|||
return true;
|
||||
}
|
||||
|
||||
// 0 is where the windows live, 1 is each on its card.
|
||||
// 0 is where the windows live, 1 is each on its card, and past 1 is the
|
||||
// resisted overshoot toward home. A compositor that predates the overshoot
|
||||
// clamps it back to 1 on arrival, which is the old behaviour exactly.
|
||||
function progress(shift) {
|
||||
if (!root.inFlight)
|
||||
return;
|
||||
const s = Math.max(0, Math.min(1, shift));
|
||||
if (Math.abs(s - root._lastShift) < 0.01)
|
||||
const s = Math.max(0, Math.min(root.maxOvershoot, shift));
|
||||
if (Math.abs(s - root._lastShift) < root.shiftEpsilon)
|
||||
return;
|
||||
root._lastShift = s;
|
||||
ViewtopControl.overviewProgress(s);
|
||||
|
|
|
|||
Loading…
Reference in a new issue