Watch
1
0
Fork
You've already forked souveraine
0

shell: the overview shows windows, because there are no workspaces here

OverviewWidget draws a grid of workspaces and places windows in them by
Hyprland coordinates — HyprlandData.windowList, monitorData,
Hyprland.monitorFor. Under viewtop none of it resolves, and more to the
point viewtop has no workspaces at all: one space, a tiling layout. The
old overview could not be repaired by repointing it at another data
source, because the thing it draws does not exist. It rendered an empty
frame, which reads as the overview being broken.

WindowOverview lists what does exist. Cards from
ToplevelManager.toplevels — the same list the dock and TaskbarApps
already read, so there is one idea of what is open — each holding a
live ScreencopyView of one window, which only became possible when the
compositor started serving a per-window capture source; until then a
card could have shown nothing but the screen it was covering.

The motion is the reference shell's, read out of
overview_window_card.dart rather than invented: stagger at index * 45 ms
capped at the fifth card, intro scale 0.86 to 1.0, dismiss past 32% of
the card height, 56 px of downward rubber-banding, settle under 200 ms.
The cap is the part worth keeping — without it the tenth card starts
half a second after the first and the overview feels like it is loading
rather than opening.

Captures run only while the overview is up. A live capture per window is
a render of that window every frame, and leaving them going behind a
closed overview is battery spent drawing what nobody can see.
This commit is contained in:
Fimeg 2026-08-03 18:37:56 -04:00
commit fdb0acfdce
3 changed files with 247 additions and 2 deletions

View file

