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 + } + } }