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.
This commit is contained in:
parent
46d2601908
commit
f06bb8394e
2 changed files with 298 additions and 13 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue