aboutsummaryrefslogtreecommitdiffstats
path: root/window-switcher/Switcher.qml
blob: 960cb5818fa83eee253b89f97d47ebbe40aaa676 (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
209
// 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

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: "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 {
                id: keyHandler
                anchors.fill: parent
                focus: true

                // The LazyLoader destroys the overlay when it closes and
                // builds a new one on the next opening, verified by a build
                // counter: two openings, two constructions. So this starts at
                // 0 every time on its own, and there is nothing to reset. A
                // Component.onCompleted assigning 0 here would be a no-op.
                property int selectedIndex: 0

                function commit() {
                    const list = Windows.windows;
                    if (selectedIndex >= 0 && selectedIndex < list.length)
                        root.focusAfterClose(list[selectedIndex]);
                }

                function move(delta) {
                    const n = Windows.windows.length;
                    if (n === 0) return;
                    selectedIndex = (selectedIndex + delta + n) % n;
                }

                Keys.onEscapePressed: root.close()
                Keys.onReturnPressed: commit()
                Keys.onEnterPressed: commit()
                Keys.onLeftPressed: move(-1)
                Keys.onRightPressed: move(1)
                Keys.onUpPressed: move(-grid.columns)
                Keys.onDownPressed: move(grid.columns)
                Keys.onTabPressed: move(1)
                Keys.onBacktabPressed: move(-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
                        }
                    }

                    Grid {
                        id: grid
                        anchors.centerIn: parent
                        visible: Windows.windows.length > 0

                        readonly property int count: Windows.windows.length
                        readonly property int gap: 30
                        readonly property int maxCardWidth: 640

                        // Columns are the window count capped at six, so one
                        // card sits dead centre and two straddle the middle.
                        columns: Math.max(1, Math.min(count, 6))

                        // Cards grow to fill their row, up to a ceiling.
                        // Without the ceiling a lone preview becomes a
                        // full-screen mirror of the window it stands for.
                        readonly property int rows: Math.ceil(count / columns)

                        // The three text lines and the spacing above them,
                        // which the box height has to leave room for.
                        readonly property int textHeight: 54

                        // Clamped by the height as well as the width. Without
                        // the second term a busy desktop pushes rows off the
                        // top and bottom, and a centred Grid has no way to
                        // scroll to them, so the cards shrink instead.
                        readonly property int cardWidth: Math.min(
                            maxCardWidth,
                            Math.floor((parent.width * 0.92 - (columns - 1) * gap) / columns),
                            Math.floor(((parent.height * 0.92 - (rows - 1) * gap) / rows - textHeight) * 16 / 10))

                        spacing: gap

                        Repeater {
                            model: Windows.windows

                            WindowCard {
                                required property int index
                                required property var modelData

                                toplevel: modelData
                                cardWidth: grid.cardWidth
                                selected: index === keyHandler.selectedIndex

                                onActivated: {
                                    keyHandler.selectedIndex = index;
                                    keyHandler.commit();
                                }
                                // 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)
                            }
                        }
                    }
                }
            }
        }
    }
}