// Copyright (C) 2026 Danilo M. // // 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 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` 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 } } } } } } }