aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--desktop/Drawer.qml187
-rw-r--r--desktop/shell.qml27
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(); }
+ }
}