// 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 QtQuick // The settings window: a normal toplevel, not a layer surface, so Hyprland // tiles or floats it like any app. The drawer is the quick area; things set // once and left alone live here. It hosts the pages of the modules that // declare `settings: true`, from the same module list as the drawer, with a // searchable list of them down the left and an About page as the landing. Scope { id: root property list modules property bool open: false property string page: "" readonly property var pages: { const out = []; for (let i = 0; i < root.modules.length; i++) if (root.modules[i].settings) out.push(root.modules[i]); return out; } property string query: "" // The sidebar's width: the saved one, or the live one while its edge is // dragged (-1 when not), saved once on release. property real dragWidth: -1 readonly property real sideWidth: root.dragWidth >= 0 ? root.dragWidth : Prefs.settingsSidebar // What the search keeps, as {mod, hit, key}. A page matched by its label // or a keyword has key "" and opens at the top; hit is the keyword when // the label did not match. Then each row inside a page that matches, from // the module's searchItems, with key naming that row. readonly property int perPage: 8 readonly property var found: { const q = root.query.trim().toLowerCase(); const out = []; for (const m of root.pages) { if (q === "" || m.label.toLowerCase().includes(q) || m.name.includes(q)) out.push({ mod: m, hit: "", key: "" }); else { const k = Array.from(m.keywords).find(w => w.toLowerCase().includes(q)); if (k !== undefined) out.push({ mod: m, hit: k, key: "" }); } if (q === "") continue; // ponytail: the first perPage rows per page; a narrower query finds the rest. for (const it of m.searchItems.filter(it => it.text.toLowerCase().includes(q)).slice(0, root.perPage)) out.push({ mod: m, hit: it.text, key: it.key }); } return out; } // The page is shown first so the Loader holds it, then asked to reveal. signal reveal(string key) function go(entry) { root.page = entry.mod.name; if (entry.key) root.reveal(entry.key); } // The search index is the modules' own copy of their rows, reread on // every open so it matches what the pages will show. onOpenChanged: if (root.open) for (const m of root.pages) m.refresh() // Null is the landing page. readonly property QtObject current: root.pages.find(m => m.name === root.page) ?? null // An unknown or empty name opens the landing page. function show(name) { root.page = name; root.open = true; } // One step out: a page to the landing page, the landing page closes. function back() { if (root.current) root.page = ""; else root.close(); } function close() { root.open = false; root.page = ""; root.query = ""; } LazyLoader { active: root.open FloatingWindow { title: "Settings" implicitWidth: 900 implicitHeight: 640 color: Theme.base onClosed: root.close() Item { anchors.fill: parent focus: true Keys.onEscapePressed: root.back() // Search: filters the list below by label and by each // module's keywords, the settings buried in its page. Enter // opens the first match; Escape clears, then steps out. Rectangle { id: search anchors { top: parent.top; left: parent.left; margins: 12 } width: root.sideWidth height: 34 radius: 8 color: Theme.surface TextInput { id: searchIn anchors { fill: parent; leftMargin: 10; rightMargin: 30 } verticalAlignment: TextInput.AlignVCenter clip: true selectByMouse: true color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } text: root.query onTextEdited: root.query = text onAccepted: if (root.found.length) root.go(root.found[0]) Keys.onEscapePressed: event => { if (root.query !== "") root.query = ""; else event.accepted = false; } Text { anchors.verticalCenter: parent.verticalCenter visible: parent.text === "" text: "Search settings" color: Theme.subtext font: parent.font } } // Clear, there only while something is typed. Text { anchors { right: parent.right; rightMargin: 10; verticalCenter: parent.verticalCenter } visible: root.query !== "" // nf-fa-xmark, present in Inconsolata Nerd Font. text: "" font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 2 } color: clearArea.containsMouse ? Theme.text : Theme.subtext MouseArea { id: clearArea anchors { fill: parent; margins: -6 } hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { root.query = ""; searchIn.forceActiveFocus(); } } } } ListView { id: side anchors { top: search.bottom; left: parent.left; bottom: parent.bottom; margins: 12 } width: root.sideWidth spacing: 4 model: root.found delegate: Rectangle { id: entry required property var modelData readonly property bool selected: root.current === modelData.mod && modelData.key === "" width: side.width height: modelData.hit ? 46 : 36 radius: 8 color: selected ? Qt.alpha(Theme.accent, 0.22) : area.containsMouse ? Qt.alpha(Theme.text, 0.06) : "transparent" Text { id: entryIcon anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter } text: entry.modelData.mod.icon font { family: Theme.iconFamily; pixelSize: Theme.fontSize } color: Theme.text } Column { anchors { left: entryIcon.right; leftMargin: 10; right: parent.right; rightMargin: 8 verticalCenter: parent.verticalCenter } Text { text: entry.modelData.mod.label font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: Theme.text } // Which buried setting matched, when the label did not. Text { visible: entry.modelData.hit !== "" width: parent.width elide: Text.ElideRight text: entry.modelData.hit font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } color: Theme.subtext } } MouseArea { id: area anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: root.go(entry.modelData) } } } Text { anchors { top: search.bottom; left: parent.left; margins: 12; leftMargin: 22 } visible: root.found.length === 0 text: "Nothing matches" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } color: Theme.subtext } Rectangle { id: rule anchors { top: parent.top; bottom: parent.bottom; left: side.right; leftMargin: 12 } width: 1 color: grip.containsMouse || grip.pressed ? Qt.alpha(Theme.accent, 0.6) : Qt.alpha(Theme.text, 0.12) } // The rule is the sidebar's edge: drag it to resize. MouseArea { id: grip anchors { top: parent.top; bottom: parent.bottom; horizontalCenter: rule.horizontalCenter } width: 10 hoverEnabled: true cursorShape: Qt.SplitHCursor onPositionChanged: mouse => { if (!pressed) return; // The rule sits 12px right of the sidebar, which starts 12px in. const x = mapToItem(parent, mouse.x, 0).x - 24; root.dragWidth = Math.max(Prefs.sidebarMin, Math.min(Prefs.sidebarMax, x)); } onReleased: { if (root.dragWidth >= 0) Prefs.setSettingsSidebar(root.dragWidth); root.dragWidth = -1; } } // The landing page, where back returns to: what this shell is, // centred in the space beside the sidebar. Item { anchors { top: parent.top; right: parent.right; bottom: parent.bottom; left: rule.right; margins: 16 } visible: root.current === null Column { anchors.centerIn: parent width: Math.min(parent.width, 560) spacing: 12 Text { width: parent.width horizontalAlignment: Text.AlignHCenter text: "About" font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 2; bold: true } color: Theme.text } Text { width: parent.width horizontalAlignment: Text.AlignHCenter wrapMode: Text.Wrap textFormat: Text.PlainText lineHeight: 1.25 font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: Theme.text text: "A desktop shell for Hyprland, built on Quickshell.\n\n" + "SUPER alone opens the drawer, the quick area: notifications, sound, " + "network, bluetooth, mail, VMs, performance, the clipboard and the " + "rest of what changes during the day. The island at the top of the " + "main screen carries the clock, volume, tray, keyboard layout and " + "calendar, and SUPER+Home opens the assistant.\n\n" + "This window holds what is set once and left alone. Pick a page on " + "the left, or search for a setting by name." } Text { width: parent.width horizontalAlignment: Text.AlignHCenter wrapMode: Text.Wrap text: "GPLv2. Colours follow the wallpaper through unified-desktop-theme." font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } color: Theme.subtext } } } Loader { id: pageLoader anchors { top: parent.top; right: parent.right; bottom: parent.bottom; left: rule.right; margins: 16 } sourceComponent: root.current?.page ?? null // The Page's back arrow steps out to the landing page, // dismiss closes the window, and go() moves to another // settings page by name. Connections { target: root function onReveal(key) { if (pageLoader.item?.reveal) pageLoader.item.reveal(key); } } onLoaded: { if (item && item.back) item.back.connect(root.back); if (item && item.dismiss) item.dismiss.connect(root.close); if (item && item.go) item.go.connect(name => root.page = name); } Binding { target: pageLoader.item property: "icon" value: root.current ? root.current.icon : "" when: pageLoader.item !== null } } } } } }