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:
parent
a3e0420f5a
commit
df8a56dfb3
3 changed files with 158 additions and 2 deletions
106
surfaces/quickshell/modules/souveraine/windowSheet/PoseRamp.qml
Normal file
106
surfaces/quickshell/modules/souveraine/windowSheet/PoseRamp.qml
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue