Watch
1
0
Fork
You've already forked souveraine
0

two swipes, one progress; home is zone 0; pocket is a belief

This commit is contained in:
Fimeg 2026-08-05 19:16:41 -04:00
commit d7965a57fd
188 changed files with 9258 additions and 3970 deletions

View file

@ -164,6 +164,23 @@ Singleton {
// (app launcher/search) this is the running-work view.
property bool missionControlOpen: false
// WRITER: navigation rail, during an upward drag only. 0 at rest, 1 at the
// multitasking detent.
//
// The one progress value the transition and the destination share. The rail
// shrinks the *real* windows through `pose` as the thumb climbs, and
// `ZoneOverview` enters against this same number, so the card the drag was
// pulling toward is already the size the window had reached when it takes
// over. Two surfaces animating the same motion from two clocks is the thing
// that made the first attempt read as a scale effect with a view bolted
// after it TASK-60's acceptance names it: *"no frame where a window is
// scaled by one and laid out by the other."*
//
// A plain number rather than a signal because it is a level: a surface that
// appears mid-drag needs to know where the drag *is*, not to have missed
// the edge where it started.
property real zonePullProgress: 0
// WRITER: Dock.qml IPC (swipeDown). A rail swipe-down on a visible
// dock dismisses it in ANY state including pinned and
// shown-on-empty-desktop. Swipe up clears it.

View file

@ -131,6 +131,7 @@ modules/souveraine/lock/LockSurfaceHost.qml souveraine/modules/souveraine/lock/L
modules/souveraine/lock/qmldir souveraine/modules/souveraine/lock/qmldir
modules/souveraine/navigation/SystemGestureRail.qml souveraine/modules/souveraine/navigation/SystemGestureRail.qml
modules/souveraine/navigation/WindowOverview.qml souveraine/modules/souveraine/navigation/WindowOverview.qml
modules/souveraine/navigation/ZoneOverview.qml souveraine/modules/souveraine/navigation/ZoneOverview.qml
modules/souveraine/navigation/qmldir souveraine/modules/souveraine/navigation/qmldir
modules/souveraine/boot/BootBloom.qml souveraine/modules/souveraine/boot/BootBloom.qml
modules/souveraine/boot/BootBloom.frag.qsb souveraine/modules/souveraine/boot/BootBloom.frag.qsb

View file

@ -31,6 +31,7 @@ import qs.modules.common.functions as CF
import Qt.labs.synchronizer
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
@ -79,7 +80,11 @@ Scope {
// panel sheet was added the mask must cover what panelBg
// covers, or the sheet renders clipped and its margin is dead
// input space.
item: panelBg
//
// Mission control covers the whole glass (the blurred home
// backdrop), so there it is the whole window or the cards take no
// taps at all.
item: GlobalStates.missionControlOpen ? null : panelBg
}
anchors {
@ -215,9 +220,46 @@ Scope {
// the wallpaper reads as a soft base behind it. Declared before the
// column (stacked below it) so nothing here can eat taps; it is
// input-transparent anyway.
// Multitasking sits on the home zone, blurred not on the app you came
// from, and not on nothing. MultiEffect (Qt6), not GaussianBlur: that
// one needs Qt5Compat and dies on the phone's GLES path (2026-08-05).
// The sheet under it is the known-good look if the effect no-ops.
Item {
id: missionBackdrop
visible: GlobalStates.missionControlOpen
anchors.fill: parent
z: -1
Image {
id: homeWall
anchors.fill: parent
source: Config.options.background.wallpaperPath ?? ""
fillMode: Image.PreserveAspectCrop
cache: true
asynchronous: true
visible: false
}
MultiEffect {
anchors.fill: parent
source: homeWall
visible: homeWall.status === Image.Ready
blurEnabled: true
blurMax: 64
blur: 1
saturation: -0.2
brightness: -0.25
}
Rectangle {
anchors.fill: parent
color: CF.ColorUtils.transparentize(Appearance?.colors?.colLayer0 ?? "#101010", 0.25)
}
}
Rectangle {
id: panelBg
visible: columnLayout.visible
visible: columnLayout.visible && !GlobalStates.missionControlOpen
anchors.fill: columnLayout
// The sheet breathes past the column so the drawer reads as a
// panel, not as widgets floating on the wallpaper. The mask
@ -291,16 +333,24 @@ Scope {
// of workspaces from HyprlandData; viewtop has neither, so it
// rendered an empty frame and read as the overview being
// broken. See WindowOverview.qml's header.
WindowOverview {
// ZoneOverview, not WindowOverview (TASK-60). The flat list
// of window cards drew the two halves of a split as unrelated
// things, and could not show a zone you were not on. A zone is
// the destination; its windows live inside its card.
ZoneOverview {
width: overviewLoader.parent.width
height: panelWindow.height * 0.78
visible: (panelWindow.searchingText == "")
onActivated: toplevel => {
toplevel?.activate();
onActivated: zone => {
// The compositor moves the canvas; the shell only asks.
// Going to a zone is not "activate a toplevel" that
// was the old model's verb and it could not express
// "this place, which happens to hold two windows".
ViewtopControl.zone(zone);
GlobalStates.overviewOpen = false;
GlobalStates.missionControlOpen = false;
}
onClosed: toplevel => toplevel?.close()
onClosed: id => ViewtopControl.close(id)
}
}

View file

@ -2,19 +2,30 @@
//
// This is deliberately part of the primary Souveraine shell, rather than a
// second `qs -c ` configuration. It owns the small bottom input region that
// stays available over fullscreen applications: an escalating upward gesture
// drives the dock and, pushed further, Mission Control; swipe down peels the
// nearest surface back; a double tap toggles fullscreen for the real active
// toplevel.
// stays available over fullscreen applications.
//
// The upward gesture is progressive, not a single threshold. As the drag
// climbs it crosses two stages first REVEAL (the dock shows), then MISSION
// (the process/task surface, GlobalStates.missionControlOpen). The handle
// tracks the drag live: it grows, brightens and lifts toward whichever stage
// the current travel has reached, so the pill reads as "on top" of the
// motion rather than a passive strip. Release commits the stage the drag
// last held; a flick past the mission line commits Mission Control even from
// a short-but-fast throw.
// **Two swipes, and nothing else.** Casey, 2026-08-05: *"there is only two
// swipe modes on it."* A short climb reaches multitasking; a long one past
// 18% of the panel reaches home. Swipe down peels the nearest surface back.
// There is no tap: Home used to be one, which made a third gesture compete for
// the strip a thumb rests on, and it could only be told apart from a double tap
// by making it wait 350 ms first. Both are gone, along with the double tap's
// `hyprctl` fullscreen (START-HERE §3).
//
// One exception, and it is the keyboard's: with an OSK up, a pull past a third
// detent further than home, where the handle squares off swaps the keyboard
// (TASK-38). It is deliberately beyond the others because a swap mid-sentence
// has to cost a deliberate reach, not a near miss.
//
// The upward gesture is progressive, not a single threshold, and the progress
// is *published* (`GlobalStates.zonePullProgress`) rather than kept: the real
// windows shrink through `pose` as the thumb climbs, and `ZoneOverview` enters
// against the same number, so the transition and the destination are one
// motion. The handle tracks it too it grows, brightens and lifts toward
// whichever stage the travel has reached, so the pill reads as "on top" of the
// motion rather than a passive strip. Release commits the stage the drag last
// held; a flick past the mission line commits multitasking even from a
// short-but-fast throw.
//
// Discovery: releasing short of REVEAL three times in a row (without ever
// finding Mission Control) nudges the handle with a brief pulse the first
@ -30,7 +41,6 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import Quickshell.Wayland
import qs
import qs.modules.common
@ -116,8 +126,18 @@ PanelWindow {
// same thing on the phone and on a laptop panel. 0.18 sits in the middle of
// what Casey named. The floor keeps it reachable if a tiny output ever
// makes the proportion smaller than the first detent.
//
// `screen.height`, not `QsWindow.window.height`. The window here is the
// rail itself a 32 px strip along the bottom so asking it for a height
// and taking 18% of it yielded 5.76, the floor won every time, and the
// "long swipe" detent sat at 108 px: a tenth of the panel, sixty pixels
// above the short one. Two destinations that far apart are one destination
// with a coin toss in front of it. The fallback is in *logical* pixels for
// the same reason this panel is 1080 logical tall and 2160 physical, and
// `mouse.y` arrives in the first of those. `.screen.height` is the tree's
// idiom for this (`SelectionHost`, `FullscreenPolkitWindow`).
readonly property int missionAt: Math.max(revealAt + 60,
Math.round((rail.QsWindow.window?.height ?? 2160) * 0.18))
Math.round((rail.screen?.height ?? 1080) * 0.18))
// TASK-38: one more detent PAST the mission/max stage, armed only while an
// OSK is up, that swaps stevia <-> squeekboard. stevia dropped its terminal
// layout in 0.56.0-9, so this gesture is the only way to reach a terminal
@ -211,31 +231,34 @@ PanelWindow {
anchors.fill: parent
property real startY: 0
property real startAt: -1
property real lastTapAt: -1
// Consecutive upward attempts that fell short of REVEAL without ever
// reaching Mission Control feeds the discovery nudge.
property int shortSwipes: 0
readonly property int tapSlop: 24
readonly property int doubleTapInterval: 350
// Fast throws commit the stage above their travel: a short-but-quick
// flick past this speed (px/ms) escalates one stage.
readonly property real flickSpeed: 1.1
function toggleFullscreen(): void {
GlobalStates.dockRevealed = false
const raw = Hyprland.activeToplevel?.address
if (!raw)
return
const address = raw.startsWith("0x") ? raw : "0x" + raw
Quickshell.execDetached(["hyprctl", "dispatch",
`hl.dsp.window.fullscreen({ window = "address:${address}", mode = "fullscreen", action = "toggle" })`])
}
// Double-tap used to toggle fullscreen through `hyprctl dispatch`, and
// has therefore done nothing since the viewtop move START-HERE §3's
// exact shape, and the rule there is that the fix is never to re-add
// the binding. viewtop tiles a zone to the whole display already and
// has no fullscreen intent in `wire`; a window that wants the glass to
// itself asks for a zone (`workspace.rs`: "a window that wants the
// whole display is asking not to share"). So this is deleted rather
// than ported, and the second tap is inert until there is a verb to
// point it at a gesture that fires nothing is better than one that
// fires something nobody chose.
//
// What it leaves behind: the single-tap Home path no longer needs to be
// delayed by a double-tap window. It still is, because the delay is
// also what lets a fast double-tap be distinguished at all, and TASK-55
// Q5 wants Home to stay predictable more than it wants it instant.
// A single tap is Home. Delay it by the double-tap window so the
// existing fullscreen gesture remains unambiguous; the second tap
// cancels this timer before toggling fullscreen.
function goHome(): void {
lastTapAt = -1
GlobalStates.missionControlOpen = false
GlobalStates.overviewOpen = false
GlobalStates.dockRevealed = false
@ -247,7 +270,19 @@ PanelWindow {
// a phone whose Home does nothing is one wrong gesture from being
// stuck. `workspace` is served on the control socket and takes the
// zone to go to.
ViewtopControl.zone(Config.options.navigation?.homeZone ?? 1)
//
// `ViewtopControl.homeZone`, not a config lookup. This read
// `Config.options.navigation?.homeZone ?? 1`, and there is no
// `navigation` block in `Config.qml` so it was always 1, while
// home is 0 (`workspace::HOME_ZONE`, and the same constant the
// dock's pinned rule and the overview's "Home" label both test
// against). Home therefore landed on the first *app* zone, and the
// dock whose whole new rule is "pinned on home" correctly
// refused to appear there. It looked like it worked because the
// compositor clamps `to` against `count - 1`, so with only home
// open, 1 becomes 0; it broke the moment a second zone existed.
// Casey's "zone one" is the strip's first zone, which is index 0.
ViewtopControl.zone(ViewtopControl.homeZone)
}
// Commit an upward gesture. `stage` is the stage the drag settled on
@ -267,18 +302,11 @@ PanelWindow {
// also leave it means the way out is a different gesture than the way
// in, which is the thing that makes a phone feel stuck.
function commitUp(stage: int): void {
singleTapTimer.stop()
lastTapAt = -1
if (stage >= 2) {
// Square one. `goHome` clears multitasking, the overview, the
// dock and the OSK before moving, so the zone is not arrived at
// with the last screen's furniture still up.
//
// The pose is released here rather than on arrival: home is a
// different zone, and a window left at 0.6 on the zone behind
// you is one you would find shrunken next time you swiped back
// to it, with no gesture in flight to explain why.
ViewtopControl.clearPose()
goHome()
shortSwipes = 0
return
@ -288,11 +316,6 @@ PanelWindow {
// belongs to zone one and is shown by being *there*, not by a
// gesture that reveals it over whatever else is on screen.
GlobalStates.missionControlOpen = !GlobalStates.missionControlOpen
// Multitasking draws its own cards over the scene, so the live
// windows go back to full size underneath it. Leaving them
// posed would mean two shrunken pictures of the same app
// the card and the window behind it.
ViewtopControl.clearPose()
if (GlobalStates.missionControlOpen
&& !Persistent.states.navigation.missionControlDiscovered)
Persistent.states.navigation.missionControlDiscovered = true
@ -314,25 +337,54 @@ PanelWindow {
// the handle doesn't chase a dismiss gesture.
rail.dragTravel = Math.max(0, startY - mouse.y)
// The app shrinks under the thumb. Casey, 2026-08-05: *"swipe up
// just a bit makes the app sorta scale and you fall into a multi
// tasking area"* and Phosh's home is the same shape, a drag
// surface travelling between two states rather than a button that
// teleports.
// The app shrinks under the thumb, and it is the *real* app.
// Casey, 2026-08-05: *"swipe up just a bit makes the app sorta
// scale and you fall into a multi tasking area"*, and on the
// question of pictures-vs-windows: *"real windows is nicer that's
// what we had on one of the previews and it worked great."* Phosh's
// `home.c` is the same shape, a drag surface travelling between two
// states rather than a button that teleports.
//
// This was deleted once, and deleting it was the wrong correction.
// What was wrong was that the scale had been mistaken for the
// *destination* there was nothing behind it but the zone you were
// already on, smaller. It is the transition, `ZoneOverview` is the
// destination, and TASK-60's acceptance wants them to share one
// progress rather than animate the same motion from two clocks. So
// the number is published, not kept.
//
// Scaled against `missionAt`, the multitasking detent, so the app
// has visibly become a card by the moment it would commit. It
// keeps shrinking past that toward home, which is what makes the
// long pull feel like a further degree of the same motion instead
// of a second, unrelated gesture.
// has visibly become a card by the moment it would commit. It keeps
// shrinking past that toward home, which makes the long pull read
// as a further degree of the same motion rather than a second,
// unrelated gesture.
//
// Nothing is scaled below 0.6: past that the window is a thumbnail
// and its own inverse-mapped touch targets stop being findable if
// the drag is abandoned there.
const progress = Math.min(1.0, rail.dragTravel / Math.max(1, rail.missionAt))
GlobalStates.zonePullProgress = progress
ViewtopControl.poseActiveZone(1.0 - 0.4 * progress)
}
// End an upward pull, one way or the other.
//
// `committed` means the multitasking view is taking over: it draws the
// zone the pose was shrinking, so leaving the pose on would put two
// shrunken pictures of the same app on the glass the card and the
// window behind it. Not committed means the gesture was abandoned and
// the windows have to spring back, or a half-swipe leaves a permanently
// smaller app with nothing on screen to explain it.
//
// Either way the pose is cleared, and the *reason* differs rather than
// the action. Home takes the same path: `goHome` moves the strip, and a
// window left at 0.6 on the zone behind you is one you would find
// shrunken next time you swiped back to it.
function endPull(committed: bool): void {
GlobalStates.zonePullProgress = 0
ViewtopControl.clearPose()
}
onReleased: mouse => {
const wasDragging = rail.dragging
rail.dragging = false
@ -352,8 +404,6 @@ PanelWindow {
// here, per INTERFACE-ARCHITECTURE §4 the binding stays data, so
// it can be re-pointed without editing this file.
if (GlobalStates.oskOpen && travel >= rail.swapAt) {
singleTapTimer.stop()
lastTapAt = -1
rail.swapping = true
swapSettle.restart()
// No re-probe across the swap. The incoming keyboard is a
@ -362,6 +412,7 @@ PanelWindow {
// the new exclusive zone lands, so the height nobody measures
// is the height that cannot be wrong.
Gestures.deliver("osk-swap", Gestures.oskSwapAction)
endPull(false)
return
}
@ -373,19 +424,20 @@ PanelWindow {
if (speed >= flickSpeed && stage < 2)
stage += 1
commitUp(stage)
endPull(true)
return
}
// A climb that did not reach the first detent. The app was shrinking
// under the thumb and has to spring back, or an abandoned gesture
// leaves a permanently smaller window and no way to say so.
// An abandoned climb: the app was shrinking under the thumb and
// has to spring back. Only ever after a drag *this rail started*
// geometry is the agent's outright (doctrine §13), and a rail that
// reset poses as a general safety net would be the shell overruling
// what she had composed because a finger moved.
if (wasDragging)
ViewtopControl.clearPose()
endPull(false)
// Downward: peel the nearest surface (keyboard, then dock).
if (deltaY >= rail.revealAt) {
singleTapTimer.stop()
lastTapAt = -1
if (GlobalStates.oskOpen) {
GlobalStates.oskOpen = false
return
@ -400,34 +452,16 @@ PanelWindow {
}
// Neither committed: an upward attempt that fell short of REVEAL
// counts toward the discovery nudge; anything else resets it.
if (travel > tapSlop) {
singleTapTimer.stop()
lastTapAt = -1
// counts toward the discovery nudge. Anything else a tap, a
// sideways smudge does nothing at all, on purpose. Casey,
// 2026-08-05: *"there is only two swipe modes on it."* The rail is
// two swipes and no taps: short up is multitasking, long up is
// home. A tap used to be a third way to reach Home and a double tap
// a fourth thing on the same 32 px strip, which is three gestures
// competing for the one region a thumb rests in and one of them
// could only resolve by making the other wait 350 ms first.
if (travel > tapSlop)
maybeNudgeDiscovery()
return
}
if (Math.abs(deltaY) > tapSlop) {
singleTapTimer.stop()
lastTapAt = -1
return
}
const now = Date.now()
if (lastTapAt > 0 && now - lastTapAt <= doubleTapInterval) {
singleTapTimer.stop()
lastTapAt = -1
toggleFullscreen()
} else {
lastTapAt = now
singleTapTimer.restart()
}
}
Timer {
id: singleTapTimer
interval: gestureArea.doubleTapInterval
onTriggered: gestureArea.goHome()
}
// Count a short upward attempt; after three in a row, and only until
@ -443,14 +477,13 @@ PanelWindow {
}
onCanceled: {
singleTapTimer.stop()
rail.dragging = false
rail.dragTravel = 0
lastTapAt = -1
// A cancel is the compositor taking the sequence away mid-drag
// the FTS controller does exactly this after a wake. The scale must
// still come home; nothing else will do it.
ViewtopControl.clearPose()
// the FTS controller does exactly this after a wake. The windows
// are really scaled at this point, so something has to bring them
// home; nothing else will.
endPull(false)
}
}
}

View file

@ -0,0 +1,343 @@
// Multitasking: every zone that has something on it, as one card each.
//
// TASK-60. This replaces a flat list of window cards, which was the wrong
// model and was told so on device Casey, 2026-08-05: *"it's supposed to be
// showing me all sorta backgrounded zones; including split zones. You've seen
// macOS. That whole multitasking view is a function of it's own."*
//
// ## Why a zone is the card, and a window is not
//
// viewtop's canvas is a strip of zones that are minted when a window needs
// somewhere to be and destroyed when the last one leaves (`workspace.rs`). A
// zone holding two tiled windows is **one place you can go**, not two things
// you can pick between and a flat window list said the opposite: it drew the
// halves of a split as two unrelated cards, and nothing on screen said they
// shared a destination. macOS's Mission Control is two levels for this reason,
// spaces above and windows within; on a phone there is room for one level at a
// time, so the zone wins and its windows are composited inside it.
//
// Home (`HOME_ZONE`) is always present and always empty. It is drawn as a card
// anyway, because "go back to square one" is the one destination that must be
// reachable from here even when nothing is open.
//
// ## Pictures, except the one you are looking at
//
// TASK-60 Q2 asked whether cards are live pictures or real posed windows, and
// the answer is *both, in their own half of the motion*. Casey, 2026-08-05:
// *"real windows is nicer or if the multitasking grid pictures all but the
// 'viewed/observed' one and as we scroll through the one we rest on can be
// live."*
//
// So: the **transition** is real windows. The rail shrinks them through `pose`
// as the thumb climbs (`SystemGestureRail`), which is the part that has to be
// the actual app because it is the app you are still holding.
//
// The **destination** is this, and here only the card you have come to rest on
// runs a live capture. Every other card holds a single frame. A live
// `ScreencopyView` is a render of that window every frame; N of them is N
// windows redrawing to fill a strip where you can only look at one. Casey's own
// framing *"why we're drawing ones not in 'frame'"* is also why
// `cacheBuffer` is 0: a card off the side of the screen is not drawn at all,
// not drawn cheaply.
//
// ## Where the facts come from
//
// The compositor, pushed (`ViewtopControl.subscribed`) or asked synchronously
// when this opens never from the poll that made the first version wrong. A
// view that opens stale is wrong at exactly the moment it is used, since the
// reason to open it is that something just changed.
//
// Cards are joined to their pictures by `app_id` + `title`, which the
// compositor reports beside each window. That is a bridge, not an identity:
// quickshell sees foreign-toplevel handles and viewtop sees `SurfaceId`, and
// the two namespaces have never met. Two terminals with the same title
// collide. The sound fix is a shared id in the protocol; this is enough to
// group cards and is deliberately written down as approximate rather than
// presented as correct.
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
Item {
id: root
signal activated(int zone)
// Carries the compositor's own id, not a toplevel handle. Closing is a
// scene verb like every other one the hand can reach (TASK-55), so it goes
// out the one door in `ViewtopControl` a second path would be a second
// thing that can refuse differently, log differently, and be missed by the
// trail when the agent is the one closing the window.
signal closed(int id)
// One entry per zone that exists, each carrying the windows on it.
// Rebuilt from `ViewtopControl.windows`, which is the compositor's answer
// rather than the shell's own bookkeeping there is one idea of what is
// where, and it is not this file's.
readonly property var zones: {
const byZone = {};
const count = Math.max(1, ViewtopControl.zoneCount);
for (let z = 0; z < count; z++)
byZone[z] = { zone: z, windows: [], focused: false };
for (const w of ViewtopControl.windows) {
if (byZone[w.workspace] === undefined)
byZone[w.workspace] = { zone: w.workspace, windows: [], focused: false };
byZone[w.workspace].windows.push(w);
if (w.focused)
byZone[w.workspace].focused = true;
}
const out = [];
for (const k in byZone)
out.push(byZone[k]);
out.sort((a, b) => a.zone - b.zone);
return out;
}
// The live toplevel whose app_id+title matches a reported window, so a
// card can show a picture of it. Null when nothing matches, which draws a
// placeholder rather than an empty rectangle an overview that renders
// nothing and one that is broken must not look the same.
//
// A window the compositor has not named at all cannot be joined, and must
// not be: with both fields absent this would match the first toplevel that
// also reports neither, and put one app's picture on every card. That is
// the state on a compositor older than the commit that added them, which is
// exactly when a wrong picture would be least explicable.
function toplevelFor(w) {
if (!w || (!w.app_id && !w.title))
return null;
const all = ToplevelManager.toplevels?.values ?? [];
for (const t of all) {
if ((t.appId || "") === (w.app_id || "") && (t.title || "") === (w.title || ""))
return t;
}
return null;
}
// One progress drives the whole surface the reference shell's trick, and
// Phosh's: state gates visibility, visibility never sets state.
//
// While the rail is still pulling, this *is* the rail's progress, so the
// cards grow at exactly the rate the real windows behind them are
// shrinking. Once the gesture commits the state flag takes over and the
// animation carries it the rest of the way. TASK-60's acceptance is the
// reason: *"no frame where a window is scaled by one and laid out by the
// other."*
readonly property bool open: GlobalStates.overviewOpen || GlobalStates.missionControlOpen
property real progress: root.open
? 1
: Math.min(1, GlobalStates.zonePullProgress)
Behavior on progress {
// Only the settle is animated. Following the thumb is not an animation
// and must not be smoothed, or the cards lag the fingers that are
// moving them.
enabled: root.open || GlobalStates.zonePullProgress === 0
NumberAnimation {
duration: 260
easing.type: Easing.OutCubic
}
}
// Ask the moment this becomes visible. Cheap, and the whole answer to why
// the previous version showed a second app as absent. Redundant when the
// compositor is pushing, and kept anyway: it costs one request and it is
// what makes the surface correct on a phone whose compositor predates the
// push channel.
onOpenChanged: {
if (root.open)
ViewtopControl.refreshWindows();
}
implicitWidth: parent ? parent.width : 540
implicitHeight: parent ? parent.height : 800
ListView {
id: list
anchors.fill: parent
anchors.margins: 12
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
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.
cacheBuffer: 0
// 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))
delegate: Item {
id: zoneCard
required property var modelData
required property int index
width: list.width
height: list.height
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
// one, runs live captures.
readonly property bool resting: zoneCard.index === list.currentIndex
// Staggered entry, capped at the fifth card so a long strip does
// not read as loading.
readonly property real share: {
const start = Math.min(zoneCard.index, 5) * 0.045;
return Math.max(0, Math.min(1, (root.progress - start) / (1 - start)));
}
opacity: zoneCard.share
// Starts at the scale the rail's `pose` had already reached (0.6 at
// the multitasking detent) and finishes at 1, so the card takes over
// the motion at the size the real window had got to rather than
// popping into a different one.
scale: 0.6 + 0.4 * zoneCard.share
Rectangle {
anchors.fill: parent
anchors.bottomMargin: 34
radius: 18
color: Appearance.colors.colLayer1
// 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.
border.width: zoneCard.isActive ? 2 : 1
border.color: zoneCard.isActive
? Appearance.colors.colOnLayer1
: Appearance.colors.colLayer1Active
clip: true
// The windows of this zone, laid out the way the compositor
// tiles them: one fills, two split top and bottom. Not a
// guess `placement.rs` splits on the short axis for a phone,
// and a preview that disagreed with the real layout would make
// the overview a picture of a desktop that does not exist.
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 8
visible: zoneCard.modelData.windows.length > 0
Repeater {
model: zoneCard.modelData.windows
delegate: Item {
id: pane
required property var modelData
Layout.fillWidth: true
Layout.fillHeight: true
readonly property var toplevel: root.toplevelFor(pane.modelData)
// A window that has left its zone's confinement.
// Said on the card because a float is precisely the
// window you can no longer find by remembering
// which zone you put it on.
readonly property bool floating: pane.modelData.floating === true
ScreencopyView {
id: shot
anchors.fill: parent
// Never captures behind a closed overview, and
// only *keeps* capturing on the card being
// looked at. The others hold the frame they
// arrived with, which is what a card off to the
// side is worth.
captureSource: root.progress > 0 ? pane.toplevel : null
live: root.progress > 0 && zoneCard.resting
visible: pane.toplevel !== null
}
// The join failed. Said out loud, because a blank
// card and a broken overview must not look alike.
StyledText {
anchors.centerIn: parent
visible: pane.toplevel === null
text: pane.modelData.app_id || qsTr("window")
opacity: 0.6
}
StyledText {
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.margins: 8
visible: pane.floating
text: qsTr("(float)")
opacity: 0.75
}
RippleButton {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 8
implicitWidth: 34
implicitHeight: 34
buttonRadius: 17
onClicked: root.closed(pane.modelData.id)
contentItem: MaterialSymbol {
anchors.centerIn: parent
text: "close"
iconSize: 18
}
}
}
}
}
// Home, and any zone that is empty. Home is empty on purpose
// it is the widget space so this is a destination, not a
// failure.
StyledText {
anchors.centerIn: parent
visible: zoneCard.modelData.windows.length === 0
text: zoneCard.isHome ? qsTr("Home") : qsTr("Empty")
opacity: 0.6
font.pixelSize: Appearance.font.pixelSize.large
}
// Tapping the card goes to that zone. The whole card, not just
// a picture inside it: the destination is the zone, so the
// target should be the thing that represents it.
MouseArea {
anchors.fill: parent
z: -1
onClicked: root.activated(zoneCard.modelData.zone)
}
}
StyledText {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 6
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
opacity: 0.85
text: {
if (zoneCard.isHome)
return qsTr("Home");
const n = zoneCard.modelData.windows.length;
if (n === 0)
return qsTr("Zone %1").arg(zoneCard.modelData.zone);
if (n === 1)
return zoneCard.modelData.windows[0].title
|| zoneCard.modelData.windows[0].app_id
|| qsTr("Zone %1").arg(zoneCard.modelData.zone);
// A split names itself as one place holding two things,
// which is the distinction this whole surface exists for.
return qsTr("Split · %1 windows").arg(n);
}
}
}
}
}

View file

@ -1,2 +1,3 @@
SystemGestureRail 1.0 SystemGestureRail.qml
WindowOverview 1.0 WindowOverview.qml
ZoneOverview 1.0 ZoneOverview.qml

View file

@ -44,6 +44,19 @@ Singleton {
property var windows: []
signal windowsChanged_()
// The serialised form of the last canvas we published, so an unchanged
// answer is not republished.
//
// This is not a micro-optimisation, it is a correctness fix. `windows` is a
// `var` holding a fresh array on every reply, so assigning it fires
// `windowsChanged` whether or not anything changed. A surface that binds a
// ListView model to it therefore had its entire delegate tree and every
// `ScreencopyView` inside it destroyed and rebuilt on the poll interval,
// which is a view that flickers and loses its place while you are reading
// it. Republish on *difference*, the same level-not-edge discipline
// `lockhint.rs` applies to `LockedHint`.
property string _lastCanvas: ""
// Which zone is in front, as the compositor last reported it. -1 is
// "not asked yet" and is deliberately not 0: home is 0, so defaulting to it
// would make every surface believe it was on home before the first reply.
@ -59,37 +72,156 @@ Singleton {
root._send({ op: "workspaces" });
}
// The zone is polled rather than pushed: the compositor has no subscription
// for it yet, and a surface that reads a stale zone shows the wrong
// furniture. Two seconds is slow enough to be free and fast enough that the
// dock does not visibly lag a zone change; a push channel would replace
// this and should.
// True once the compositor has accepted a `subscribe` and is pushing canvas
// changes down the second socket below.
property bool subscribed: false
// The poll, which now exists only as the fallback for a compositor too old
// to push.
//
// TASK-60 Q4: *"Either the compositor pushes zone/window changes, or this
// surface asks synchronously when it opens and stops guessing in between."*
// Polling was the proximate cause of the multitasking view scaling the
// wrong windows a two-second answer is wrong for the whole of every
// gesture, and a gesture is exactly when something asks. The push channel
// is the answer; this stays because the phone can be running a compositor
// that predates it, and a shell that hard-depends on an op the running
// compositor does not serve is a shell that breaks on the deploy ordering
// TASK-28 is made of.
Timer {
interval: 2000
running: true
running: !root.subscribed
repeat: true
triggeredOnStart: true
onTriggered: root.refreshWindows()
}
// Take one reply's canvas facts, whether it arrived as an answer or as a
// push. Both carry the same shape by construction the compositor
// serialises the `workspaces` payload once and uses it for both so there
// is one reader here rather than two that can drift.
function _ingest(reply) {
if (reply.windows !== undefined) {
// Compare before publishing. See `_lastCanvas`.
const encoded = JSON.stringify(reply.windows);
if (encoded !== root._lastCanvas) {
root._lastCanvas = encoded;
root.windows = reply.windows;
root.windowsChanged_();
}
}
// `active` is an index and 0 is a real, meaningful value it is home
// so this must test for presence, not truthiness. `if (reply.active)`
// would silently ignore every report that we are on home, which is the
// one zone anything here cares about.
if (reply.active !== undefined)
root.activeZone = reply.active;
if (reply.count !== undefined)
root.zoneCount = reply.count;
}
// The push channel: a second, long-lived connection that carries canvas
// changes as they happen.
//
// Separate from the request socket on purpose. viewtop answers one request
// per connection and then closes; a subscription is the opposite shape it
// is written to, never read from, and outlives every request. Multiplexing
// both onto one socket would mean interleaving a push into the middle of
// somebody's reply, which is how a request/response client learns to
// distrust its own parser.
Socket {
id: feed
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/viewtop.sock"
connected: true
onConnectionStateChanged: {
if (connected) {
feed.write(JSON.stringify({ op: "subscribe" }) + "\n");
} else {
// Either the compositor went away or it never served the op.
// Both mean the poll is the truth again until we get back in.
root.subscribed = false;
resubscribe.restart();
}
}
parser: SplitParser {
splitMarker: "\n"
onRead: message => {
let reply;
try {
reply = JSON.parse(message);
} catch (e) {
console.error("[viewtop-control] unparseable push: " + message);
return;
}
if (reply.ok === false) {
// A compositor that does not serve `subscribe` says so with
// a code, which is the whole point of the codes. Stop
// asking, say it once, and let the poll carry it this is
// the ordinary state of a phone between a shell update and
// the compositor package that follows it.
console.log("[viewtop-control] no push channel ("
+ (reply.code || "refused")
+ "); falling back to the 2 s poll");
root.subscribed = false;
resubscribe.stop();
feed.connected = false;
return;
}
root.subscribed = true;
root._ingest(reply);
}
}
}
// Reconnect the feed after the compositor restarts. A session restart is
// the ordinary case: the shell outlives individual compositor runs, and a
// subscription that never came back would leave every surface reading a
// canvas frozen at the moment of the crash.
Timer {
id: resubscribe
interval: 5000
repeat: false
onTriggered: if (!root.subscribed) feed.connected = true
}
// Requests waiting on a connection. A queue rather than SessiondPolicy's
// single slot: the sheet can fire two verbs in a row (kill after a close
// that was refused), and dropping the second would be silent.
property var _queue: []
property var _inflight: null
// Whether the in-flight request was answered before the socket closed.
// The compositor serves exactly one request per connection and then hangs
// up, so a disconnect is the *normal* end of every exchange and telling
// that apart from a compositor that died mid-request is the difference
// between a silent success and a spurious "socket unavailable".
property bool _replied: false
function _send(msg) {
root._queue.push(msg);
if (sock.connected)
root._drain();
else
sock.connected = true;
root._pump();
}
function _drain() {
// One request per connection, because that is what the other end serves.
//
// This used to write the whole queue down a single socket, which worked for
// exactly one request: `handle()` in `control.rs` reads one line, answers,
// and drops the stream. The second verb of any pair `kill` after a
// refused `close`, the one case the queue exists for was written into a
// socket that had already been closed, and surfaced as a refusal of a verb
// that was never delivered. So the connection is re-established per
// request, and a close with nothing in flight is silence rather than an
// error.
function _pump() {
if (root._inflight !== null || root._queue.length === 0)
return;
if (!sock.connected) {
sock.connected = true;
return;
}
root._inflight = root._queue.shift();
root._replied = false;
sock.write(JSON.stringify(root._inflight) + "\n");
}
@ -131,6 +263,27 @@ Singleton {
root.scene("unplace", { id: id });
}
// Let a window out of its zone.
//
// TASK-60 Q6. A zone tiles what is on it, which is right for the thing you
// are doing and wrong for the thing you are keeping a video that should
// survive going somewhere else, a call, anything picture-in-picture. Those
// want to leave the zone's confinement rather than take a half of it.
//
// Distinct from `place`: a placed window is still the zone's, put somewhere
// specific in it. A floated one has stopped being the zone's business, so
// it is not counted when the strip decides whether a zone still has
// anything on it. That is also why it must be visible on a card a float
// is a window you can no longer find by remembering which zone you left it
// on.
function float(id) {
root.scene("float", { id: id });
}
function unfloat(id) {
root.scene("unfloat", { id: id });
}
// Compose a window's visual geometry the verb that makes a live app a
// scaled, floating, still-touchable thing rather than a picture of one.
// The compositor maps input back through the inverse, so a shrunken window
@ -215,24 +368,37 @@ Singleton {
onConnectionStateChanged: {
if (connected) {
root._drain();
} else if (root._inflight !== null || root._queue.length > 0) {
const intent = (root._inflight && root._inflight.intent) || "?";
root.lastError = "viewtop socket unavailable";
console.error("[viewtop-control] could not reach the compositor at "
+ sock.path + " — the scene was NOT changed");
root._inflight = null;
root._queue = [];
root.refused(intent, root.lastError);
root._pump();
return;
}
// Answered, then hung up: the exchange completed. Carry on with
// whatever is behind it.
if (root._inflight === null && root._replied) {
root._pump();
return;
}
// Nothing was in flight and nothing is waiting an idle socket
// closing is not news.
if (root._inflight === null && root._queue.length === 0)
return;
const intent = (root._inflight && root._inflight.intent)
|| (root._inflight && root._inflight.op) || "?";
root.lastError = "viewtop socket unavailable";
console.error("[viewtop-control] could not reach the compositor at "
+ sock.path + " — the scene was NOT changed");
root._inflight = null;
root._queue = [];
root.refused(intent, root.lastError);
}
parser: SplitParser {
splitMarker: "\n"
onRead: message => {
const sent = root._inflight;
const intent = (sent && sent.intent) || "?";
const intent = (sent && sent.intent) || (sent && sent.op) || "?";
root._inflight = null;
root._replied = true;
let reply;
try {
@ -241,7 +407,7 @@ Singleton {
console.error("[viewtop-control] unparseable reply: " + message);
root.lastError = "unparseable reply";
root.refused(intent, root.lastError);
root._drain();
root._pump();
return;
}
@ -259,22 +425,10 @@ Singleton {
// A `workspaces` reply carries the canvas rather than a
// verb's outcome. Captured here so a chooser has something
// real to list instead of a guess at what is open.
if (reply.windows !== undefined) {
root.windows = reply.windows;
root.windowsChanged_();
}
// `active` is an index and 0 is a real, meaningful value
// it is home so this must test for presence, not
// truthiness. `if (reply.active)` would silently ignore
// every report that we are on home, which is the one zone
// anything here cares about.
if (reply.active !== undefined)
root.activeZone = reply.active;
if (reply.count !== undefined)
root.zoneCount = reply.count;
root._ingest(reply);
root.succeeded(intent);
}
root._drain();
root._pump();
}
}
}