aboutsummaryrefslogtreecommitdiffstats
path: root/window-switcher/Switcher.qml
blob: 1eebfa8fb4e40ee5286214af3f4d71e03600cd1b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 2 as
// published by the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.

import Quickshell
import Quickshell.Wayland
import QtQuick
import "Expose.js" as Expose

Scope {
    id: root

    property bool open: false
    property string monitor: "DP-1"

    readonly property var screenObj:
        Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]

    function toggle() { root.open = !root.open; }
    function close() { root.open = false; }

    // Hyprland.toplevels reads empty until it is asked, and a window opened
    // since the last showing would otherwise be missing from the grid.
    onOpenChanged: if (open) Windows.refresh();

    // A focus dispatched while the overlay is up is accepted and then ignored:
    // the layer surface holds keyboard focus exclusively, and the compositor
    // will not move window focus out from under that grab. Closing in the same
    // turn does not help either, because close() only clears a property and the
    // surface survives until the frame after. So the target is remembered and
    // focused once the grab is really gone.
    property var pendingFocus: null

    function focusAfterClose(toplevel) {
        root.pendingFocus = toplevel;
        root.close();
        focusTimer.restart();
    }

    Timer {
        id: focusTimer
        interval: 60
        onTriggered: {
            if (root.pendingFocus) Windows.focus(root.pendingFocus);
            root.pendingFocus = null;
        }
    }

    // A config with no visible window exits, reporting nothing. This 1x1
    // click-through window is what holds the shell open while the overlay
    // is hidden, which is almost always.
    PanelWindow {
        anchors { top: true; left: true }
        implicitWidth: 1
        implicitHeight: 1
        color: "transparent"
        exclusionMode: ExclusionMode.Ignore
        mask: Region {}
        WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
    }

    LazyLoader {
        active: root.open

        PanelWindow {
            id: win
            screen: root.screenObj
            anchors { top: true; left: true; right: true; bottom: true }
            color: "transparent"
            exclusionMode: ExclusionMode.Ignore
            WlrLayershell.layer: WlrLayer.Overlay
            WlrLayershell.namespace: "keystone-window-switcher"
            WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive

            // Keys reach a focused item, not a window: Keys.onEscapePressed on
            // the PanelWindow above never fires. This item is what listens.
            Item {
                anchors.fill: parent
                focus: true
                Keys.onEscapePressed: root.close()
                Keys.onReturnPressed: if (scatter.selected) root.focusAfterClose(scatter.selected)
                Keys.onEnterPressed: if (scatter.selected) root.focusAfterClose(scatter.selected)
                Keys.onLeftPressed: scatter.move(-1, 0)
                Keys.onRightPressed: scatter.move(1, 0)
                Keys.onUpPressed: scatter.move(0, -1)
                Keys.onDownPressed: scatter.move(0, 1)

                Rectangle {
                    anchors.fill: parent
                    color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.82)

                    MouseArea {
                        anchors.fill: parent
                        onClicked: root.close()
                    }

                    // Nothing to switch to. A full-screen dim with nothing in
                    // it reads as a hang, so it says so instead.
                    Column {
                        anchors.centerIn: parent
                        spacing: 14
                        visible: Windows.windows.length === 0

                        Text {
                            anchors.horizontalCenter: parent.horizontalCenter
                            text: "Nothing here but hopes and dreams..."
                            color: Theme.text
                            font.family: Theme.fontFamily
                            font.pixelSize: 26
                        }
                        Text {
                            anchors.horizontalCenter: parent.horizontalCenter
                            text: "go create something beautiful!"
                            color: Theme.overlay
                            font.family: Theme.fontFamily
                            font.pixelSize: 32
                        }
                    }

                    // The scatter. Recomputed whenever the window list or a
                    // window's size changes, so closing one moves the rest.
                    Item {
                        id: scatter
                        anchors.fill: parent

                        // Real sizes come from the raw hyprctl object. A window
                        // that has none yet stands in at 1280x800 until it does.
                        // The bottom 28px are kept free for the lowest card's
                        // app name, which hangs below its preview.
                        readonly property var rects: Expose.layout(
                            Windows.windows.map(t => {
                                const o = t.lastIpcObject;
                                const s = o && o.size;
                                return s ? { w: s[0], h: s[1] } : { w: 1280, h: 800 };
                            }), width, height - 28)

                        // ponytail: a fade, not a flight from each window's real
                        // position; add the flight if the fade feels abrupt.
                        opacity: 0
                        Component.onCompleted: opacity = 1
                        Behavior on opacity { NumberAnimation { duration: 150 } }

                        // The keyboard selection, held as the window rather
                        // than an index, so a close that shifts the list
                        // does not move it onto a neighbour. Hovering a card
                        // selects it too, so pointer and keys never show two.
                        property var selected: null
                        readonly property int selectedIndex: Windows.windows.indexOf(selected)

                        function move(dx, dy) {
                            const i = Expose.step(rects, selectedIndex, dx, dy);
                            if (i >= 0) selected = Windows.windows[i];
                        }

                        // Cards glide to their new rects when the scatter
                        // changes, but not while it fades in, or every card
                        // would fly in from the corner on open.
                        property bool settled: false
                        onOpacityChanged: if (opacity === 1) settled = true

                        // A plain array model would rebuild every card on any
                        // change, leaving nothing to animate. ScriptModel diffs
                        // by identity, so a closed window removes one delegate
                        // and the rest keep theirs.
                        Repeater {
                            model: ScriptModel { values: Windows.windows }

                            WindowCard {
                                required property int index
                                required property var modelData

                                readonly property var rect: scatter.rects[index]
                                    ?? { x: 0, y: 0, w: 0, h: 0 }

                                toplevel: modelData
                                x: rect.x
                                y: rect.y
                                width: rect.w
                                height: rect.h

                                Behavior on x { enabled: scatter.settled; NumberAnimation { duration: 250; easing.type: Easing.OutCubic } }
                                Behavior on y { enabled: scatter.settled; NumberAnimation { duration: 250; easing.type: Easing.OutCubic } }
                                Behavior on width { enabled: scatter.settled; NumberAnimation { duration: 250; easing.type: Easing.OutCubic } }
                                Behavior on height { enabled: scatter.settled; NumberAnimation { duration: 250; easing.type: Easing.OutCubic } }

                                selected: scatter.selected === modelData
                                onHoveredChanged: if (hovered) scatter.selected = modelData
                                onActivated: root.focusAfterClose(modelData)
                                // Closing is a side errand, so the overlay
                                // stays open: a dismissal would mean reopening
                                // to close a second window. The card goes when
                                // Hyprland says it went.
                                onCloseRequested: Windows.closeWindow(modelData)
                            }
                        }
                    }
                }
            }
        }
    }
}