@ -29,6 +29,7 @@ import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Hyprland
import qs.modules.souveraine.navigation
Scope {
id: overviewScope
@ -160,9 +161,19 @@ Scope {
id: overviewLoader
anchors.horizontalCenter: parent.horizontalCenter
active: GlobalStates.overviewOpen && (Config?.options.overview.enable ?? true)
sourceComponent: OverviewWidget {
screen: panelWindow.screen
// WindowOverview, not OverviewWidget. The latter draws a grid
// 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.
sourceComponent: WindowOverview {
width: overviewLoader.parent.width
height: panelWindow.height * 0.7
visible: (panelWindow.searchingText == "")
onActivated: toplevel => {
toplevel?.activate();
GlobalStates.overviewOpen = false;
}
onClosed: toplevel => toplevel?.close()
}
}
}

View file

@ -0,0 +1,233 @@
// The window overview: every open window as a card, scaled down.
//
// WHY THIS REPLACES THE WORKSPACE GRID. `ii-base/modules/ii/overview/
// OverviewWidget.qml` draws a grid of *workspaces* and places windows inside
// them by Hyprland coordinates `HyprlandData.windowList`, `monitorData`,
// `Hyprland.monitorFor`. Under viewtop none of that resolves: HyprlandData
// cannot parse a compositor that is not Hyprland (the shell logs exactly that
// on every refresh), and more fundamentally **viewtop has no workspaces at
// all**. There is one space and a tiling layout. So the old overview cannot be
// repaired by pointing it at a different data source; the thing it draws does
// not exist here.
//
// What does exist is windows, and the reference Flutter shell's overview is
// built on exactly that: equal-sized cards, each holding a live texture of one
// window, flick-up to dismiss. Its layout search tries every column count for
// the largest cards that fit on its landscape target that spreads wide, and
// on our 540x1080 portrait the same search collapses to one column of
// full-width cards, which is what this lays out directly rather than
// rediscovering by search.
//
// The previews are `ScreencopyView` against a `Toplevel`, which needs the
// compositor to serve a per-window capture source. viewtop did not until
// 2026-08-03 only whole-output capture which is why a card could never have
// shown anything but the screen it was covering.
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs
import qs.modules.common
import qs.modules.common.widgets
Item {
id: root
// Newest first. `ToplevelManager.toplevels.values` is the same list the
// dock and TaskbarApps already read, so there is one idea of what is open.
readonly property var windows: {
const all = ToplevelManager.toplevels?.values ?? [];
return all.slice().reverse();
}
readonly property int count: windows.length
signal activated(var toplevel)
signal closed(var toplevel)
implicitWidth: parent ? parent.width : 540
implicitHeight: parent ? parent.height : 800
// Nothing open. Said plainly rather than rendering an empty frame, because
// an overview that draws nothing and an overview that is broken look
// identical which is the failure this whole surface was in.
StyledText {
anchors.centerIn: parent
visible: root.count === 0
text: qsTr("No open windows")
opacity: 0.6
}
ListView {
id: list
anchors.fill: parent
anchors.margins: 12
visible: root.count > 0
model: root.windows
spacing: 12
clip: true
// Cards are tall; a phone shows one and a bit at a time and flicks
// between them.
cacheBuffer: height * 2
delegate: Item {
id: card
required property var modelData
required property int index
// --- Entry, dismissal and parallax --------------------------------
//
// The numbers are the reference shell's, read out of
// `overview_window_card.dart` rather than guessed, because they are
// the part that took someone a long time to get right:
//
// stagger index * 0.045 of the run, capped at the 5th card
// intro scale 0.86 -> 1.0
// dismiss at 32% of the card's height
// fling -760 px/s
// rubber band 56 px downward, past which it stops following
// settle 90..240 ms
//
// The cap matters: without it the tenth card starts its entry half
// a second after the first, and the overview feels like it is
// loading rather than opening.
property real dismissY: 0
property bool dismissed: false
readonly property real dismissDistance: height * 0.32
readonly property real introDelay: Math.min(index, 4) * 45
opacity: dismissed ? 0 : 1 - Math.min(1, Math.abs(dismissY) / (height * 0.8))
transform: Translate { y: card.dismissY }
scale: 0.86
Component.onCompleted: introTimer.start()
Timer {
id: introTimer
interval: card.introDelay
onTriggered: card.scale = 1.0
}
Behavior on scale {
NumberAnimation { duration: 220; easing.type: Easing.OutCubic }
}
Behavior on opacity {
NumberAnimation { duration: 160 }
}
NumberAnimation {
id: settle
target: card
property: "dismissY"
to: 0
duration: 180
easing.type: Easing.OutCubic
}
NumberAnimation {
id: fling
target: card
property: "dismissY"
to: -card.height * 1.2
duration: 200
easing.type: Easing.InCubic
onFinished: {
card.dismissed = true;
root.closed(card.modelData);
}
}
width: list.width
// 16:9 of the card's own width, plus the label strip. The reference
// shell reserves 44 for its title; this uses the same idea with the
// shell's own text metrics rather than a copied constant.
height: Math.round(list.width * 0.62) + label.height + 8
Rectangle {
anchors.fill: parent
radius: Appearance?.rounding?.normal ?? 12
color: Appearance?.colors?.colLayer1 ?? "#1e1e24"
border.width: 1
border.color: modelData?.activated
? (Appearance?.colors?.colPrimary ?? "#9ec5ff")
: "transparent"
}
ScreencopyView {
id: preview
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 6
height: card.height - label.height - 14
// Only while the overview is up. A live capture per window is
// a render of that window every frame, and leaving them running
// behind a closed overview is the battery cost of drawing
// things nobody can see.
captureSource: GlobalStates.overviewOpen ? card.modelData : null
live: GlobalStates.overviewOpen
// The preview is the target: tapping the picture of a window is
// how you go to it.
MouseArea {
anchors.fill: parent
// Vertical drag dismisses, tap activates. Threshold left to
// the platform so it agrees with every other scroller.
property real pressY: 0
property bool dragging: false
onPressed: mouse => {
pressY = mouse.y;
dragging = false;
}
onPositionChanged: mouse => {
const dy = mouse.y - pressY;
if (!dragging && Math.abs(dy) > 8)
dragging = true;
if (!dragging)
return;
// Follows the finger upward; downward it rubber-bands
// and stops at 56, so a card cannot be dragged into the
// one below it.
card.dismissY = dy < 0 ? dy : Math.min(56, dy * 0.4);
}
onReleased: {
if (!dragging) {
root.activated(card.modelData);
return;
}
if (card.dismissY < -card.dismissDistance)
fling.start();
else
settle.start();
}
onCanceled: settle.start()
}
}
StyledText {
id: label
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 8
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
text: card.modelData?.title || card.modelData?.appId || qsTr("Window")
opacity: 0.85
}
// Close, on the card rather than behind a gesture. The reference
// shell flicks a card up to dismiss; that wants a drag controller
// and an animation budget, and a visible target is the thing that
// works on the first try.
RippleButton {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 10
implicitWidth: 34
implicitHeight: 34
buttonRadius: 17
onClicked: root.closed(card.modelData)
contentItem: MaterialSymbol {
anchors.centerIn: parent
text: "close"
iconSize: 18
}
}
}
}
}

View file

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