Watch
1
0
Fork
You've already forked souveraine
0

sheet: spring the zoom, and let go of a window that died

pose is instant on the far side and the compositor must not ease it —
it honours, it does not adjudicate. So the curve is the shell's, on
Android's own scale spring: stiffness 200, damping 0.75, underdamped
on purpose and separate from the position spring's 0.8.

The sheet also stayed open over a window something else closed, where
all six buttons then refused with gone.
This commit is contained in:
Fimeg 2026-08-16 14:24:47 -04:00
commit df8a56dfb3
3 changed files with 158 additions and 2 deletions

View file

@ -0,0 +1,106 @@
// A window's scale, travelling instead of jumping.
//
// `pose` is instant on the far side. `set_pose` in the compositor is one
// registry update the doctrine is that it "honours rather than adjudicates",
// so easing must not live there or the compositor would be deciding how the
// agent's own verb looks. The curve is the shell's, exactly as `ZoneTransition`
// owns the carry's curve and the compositor only carries.
//
// So Zoom fired one call and the window changed size between two frames. Casey,
// 2026-08-16: *"zoom is still snapping"*.
//
// Android never steps a scale. `RectFSpringAnim` gives scale a spring of its
// own `SpringAnimation(this, RECT_SCALE_PROGRESS)` with
// `swipe_up_rect_scale_stiffness` 200 and `swipe_up_rect_scale_damping_ratio`
// **0.75**, separate from the position spring's 0.8 and deliberately under one:
// scale overshoots a little and settles, and that is the whole of why theirs
// reads better than a step.
//
// Same numbers here. Integrated on `FrameAnimation` the render clock for
// the same reason the settle is: one send per painted frame rather than one per
// whatever the socket managed.
import QtQuick
import Quickshell
import qs.services
Item {
id: ramp
property int target: 0
// Where the scale is now, and where it is going. `scale` is authoritative
// between calls: the compositor does not report pose back, so a caller that
// re-read it would be reading its own last write anyway.
property real scale: 1.0
property real to: 1.0
readonly property bool running: tick.running
// sqrt(200) in rad/s, expressed per millisecond to match the rest of the
// shell's integrators. Damping under 1 on purpose see the header.
readonly property real omega: 0.01414
readonly property real damping: 0.75
// Nothing outlives this. A spring that failed to converge still has to
// leave the window at a scale someone asked for.
readonly property int maxMs: 900
// Below this the change is not visible on a 540 px panel and is not worth a
// round trip.
readonly property real epsilon: 0.0015
property real _velocity: 0
property real _elapsed: 0
property real _sent: -1
// Seeded at rest by default: Zoom is a tap, and a tap has no throw in it.
function springTo(value, velocity) {
if (ramp.target <= 0)
return;
ramp.to = value;
ramp._velocity = velocity ?? 0;
ramp._elapsed = 0;
tick.running = true;
}
function reset(id) {
tick.running = false;
ramp.target = id;
ramp.scale = 1.0;
ramp.to = 1.0;
ramp._velocity = 0;
ramp._sent = -1;
}
function _push(force) {
if (ramp.target <= 0)
return;
if (!force && Math.abs(ramp.scale - ramp._sent) < ramp.epsilon)
return;
ramp._sent = ramp.scale;
ViewtopControl.pose(ramp.target, ramp.scale, 0.0, 0.5, 0.5);
}
FrameAnimation {
id: tick
running: false
onTriggered: {
// Clamped: a dropped frame must not integrate one large step and
// throw the window through its own target.
const dt = Math.min(48, tick.frameTime * 1000);
ramp._elapsed += dt;
const w = ramp.omega;
const offset = ramp.scale - ramp.to;
ramp._velocity += (-2 * ramp.damping * w * ramp._velocity - w * w * offset) * dt;
ramp.scale += ramp._velocity * dt;
if ((Math.abs(ramp.scale - ramp.to) < 0.001
&& Math.abs(ramp._velocity) < 0.0001)
|| ramp._elapsed >= ramp.maxMs) {
tick.running = false;
ramp.scale = ramp.to;
ramp._push(true);
return;
}
ramp._push(false);
}
}
}

View file

@ -80,6 +80,13 @@ Scope {
console.log("[window-sheet] refusing to open without a target");
return;
}
// A different window starts both cycles over. They survive the sheet
// closing on the *same* window on purpose; carrying them onto another
// one meant the first tap of Zoom or Resize appeared to do nothing.
if (id !== poseRamp.target) {
scope.poseStep = 0;
poseRamp.reset(id);
}
scope.target = id;
// Ask where that window is, now. The poll runs every two seconds, and
// a sheet that opened against two-second-old geometry would scrim the
@ -117,6 +124,37 @@ Scope {
}
}
// The subject went away while the sheet was up.
//
// TASK-55's acceptance wants the sheet dismissed "without leaving a latch
// behind". `onSucceeded` covers the exits this sheet asked for; it cannot
// cover the app quitting on its own, the tray's Close all, or a card being
// closed from the overview. The sheet stayed open over a dead id and every
// button then refused with `gone`, which reads as six broken buttons.
//
// The compositor's `cancel` deliberately emits nothing "a cancelled
// gesture did not happen", and there is a test pinning that so the
// canvas is the honest signal, and it is already pushed.
Connections {
target: ViewtopControl
function onWindowsChanged_() {
if (scope.target <= 0)
return;
for (const w of ViewtopControl.windows)
if (w.id === scope.target)
return;
// Ends the grab too. A move whose window died leaves the compositor
// holding nothing and the Done bar up with no way to reach it.
//
// The ramp is stopped by hand rather than by `dismiss`, which is
// also the ordinary close and must leave the Zoom cycle where it
// is a spring still ticking at a dead id refuses once per frame.
if (poseRamp.target === scope.target)
poseRamp.reset(0);
scope.dismiss();
}
}
// A refusal the hand can see. `close` is a request, and a client that says
// no must not look like a button that did nothing that silence is the
// complaint this whole task came from.
@ -155,6 +193,13 @@ Scope {
onFinished: ViewtopControl.refreshWindows()
}
// Zoom's travel. Outlives the sheet for the same reason `poseStep` does
// a ramp that stopped when the sheet closed would leave the window
// part-scaled at whatever frame the dismissal landed on.
PoseRamp {
id: poseRamp
}
// The way out of move mode. A separate, small surface rather than part of
// the sheet: while moving, the whole screen has to stay reachable by the
// finger that is dragging the window, so a full-screen scrim would eat the
@ -404,10 +449,14 @@ Scope {
// Identity is a step in the cycle rather than a separate
// Reset: a pose the user cannot get out of with the button
// they got into it with is a trap.
// Sprung, not stepped. One `pose` call changed the window's
// size between two frames Casey, 2026-08-16: *"zoom is
// still snapping"*. `PoseRamp` carries it on Android's own
// scale spring; the compositor still only honours what it
// is told, once per painted frame.
onTapped: {
scope.poseStep = (scope.poseStep + 1) % 3;
const scale = [1.0, 0.85, 0.7][scope.poseStep];
ViewtopControl.pose(scope.target, scale, 0.0, 0.5, 0.5);
poseRamp.springTo([1.0, 0.85, 0.7][scope.poseStep], 0);
}
}

View file

@ -1,5 +1,6 @@
PowerMenu 1.0 PowerMenu.qml
PowerOptionRow 1.0 PowerOptionRow.qml
PoseRamp 1.0 PoseRamp.qml
SheetButton 1.0 SheetButton.qml
WindowResize 1.0 WindowResize.qml
WindowSheet 1.0 WindowSheet.qml