From f06bb8394e64f61fd0a409de4482455e58a512ae Mon Sep 17 00:00:00 2001 From: Fimeg Date: Sun, 16 Aug 2026 10:31:18 -0400 Subject: [PATCH] overview: draw the zone, opaque, with a tray under it The card was the whole panel scaled, so the bar's reservation rode inside it as dead space above every window. Subtracted in targetFor and the pane layout both, or the carry lands a bar-height off its own picture. Minting a zone needed no new verb: Request::Workspace already pushes one when asked for the zone past the end. --- surfaces/quickshell/modules/ii/dock/Dock.qml | 16 + .../souveraine/navigation/ZoneOverview.qml | 295 +++++++++++++++++- 2 files changed, 298 insertions(+), 13 deletions(-) diff --git a/surfaces/quickshell/modules/ii/dock/Dock.qml b/surfaces/quickshell/modules/ii/dock/Dock.qml index f211e48..4894f54 100644 --- a/surfaces/quickshell/modules/ii/dock/Dock.qml +++ b/surfaces/quickshell/modules/ii/dock/Dock.qml @@ -72,6 +72,22 @@ Scope { // Scope } function computeDockState() { + // Multitasking owns the screen. The dock is *home's* furniture, and + // home is one of the cards — a dock over the strip is one destination's + // furniture drawn on top of the picker for all of them. Casey, + // 2026-08-16: *"remove the dock it should not be there."* + // + // First in the ladder, above the home check, because the gesture is + // most often made **from** home: `activeZone` is still 0 for the whole + // time the overview is up, so every rung below this returns PINNED. + // + // The peek counts. The destination draws from the first climbing pixel + // (`SystemGestureRail.peekWanted`), so the dock leaving as the cards + // arrive is one motion rather than two events. + if (GlobalStates.missionControlOpen || GlobalStates.overviewOpen + || GlobalStates.missionPeek) + return Dock.DockState.Hidden; + // A visible keyboard owns the bottom edge. This must precede the home // zone's unconditional PINNED return below; otherwise home stacks the // dock's exclusive zone under the OSK and charges the display twice. diff --git a/surfaces/quickshell/modules/souveraine/navigation/ZoneOverview.qml b/surfaces/quickshell/modules/souveraine/navigation/ZoneOverview.qml index 11f1673..8c0918b 100644 --- a/surfaces/quickshell/modules/souveraine/navigation/ZoneOverview.qml +++ b/surfaces/quickshell/modules/souveraine/navigation/ZoneOverview.qml @@ -56,6 +56,7 @@ // presented as correct. import QtQuick import Quickshell +import Quickshell.Io import Quickshell.Wayland import qs import qs.services @@ -145,8 +146,10 @@ Item { // what makes the surface correct on a phone whose compositor predates the // push channel. onOpenChanged: { - if (root.open) + if (root.open) { ViewtopControl.refreshWindows(); + list.seat(); + } } // A peek draws these cards without ever setting `open`, so it owes the same @@ -162,18 +165,92 @@ Item { implicitWidth: parent ? parent.width : 540 implicitHeight: parent ? parent.height : 800 + // --- The line she speaks on ---------------------------------------------- + // + // Deliberately **not** model-dependent. It carries a real, locally computed + // fact about what is open, and it is a place a model can later write to + // rather than a place that only exists once one can. Casey, 2026-08-16: + // *"It should sorta initially be less than model dependant but we'll build + // out that whole system."* + // + // Doctrine §13 is why the band is here at all: multitasking is the one + // surface that is purely about operation — what is running, where it lives — + // and operation is hers. A verb she cannot reach is a defect; a screen + // about her half of the device with nowhere for her to speak is the same + // defect wearing a different face. + property string spoken: "" + + readonly property string note: { + if (root.spoken.length > 0) + return root.spoken; + const zones = Math.max(1, ViewtopControl.zoneCount); + const wins = ViewtopControl.windows.length; + const floats = ViewtopControl.windows.filter(w => w.floating === true).length; + const parts = [qsTr("%n zone(s)", "", zones), qsTr("%n window(s)", "", wins)]; + if (floats > 0) + parts.push(qsTr("%n floating", "", floats)); + return parts.join(" · "); + } + + // `qs -c souveraine ipc call overviewNote say "…"`. One verb, so the thing + // that writes here is a caller with a name rather than a global somebody + // sets from four places. + IpcHandler { + target: "overviewNote" + + function say(text: string): void { + root.spoken = text; + } + + function clear(): void { + root.spoken = ""; + } + + function state(): string { + return JSON.stringify({ + spoken: root.spoken, + shown: root.note, + subject: root.subject ? root.subject.id : null, + restingZone: root.restingZone ? root.restingZone.zone : null + }); + } + } + + // The zone the strip has come to rest on, and the window inside it a verb + // would act on. Focused first, else the first one there — a split has two + // and the tray has to name one of them without asking. + readonly property var restingZone: root.zones.length === 0 ? null + : root.zones[Math.max(0, Math.min(root.zones.length - 1, list.currentIndex))] + + readonly property var subject: { + const z = root.restingZone; + if (!z || z.windows.length === 0) + return null; + for (const w of z.windows) + if (w.focused) + return w; + return z.windows[0]; + } + ListView { id: list anchors.fill: parent anchors.margins: 12 + anchors.bottomMargin: 12 + ZoneTransition.trayHeight transform: Translate { y: (1 - root.progress) * 24 } model: root.zones orientation: ListView.Horizontal snapMode: ListView.SnapOneItem highlightRangeMode: ListView.StrictlyEnforceRange - preferredHighlightBegin: 0 - preferredHighlightEnd: width - spacing: 12 + // Centred, not pinned left. The delegate is one card wide plus its + // gutter, so both neighbours show and the strip reads as cards you + // scroll between rather than screens you page through. `ZoneTransition` + // owns these numbers because it also owns where the carry lands. + preferredHighlightBegin: ZoneTransition.highlightBegin + preferredHighlightEnd: ZoneTransition.highlightBegin + ZoneTransition.delegateWidth + // The gutter is inside the delegate; two sources of separation would be + // two numbers to keep agreeing. + spacing: 0 clip: true // Nothing off the sides is built. A card that is not in frame is a // window capture nobody can see — see the header. @@ -181,17 +258,40 @@ Item { // Open on the zone you are actually on, so the first card is where you // came from rather than wherever the strip happens to start. - Component.onCompleted: list.currentIndex = - Math.max(0, root.zones.findIndex(z => z.zone === ViewtopControl.activeZone)) + // + // Re-seated on every model change, not set once at construction. + // `root.zones` rebuilds into a **new array** whenever the compositor's + // window list moves, a ListView resets `currentIndex` to 0 on a model + // change, and `onOpenChanged` asks for a refresh the instant this + // opens — so the reset was guaranteed and the strip always came to rest + // on Home. Measured 2026-08-16: three zones, Firefox in front, and the + // card you were shown was the empty one. + // + // `callLater` so the delegates exist to be seated. + function seat(): void { + list.currentIndex = Math.max(0, + root.zones.findIndex(z => z.zone === ViewtopControl.activeZone)); + } + onModelChanged: Qt.callLater(list.seat) + Component.onCompleted: list.seat() delegate: Item { id: zoneCard required property var modelData required property int index - width: list.width + width: ZoneTransition.delegateWidth height: list.height + // How far this card is from the middle of the strip, in delegates. + // 0 is the one you are resting on. + readonly property real offCentre: { + const centre = list.contentX + list.width / 2; + const mine = zoneCard.x + zoneCard.width / 2; + return Math.abs(mine - centre) + / Math.max(1, ZoneTransition.delegateWidth); + } + readonly property bool isHome: zoneCard.modelData.zone === ViewtopControl.homeZone readonly property bool isActive: zoneCard.modelData.zone === ViewtopControl.activeZone // The card the strip has come to rest on. This one, and only this @@ -204,9 +304,25 @@ Item { const start = Math.min(zoneCard.index, 5) * 0.045; return Math.max(0, Math.min(1, (root.progress - start) / (1 - start))); } - opacity: zoneCard.share + // Depth, and only where it cannot become a second writer. + // + // A card off to the side sits back and dims, so the strip reads as + // cards floating at different distances rather than a filmstrip. + // **Gated on `inFlight`:** while the compositor is carrying a real + // window onto one of these rects, a card that also scales is + // exactly the fault TASK-60 was written to remove, so during a + // carry every card sits at its true rect and the depth eases in on + // the commit frame through the Behavior below. + readonly property real depth: ZoneTransition.inFlight + ? 0 : Math.min(1.4, zoneCard.offCentre) + scale: 1 - 0.07 * zoneCard.depth + opacity: zoneCard.share * (1 - 0.4 * Math.min(1, zoneCard.depth)) + Behavior on scale { + NumberAnimation { duration: 180; easing.type: Easing.OutCubic } + } - // No `scale` here, and that absence is the point of TASK-60. + // No `scale` tied to the *gesture*, and that absence is the point of + // TASK-60. // // This used to read `1.0 - 0.4 * root.progress`, tuned to match the // curve the rail was running through `pose` on the real windows. @@ -242,12 +358,23 @@ Item { // offsets come off the shared rect rather than being re-derived, // so the card and the carry stay the same rectangle even if the // frame constants move. - x: ZoneTransition.cardX - ZoneTransition.listMargin - y: ZoneTransition.cardY - ZoneTransition.listMargin + x: ZoneTransition.cardInsetX + y: ZoneTransition.cardInsetY width: ZoneTransition.cardWidth height: ZoneTransition.cardHeight radius: 18 - color: Appearance.colors.colLayer1 + // Opaque, deliberately, and the theme colour is not. + // + // The backdrop behind this is the wallpaper blurred — which + // stays, it is the room these sit in — but a translucent card + // let it through, so every card was a coloured smear of the + // wallpaper instead of a card with something on it. Measured + // 2026-08-16 on the Home card: the whole frame read as one + // pink-and-blue blur. Cards float *on* the room; they are not + // made of it. + color: Qt.rgba(Appearance.colors.colLayer1.r, + Appearance.colors.colLayer1.g, + Appearance.colors.colLayer1.b, 1) // The zone you are on is named by its frame rather than by // moving it: a card that jumps out of the row is a card whose // neighbours shift under the thumb mid-swipe. @@ -280,8 +407,15 @@ Item { id: pane required property var modelData + // `contentTop` off the y before scaling: the card is a + // picture of the usable zone, so a window that starts + // below the bar's reservation starts at the card's top + // edge. `ZoneTransition.targetFor` makes the identical + // subtraction — that is what puts the carried window + // exactly on its own picture. x: (pane.modelData.at?.x ?? 0) * ZoneTransition.cardScale - y: (pane.modelData.at?.y ?? 0) * ZoneTransition.cardScale + y: ((pane.modelData.at?.y ?? 0) - ZoneTransition.contentTop) + * ZoneTransition.cardScale width: (pane.modelData.size?.width ?? 0) * ZoneTransition.cardScale height: (pane.modelData.size?.height ?? 0) * ZoneTransition.cardScale @@ -388,4 +522,139 @@ Item { } } } + + // --- The tray ------------------------------------------------------------ + // + // A predicate, not a launcher. Every control here acts on the card in + // front — Android's instinct with the screenshot button under recents, and + // the reason it reads as a sentence rather than a toolbar. An app grid + // would make this a second home screen; the drawer already owns that + // (TASK-14), and a verb in two places is two places to fix it. + // + // Screenshot is deliberately absent for the same reason: it is already in + // the right-hand sidebar. Casey noticed that before I did. + Item { + id: tray + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 12 + height: ZoneTransition.trayHeight - 12 + opacity: root.progress + transform: Translate { y: (1 - root.progress) * 24 } + + Row { + id: verbs + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + spacing: 18 + + // Float is first because it is the verb this surface owes. A float + // has left its zone's confinement, so the strip stops counting it + // and it becomes the one window you cannot find by remembering + // where you left it — which makes multitasking the only place it is + // findable, and therefore the only honest place to turn it off. + TrayVerb { + symbol: root.subject && root.subject.floating === true + ? "picture_in_picture_off" : "picture_in_picture" + caption: root.subject && root.subject.floating === true + ? qsTr("Unfloat") : qsTr("Float") + active: root.subject !== null + onTriggered: { + if (root.subject.floating === true) + ViewtopControl.unfloat(root.subject.id); + else + ViewtopControl.float(root.subject.id); + ViewtopControl.refreshWindows(); + } + } + + // Minting a zone has never had a control anywhere in this OS, + // because zones are born from need rather than created — and that + // left "put this somewhere of its own" unreachable. The compositor + // has always answered it: `Request::Workspace` reads + // `Some(w) if w >= count => self.workspaces.push()`, so asking for + // the zone past the end *is* the mint. One button, no new verb. + TrayVerb { + symbol: "add_to_queue" + caption: qsTr("New zone") + active: root.subject !== null + && root.restingZone.windows.length > 0 + onTriggered: { + ViewtopControl.moveToZone(root.subject.id, + Math.max(1, ViewtopControl.zoneCount)); + ViewtopControl.refreshWindows(); + } + } + + // Auxo's one contribution that everybody kept. `close` and not + // `kill`: this is a request the client may refuse or prompt on, and + // a row of cards is the wrong place to take that choice away. + // Force lives on the sheet, behind a hold, where it is deliberate. + TrayVerb { + symbol: "clear_all" + caption: qsTr("Close all") + active: root.restingZone !== null + && root.restingZone.windows.length > 0 + onTriggered: { + for (const w of root.restingZone.windows) + ViewtopControl.close(w.id); + } + } + } + + StyledText { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: 4 + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + opacity: root.spoken.length > 0 ? 0.95 : 0.55 + text: root.note + } + } + + // One verb in the tray: a symbol, a caption under it, and an off state that + // reads as unavailable rather than as broken. Local because it is the + // tray's own idiom and nothing else wants it yet; promote it to + // `common/widgets` the second something does. + component TrayVerb: Item { + id: verb + required property string symbol + required property string caption + property bool active: true + signal triggered + + width: 84 + height: 78 + opacity: verb.active ? 1 : 0.35 + + RippleButton { + id: hit + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + implicitWidth: 52 + implicitHeight: 52 + buttonRadius: 26 + enabled: verb.active + onClicked: verb.triggered() + contentItem: MaterialSymbol { + anchors.centerIn: parent + text: verb.symbol + iconSize: 24 + } + } + + StyledText { + anchors.top: hit.bottom + anchors.topMargin: 4 + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + opacity: 0.8 + text: verb.caption + } + } }