aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/Drawer.qml
blob: f3b8e6dda1ba7c5e0c6d90a179c15a1b9509cdeb (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
// 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

    // The modules the drawer hosts, in grid order. Set from shell.qml.
    property list<QtObject> modules

    // Waybar runs on this screen and the launcher sits at its left end, so
    // the drawer belongs here. Falls back to the first screen when this
    // monitor is not connected, so the drawer is never invisible.
    property string monitor: "DP-1"

    property bool open: false

    // Which module's page is showing. Empty means the grid.
    property string page: ""

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

    // A plain `list<QtObject>` is indexed directly; it is not an
    // ObjectModel, so there is no `.values` to go through.
    readonly property QtObject current: {
        for (let i = 0; i < root.modules.length; i++)
            if (root.modules[i].name === root.page) return root.modules[i];
        return null;
    }

    function show(name) {
        root.page = root.modules.some(m => m.name === name) ? name : "";
        root.open = true;
    }

    function close() {
        root.open = false;
        // Reset to the grid: a panel that reopens somewhere unexpected is
        // worse than one extra click.
        root.page = "";
    }

    function toggle(name) {
        if (root.open && (name ?? "") === root.page) root.close();
        else root.show(name);
    }

    // Clicking a tile: a module with a page opens it, one without acts.
    function activate(mod) {
        if (mod.page) root.page = mod.name;
        else { mod.activate(); root.close(); }
    }

    LazyLoader {
        active: root.open

        PanelWindow {
            id: win
            screen: root.screenObj

            anchors { top: true; left: true; right: true; bottom: true }
            color: "transparent"

            // Normal, not Ignore: waybar claims an exclusive zone at the top
            // of this screen, so respecting it puts the drawer below the bar
            // without this file knowing the bar's height. The drawer is
            // reached from the bar, so the bar must stay visible and
            // clickable while it is open.
            exclusionMode: ExclusionMode.Normal

            WlrLayershell.layer: WlrLayer.Overlay
            WlrLayershell.namespace: "quickshell-desktop"
            WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive

            // The click-outside catcher. It covers the whole surface, and the
            // drawer sits on top of it swallowing its own clicks.
            MouseArea {
                anchors.fill: parent
                onClicked: root.close()
            }

            // Keys reach a focused item, never the window: setting
            // keyboardFocus above is necessary but not sufficient, and
            // Keys.onEscapePressed on a PanelWindow never fires. See AGENTS.md.
            Rectangle {
                id: panel
                anchors { top: parent.top; left: parent.left; bottom: parent.bottom }
                width: 600
                color: Qt.alpha(Theme.base, 0.72)
                topRightRadius: 14
                bottomRightRadius: 14
                border.width: 1
                border.color: Qt.alpha(Theme.text, 0.12)

                // The panel is that focused item. As the ancestor of the page
                // content, a key pressed in any focused descendant bubbles up
                // to it.
                focus: true
                Keys.onEscapePressed: {
                    if (root.page) root.page = "";
                    else root.close();
                }

                // Clicks on the panel must not reach the catcher behind it.
                MouseArea { anchors.fill: parent }

                // --- grid view ---

                Item {
                    anchors.fill: parent
                    anchors.margins: 16
                    visible: root.page === ""

                    // Reserved for the notification engine. An empty Item that
                    // claims the space rather than a placeholder graphic: the
                    // grid has to sit where it will sit once notifications
                    // arrive, or the layout is tuned against a position that
                    // does not survive.
                    Item {
                        id: notifications
                        anchors { top: parent.top; left: parent.left; right: parent.right }
                        anchors.bottom: grid.top
                        anchors.bottomMargin: 16
                    }

                    // Fixed, never scrolled. Three columns at 600px with a
                    // 180px minimum; tiles wrap and add rows, ceiling 3x3.
                    Flow {
                        id: grid
                        anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
                        spacing: 10

                        Repeater {
                            model: root.modules

                            Tile {
                                required property QtObject modelData
                                // Three columns, or fewer if the panel is
                                // narrower than three 180px tiles allow.
                                width: (grid.width - 2 * grid.spacing) / 3
                                icon: modelData.icon
                                label: modelData.label
                                content: modelData.tileContent
                                onClicked: root.activate(modelData)
                            }
                        }
                    }
                }

                // --- page view ---

                Loader {
                    id: pageLoader
                    anchors.fill: parent
                    anchors.margins: 16
                    active: root.current !== null
                    sourceComponent: root.current?.page ?? null

                    // The Page's back arrow returns to the grid. Without this
                    // the header button is inert, since the page only emits
                    // `back` and does not know who hosts it.
                    onLoaded: if (item && item.back) item.back.connect(() => root.page = "")

                    // The page enters from the right: the one piece of motion
                    // in the design, and what makes the drawer read as one
                    // surface rather than a window swapping contents. It is a
                    // transform, not `x`: the anchors above pin x, and an
                    // anchored x is silently ignored.
                    opacity: active ? 1 : 0
                    transform: Translate {
                        x: pageLoader.active ? 0 : 44
                        Behavior on x { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } }
                    }
                    Behavior on opacity { NumberAnimation { duration: 160 } }
                }
            }
        }
    }
}