aboutsummaryrefslogtreecommitdiffstats
path: root/window-switcher/Switcher.qml
blob: e6eb792554b696e5d715e917ab40e7fadc9b3bba (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
// 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.
            // Escape is the only key: the expose is a pointer feature.
            Item {
                anchors.fill: parent
                focus: true
                Keys.onEscapePressed: root.close()

                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.
                        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)

                        // 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 } }

                        Repeater {
                            model: 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

                                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)
                            }
                        }
                    }
                }
            }
        }
    }
}