Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/modules/souveraine/windowSheet/WindowSheet.qml
Fimeg 75cb392924 sheet: let zoom reach a window
pose has been in the wire since 2026-08-05 with no caller — poseActiveZone was
the only one, and TASK-60 deleted it. Zoom composes pixels the client already
drew; Resize places a new rectangle and the client relays out inside it.
2026-08-16 10:31:18 -04:00

484 lines
22 KiB
QML

// The window action sheet — the hand's verbs on one window.
//
// TASK-55. Three fingers tapped a window; the compositor named which one, the
// machine decided what that means, and this draws the answer. The split is
// `gesture.rs`'s own: "the compositor recognises, the shell draws, and the
// answer comes back as the ToCompositor intents that already work". This file
// is the drawing half, and ViewtopControl is the answering half.
//
// ## Why a scrim and not a blur
//
// GaussianBlur fails on the phone's GLES path — the Home drawer hit exactly
// this on 2026-08-05 and fell back to a flat translucent panel (`panelBg`).
// Repeating a known-broken effect here would trade a working sheet for a
// black rectangle on the one device that matters. The scrim reads as "the app
// is behind this and paused" without asking the GPU for something it refuses.
//
// ## Why the buttons are this big
//
// The selection chip is the house precedent for a floating action surface and
// it failed twice on device — "tiny, illegible, not working properly at all" —
// at 44 px tall with 18-20 px icons. TASK-55 Q6 makes not inheriting that a
// condition of this shipping. So: 76 px circles, 34 px glyphs, 28 px between
// them, and the row sits above the vertical centre so a thumb reaching from
// the bottom bezel does not cover the window being acted on.
//
// ## Close, and the floor under it
//
// A tap is `close` — the protocol asking, which a client may refuse or answer
// with a save-your-work prompt. A press-and-hold is `kill`, which always
// works and loses unsaved work. Two verbs on one button because they are the
// same intent at two levels of insistence, and because a separate always-kill
// button would get pressed by someone who meant the polite one.
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
Scope {
id: scope
// The window this sheet is about. 0 is "none" — the sheet is never open
// without a subject, which is what keeps the no-window case from becoming
// a blank sheet nobody can explain.
property int target: 0
property bool sheetOpen: false
// Split has pinned a window and is waiting for the other half to be filled.
property bool choosing: false
// A window is grabbed and following the finger. The sheet is closed while
// this is true — it would be covering the thing being moved — so the only
// surface up is the Done bar.
property bool moving: false
// Which size Resize last applied. Kept on the scope rather than the button
// so it survives the sheet closing and reopening on the same window —
// tapping Resize twice in two visits should continue the cycle, not restart
// it and appear to do nothing.
property int sizeStep: 0
// Which pose Zoom last applied, on the scope for the same reason as
// `sizeStep`: the cycle has to survive the sheet closing.
property int poseStep: 0
// Whether the subject has left its zone's confinement, as the compositor
// last reported it. Read, never remembered — a shell that kept its own idea
// of this would draw Unfloat on a window something else had already put
// back.
readonly property bool targetFloating: {
for (const w of ViewtopControl.windows)
if (w.id === scope.target)
return w.floating === true;
return false;
}
// How long a hold on Close means kill. Longer than a tap could ever be,
// short enough that a stuck app does not feel like a negotiation.
readonly property int killHoldMs: 1000
function openFor(id) {
if (!id || id <= 0) {
console.log("[window-sheet] refusing to open without a target");
return;
}
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
// wrong rectangle for exactly as long as it took to notice.
ViewtopControl.refreshWindows();
scope.sheetOpen = true;
}
function dismiss() {
scope.sheetOpen = false;
scope.choosing = false;
// A grab outlives the sheet on purpose — Move closes the sheet to get
// out of the way — so dismissing must not orphan one. Anything else
// would leave the compositor holding a window for a finger that has
// nothing left on screen telling it how to let go.
if (scope.moving) {
ViewtopControl.drop();
scope.moving = false;
}
scope.target = 0;
}
// sessiond's `Action::WindowSheet` lands here: `qs -c souveraine ipc call
// windowSheet open <id>`. The id arrives as a string because the executor
// builds a command line; parsing is this side's job.
IpcHandler {
target: "windowSheet"
function open(id: string): void {
scope.openFor(parseInt(id, 10));
}
function close(): void {
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.
Connections {
target: ViewtopControl
function onRefused(intent, reason) {
if (!scope.sheetOpen)
return;
refusal.text = intent + " refused: " + reason;
refusal.opacity = 1;
refusalFade.restart();
}
function onSucceeded(intent) {
// The window is gone or moved; the sheet has nothing left to be
// about. Kept open for `pose`-style verbs would mean a sheet
// pointing at a window that is no longer where it was.
//
// Except mid-Split: that `place` is the *first half* of a flow, and
// dismissing on it would close the sheet before the chooser could
// ask which window fills the other half.
if (scope.choosing)
return;
if (intent === "close" || intent === "kill" || intent === "place")
scope.dismiss();
}
}
// 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
// gesture it exists to support.
Variants {
model: Quickshell.screens
PanelWindow {
id: moveBar
required property var modelData
screen: moveBar.modelData
anchors { bottom: true; left: true; right: true }
implicitHeight: 86
color: "transparent"
visible: scope.moving
WlrLayershell.namespace: "souveraine:windowmove"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.centerIn: parent
implicitWidth: 220
implicitHeight: 58
radius: 29
color: Appearance.colors.colLayer1
border.width: 1
border.color: Appearance.colors.colLayer1Active
StyledText {
anchors.centerIn: parent
text: "Done moving"
color: Appearance.colors.colOnLayer1
font.pixelSize: Appearance.font.pixelSize.normal
}
MouseArea {
anchors.fill: parent
onClicked: {
ViewtopControl.drop();
scope.moving = false;
scope.target = 0;
}
}
}
}
}
Variants {
model: Quickshell.screens
PanelWindow {
id: win
required property var modelData
screen: win.modelData
anchors { top: true; left: true; right: true; bottom: true }
color: "transparent"
visible: scope.sheetOpen
WlrLayershell.namespace: "souveraine:windowsheet"
WlrLayershell.layer: WlrLayer.Overlay
// OnDemand rather than Exclusive: an exclusive grab on the polkit
// surface stopped touch reaching the layer below it entirely
// (2026-07-26), and this surface must not repeat that.
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
exclusionMode: ExclusionMode.Ignore
// The tap-away target. Transparent and full-screen: dismissal by
// tapping outside is load-bearing on a phone and is the only exit
// that needs no explanation, so the *catcher* still spans the
// glass even though the *scrim* no longer does.
MouseArea {
anchors.fill: parent
onClicked: scope.dismiss()
}
// The scrim, over the target window only.
//
// Casey, 2026-08-05: *"Three finger tap overlay should overlay the
// app it's talking about; not all apps."* Dimming everything said
// the sheet was about the device; it is about one window, and on a
// split screen it was covering the app you were not asking about.
//
// Geometry comes from the compositor's `workspaces` reply rather
// than being computed here — the strip offset and any pose are
// facts only it holds, which is the same reason the tap carries its
// target instead of a raw point.
Rectangle {
id: scrim
readonly property var target: {
for (const w of ViewtopControl.windows)
if (w.id === scope.target && w.at && w.size)
return w;
return null;
}
// Falls back to the whole screen when the compositor reported
// no geometry. A sheet with no visible subject is confusing;
// one that dims everything is merely less precise, and the
// buttons still work.
x: scrim.target ? scrim.target.at.x : 0
y: scrim.target ? scrim.target.at.y : 0
width: scrim.target ? scrim.target.size.width : parent.width
height: scrim.target ? scrim.target.size.height : parent.height
color: "#99000000"
radius: scrim.target ? 12 : 0
Behavior on opacity { NumberAnimation { duration: 120 } }
}
// The chooser for Split's other half. Sits in the bottom half —
// where the window it is choosing for will land — so the gesture
// reads as "put something here" rather than as a menu that happens
// to be on screen.
ColumnLayout {
visible: scope.choosing
anchors.horizontalCenter: parent.horizontalCenter
y: parent.height * 0.58
spacing: 14
StyledText {
Layout.alignment: Qt.AlignHCenter
text: ViewtopControl.windows.length > 1
? "fill the other half"
: "nothing else is open"
color: "#e6ffffff"
font.pixelSize: Appearance.font.pixelSize.normal
}
Repeater {
model: ViewtopControl.windows.filter(w => w.id !== scope.target)
delegate: Rectangle {
required property var modelData
Layout.alignment: Qt.AlignHCenter
implicitWidth: 220
implicitHeight: 56
radius: 14
color: pick.pressed
? Appearance.colors.colLayer2
: Appearance.colors.colLayer1
border.width: 1
border.color: Appearance.colors.colLayer1Active
StyledText {
anchors.centerIn: parent
elide: Text.ElideRight
width: parent.width - 24
horizontalAlignment: Text.AlignHCenter
// The strip carries app_id and title now, so this
// names the window instead of numbering it. Falls
// back to the id rather than to blank: a chooser
// row you cannot identify is worse than an ugly one.
text: {
const w = parent.modelData;
const name = w.title || w.app_id || ("window " + w.id);
return w.floating ? name + " (float)" : name;
}
color: Appearance.colors.colOnLayer1
font.pixelSize: Appearance.font.pixelSize.small
}
MouseArea {
id: pick
anchors.fill: parent
onClicked: {
const pickId = parent.modelData.id;
// Bring it here first. The chooser offers every
// open window, and most of them are on another
// zone — placing one without moving it puts the
// other half of your split on a zone you are not
// looking at, which reads as the pick doing
// nothing.
const here = ViewtopControl.windows
.find(w => w.id === scope.target)?.workspace;
if (here !== undefined && parent.modelData.workspace !== here)
ViewtopControl.moveToZone(pickId, here);
ViewtopControl.place(pickId, 0,
win.height / 2, win.width, win.height / 2);
scope.choosing = false;
scope.dismiss();
}
}
}
}
}
// The row, centred on the window it acts on rather than on the
// panel — a sheet floating mid-screen while the app it names is in
// the top half reads as belonging to neither.
RowLayout {
visible: !scope.choosing
x: scrim.x + (scrim.width - width) / 2
y: scrim.y + scrim.height * 0.38
spacing: 28
SheetButton {
icon: "drag_pan"
label: "Move"
onTapped: {
// Hands the window to the finger and gets out of the
// way: the sheet closes, one-finger drags move the
// window, and `drop` ends it. A one-shot `place` here
// would be the compositor guessing where you wanted it.
ViewtopControl.grab(scope.target);
scope.moving = true;
scope.sheetOpen = false;
}
}
SheetButton {
icon: "aspect_ratio"
label: "Resize"
// Cycles the window through sizes rather than opening a
// drag-handle mode: Move already owns "follow my finger",
// and a second finger-driven mode would need a way to tell
// them apart mid-gesture. Tapping through half → two-thirds
// → full → back is reachable one-handed and needs no
// second gesture vocabulary.
//
// A real corner handle is still owed; this is the verb
// reaching the window, which is what was missing when the
// button said Move and there was no Resize at all.
onTapped: {
scope.sizeStep = (scope.sizeStep + 1) % 3;
const frac = [0.5, 0.67, 1.0][scope.sizeStep];
ViewtopControl.place(scope.target, 0, 0,
win.width, Math.round(win.height * frac));
}
}
SheetButton {
icon: "zoom_out_map"
label: "Zoom"
// The `pose` verb, reaching a window for the first time.
//
// `pose` has existed in the wire and the compositor since
// 2026-08-05 — it genuinely scales content, not only moves
// it — and **nothing in the shell has ever called it**.
// `poseActiveZone` did, and TASK-60 deleted that wrapper as
// the shell's half of the two-writer bug, which took the
// only caller with it. Casey, 2026-08-16: *"I can move
// windows but the zooming them in and out didn't work
// yet."* It did not work because nobody asked.
//
// Distinct from Resize, and the difference is load-bearing:
// Resize `place`s a different *rectangle* and the client
// relays out inside it. Zoom composes the pixels it already
// drew — the client is never told, its layout does not
// move, and the compositor maps input back through the
// inverse so a shrunken window is still touchable where it
// is drawn.
//
// 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.
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);
}
}
SheetButton {
icon: scope.targetFloating ? "picture_in_picture_off"
: "picture_in_picture"
label: scope.targetFloating ? "Unfloat" : "Float"
// A float leaves the zone's confinement — it stops being
// the zone's business, so the strip no longer counts it
// when deciding whether a zone still holds anything.
//
// Which is exactly why the label has to flip: a float is
// the one window you can no longer find by remembering
// which zone you left it on, so the way back out must be
// the same button, in the same place, saying so.
onTapped: {
if (scope.targetFloating)
ViewtopControl.unfloat(scope.target);
else
ViewtopControl.float(scope.target);
ViewtopControl.refreshWindows();
}
}
SheetButton {
icon: "splitscreen"
label: "Split"
// Pin this window to the top half, then offer the other
// half to something already open. Casey's shape: "the
// second waiting one possibly having a button to add an
// already opened one from the multitasking section".
//
// The list is queried, not assumed — a chooser offering a
// window that has since closed would `place` a dead id and
// refuse, which reads as the button being broken.
onTapped: {
ViewtopControl.place(scope.target, 0, 0, win.width, win.height / 2);
ViewtopControl.refreshWindows();
scope.choosing = true;
}
}
SheetButton {
icon: "close"
label: "Close"
holdLabel: "hold to force"
holdMs: scope.killHoldMs
onTapped: ViewtopControl.close(scope.target)
onHeld: ViewtopControl.kill(scope.target)
}
}
StyledText {
id: refusal
anchors.horizontalCenter: parent.horizontalCenter
y: parent.height * 0.38 + 150
width: parent.width * 0.8
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
color: "#e6ffffff"
font.pixelSize: Appearance.font.pixelSize.small
opacity: 0
text: ""
Behavior on opacity { NumberAnimation { duration: 150 } }
Timer {
id: refusalFade
interval: 2600
onTriggered: refusal.opacity = 0
}
}
}
}
}