diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-14 12:14:56 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-14 12:14:56 +0200 |
| commit | 14a8cd8256620c151693cf2098a91403aa76c3cb (patch) | |
| tree | 3baeb919c61f8205e2f48e9ccf3b91ffbc9e67e5 | |
| parent | 26277c179016b86b95d9f8185dab9dfed4474fc6 (diff) | |
| download | quickshell-14a8cd8256620c151693cf2098a91403aa76c3cb.tar.gz quickshell-14a8cd8256620c151693cf2098a91403aa76c3cb.zip | |
feat(desktop): the drawer, with the top reserved
Left of DP-1 because conky holds the right; ExclusionMode.Normal so the
bar the drawer is reached from stays visible and clickable. The top is an
empty Item claiming the space the notification engine will fill, so the
grid already sits where it will sit once that lands.
| -rw-r--r-- | desktop/Drawer.qml | 187 | ||||
| -rw-r--r-- | desktop/shell.qml | 27 |
2 files changed, 210 insertions, 4 deletions
diff --git a/desktop/Drawer.qml b/desktop/Drawer.qml new file mode 100644 index 0000000..f0e0a84 --- /dev/null +++ b/desktop/Drawer.qml @@ -0,0 +1,187 @@ +// 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 = 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. + Item { + anchors.fill: parent + focus: true + Keys.onEscapePressed: { + if (root.page) root.page = ""; + else root.close(); + } + } + + 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) + + // 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. + opacity: active ? 1 : 0 + x: active ? 16 : 60 + Behavior on x { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } + Behavior on opacity { NumberAnimation { duration: 160 } } + } + } + } + } +} diff --git a/desktop/shell.qml b/desktop/shell.qml index abddf36..ed219e5 100644 --- a/desktop/shell.qml +++ b/desktop/shell.qml @@ -10,13 +10,12 @@ // GNU General Public License for more details. import Quickshell +import Quickshell.Io import Quickshell.Wayland ShellRoot { - // Quickshell exits once no window is visible, and this shell's drawer is - // closed most of the time. A 1x1 transparent window with an empty mask - // holds the process open without drawing anything or catching a click. - // See AGENTS.md: without it the shell loads, reports no error, and quits. + // Quickshell exits once no window is visible, and the drawer is closed + // most of the time. See AGENTS.md. PanelWindow { visible: true implicitWidth: 1 @@ -26,4 +25,24 @@ ShellRoot { mask: Region {} WlrLayershell.keyboardFocus: WlrKeyboardFocus.None } + + Drawer { + id: drawer + modules: [] + } + + // The waybar launcher and the deep-link keybinds all reach this: + // qs -p <this dir> ipc call drawer toggle -> the grid + // qs -p <this dir> ipc call drawer open mail -> the mail page + // Quickshell 0.3.1 IPC requires every declared argument to be present, + // and a parameter with a JS default registers as QVariant, which IPC + // rejects. So the page name cannot be optional: `open` always takes a + // page, and the page-less grid is `toggle`, the zero-argument shape the + // appearance shell uses for its routes and the waybar launcher calls. + IpcHandler { + target: "drawer" + function open(page: string) { drawer.show(page); } + function toggle() { drawer.toggle(""); } + function close() { drawer.close(); } + } } |
