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:
parent
c21bd1d7d9
commit
fdb0acfdce
3 changed files with 247 additions and 2 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
SystemGestureRail 1.0 SystemGestureRail.qml
|
||||
WindowOverview 1.0 WindowOverview.qml
|
||||
|
|
|
|||
Loading…
Reference in a new issue