two swipes, one progress; home is zone 0; pocket is a belief
This commit is contained in:
parent
6c0730a790
commit
d7965a57fd
188 changed files with 9258 additions and 3970 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
SystemGestureRail 1.0 SystemGestureRail.qml
|
||||
WindowOverview 1.0 WindowOverview.qml
|
||||
ZoneOverview 1.0 ZoneOverview.qml
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue