From df8a56dfb3a7ec4aeb4d725d67aabefd586dd8bf Mon Sep 17 00:00:00 2001 From: Fimeg Date: Sun, 16 Aug 2026 14:24:47 -0400 Subject: [PATCH] sheet: spring the zoom, and let go of a window that died MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../souveraine/windowSheet/PoseRamp.qml | 106 ++++++++++++++++++ .../souveraine/windowSheet/WindowSheet.qml | 53 ++++++++- .../modules/souveraine/windowSheet/qmldir | 1 + 3 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 surfaces/quickshell/modules/souveraine/windowSheet/PoseRamp.qml diff --git a/surfaces/quickshell/modules/souveraine/windowSheet/PoseRamp.qml b/surfaces/quickshell/modules/souveraine/windowSheet/PoseRamp.qml new file mode 100644 index 0000000..2f030f5 --- /dev/null +++ b/surfaces/quickshell/modules/souveraine/windowSheet/PoseRamp.qml @@ -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); + } + } +} diff --git a/surfaces/quickshell/modules/souveraine/windowSheet/WindowSheet.qml b/surfaces/quickshell/modules/souveraine/windowSheet/WindowSheet.qml index 577001e..e477d2f 100644 --- a/surfaces/quickshell/modules/souveraine/windowSheet/WindowSheet.qml +++ b/surfaces/quickshell/modules/souveraine/windowSheet/WindowSheet.qml @@ -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); } } diff --git a/surfaces/quickshell/modules/souveraine/windowSheet/qmldir b/surfaces/quickshell/modules/souveraine/windowSheet/qmldir index 2af2dd2..2f2bf2a 100644 --- a/surfaces/quickshell/modules/souveraine/windowSheet/qmldir +++ b/surfaces/quickshell/modules/souveraine/windowSheet/qmldir @@ -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