// 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.Io import QtQuick import QtQuick.Controls // 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: "" // Grouped: a group's pages together, groups in the order their first // page is declared, pages in declaration order within a group. 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]); const order = []; for (const m of out) if (!order.includes(m.group)) order.push(m.group); return out.map((m, i) => ({ m, i })) .sort((a, b) => order.indexOf(a.m.group) - order.indexOf(b.m.group) || a.i - b.i) .map(x => x.m); } 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 { id: win title: "Settings" implicitWidth: 900 implicitHeight: 640 color: Theme.base onClosed: root.close() // Window-wide, so it works with the focus in any field, unlike // Escape, which a field may take first. Shortcut { sequence: "Ctrl+Q" onActivated: root.close() } // A titlebar of its own, since Hyprland draws none: maximize, to // fill the monitor and back, and close. Row { id: titlebar anchors { top: parent.top; right: parent.right; topMargin: 8; rightMargin: 12 } height: 28 spacing: 6 Repeater { // nf-fa-compress / nf-fa-expand and nf-fa-xmark, present // in Inconsolata Nerd Font. model: [ { icon: win.maximized ? "\uf066" : "\uf065", act: () => win.maximized = !win.maximized, danger: false }, { icon: "\uf00d", act: () => root.close(), danger: true }, ] Rectangle { required property var modelData width: 28 height: 28 radius: 14 color: btnArea.containsMouse ? Qt.alpha(modelData.danger ? Theme.red : Theme.accent, 0.25) : "transparent" Text { anchors.centerIn: parent text: parent.modelData.icon font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 2 } color: Theme.text } MouseArea { id: btnArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: parent.modelData.act() } } } } Text { anchors { horizontalCenter: parent.horizontalCenter; verticalCenter: titlebar.verticalCenter } text: "Keystone Settings" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } color: Theme.subtext } Item { anchors { top: titlebar.bottom; left: parent.left; right: parent.right; bottom: parent.bottom } 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 // Without it a scrolled list draws over the search box. clip: true model: root.found // A group's heading above its first entry, then the entry. delegate: Column { id: slot required property var modelData required property int index width: side.width Text { visible: slot.modelData.mod.group !== "" && (slot.index === 0 || root.found[slot.index - 1].mod.group !== slot.modelData.mod.group) leftPadding: 10 topPadding: slot.index === 0 ? 4 : 14 bottomPadding: 4 text: slot.modelData.mod.group.toUpperCase() font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5; bold: true; letterSpacing: 1 } color: Theme.overlay } Rectangle { id: entry readonly property var modelData: slot.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 // Unsaved changes in that page's draft. Rectangle { visible: entry.modelData.mod.dirty anchors { left: parent.right; leftMargin: 6; verticalCenter: parent.verticalCenter } width: 6; height: 6; radius: 3 color: Theme.yellow } } // 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 { id: landing anchors { top: parent.top; right: parent.right; bottom: parent.bottom; left: rule.right; margins: 16 } visible: root.current === null // Versions read from the installed package names, as // [label, package]. A package not installed shows a dash. readonly property var base: [ ["glibc", "glibc"], ["Qt", "qt6"], ["Mesa", "mesa"], ["Wayland", "wayland"], ["PipeWire", "pipewire"], ["D-Bus", "dbus"], ["elogind", "elogind"], ["OpenSSL", "openssl"], ["FFmpeg", "ffmpeg"], ] readonly property var dev: [ ["GCC", "gcc"], ["LLVM", "llvm"], ["binutils", "binutils"], ["CMake", "cmake"], ["Meson", "meson"], ["Git", "git"], ["Python", "python3"], ["Perl", "perl"], ["Rust", "rust"], ["Node.js", "nodejs"], ] // System facts, sampled once per open of the window: the // Slackware card from conky-theme-udt plus the machine. // Ages are epochs, turned into text here. property var sys: ({}) Process { running: true command: ["sh", "-c", "printf 'version\\t%s\\n' \"$(cat /etc/slackware-version)\"\n" // The trailing slash: /var/log/packages is a symlink. + "printf 'packages\\t%s\\n' \"$(ls -1 /var/log/packages/ | wc -l)\"\n" + "printf 'changelog\\t%s\\n' \"$(stat -c %Y /var/lib/slackpkg/ChangeLog.txt)\"\n" // Filesystem birth time, 0 where the filesystem keeps none. + "printf 'birth\\t%s\\n' \"$(stat -c %W /)\"\n" + "printf 'kernel\\t%s\\n' \"$(uname -r)\"\n" + "printf 'host\\t%s\\n' \"$(uname -n)\"\n" + "printf 'uptime\\t%s\\n' \"$(cut -d' ' -f1 /proc/uptime)\"\n" + "printf 'cpu\\t%s\\n' \"$(sed -n 's/^model name[^:]*: //p;T;q' /proc/cpuinfo)\"\n" + "printf 'gpu\\t%s\\n' \"$(lspci | sed -n 's/.*\\(VGA\\|3D\\)[^:]*: //p' | head -1)\"\n" + "printf 'memory\\t%s\\n' \"$(sed -n 's/^MemTotal: *\\([0-9]*\\).*/\\1/p' /proc/meminfo)\"\n" + "printf 'hyprland\\t%s\\n' \"$(hyprctl version -j | jq -r .version)\"\n" + "printf 'quickshell\\t%s\\n' \"$(qs --version | cut -d' ' -f2)\"\n" // name-version-arch-build. The version must start // with a digit, so gcc does not also match gcc-g++, // and stops at an underscore, so qt6's snapshot // suffix is dropped. + "ls -1 /var/log/packages/ | sed -n 's/^\\(" + landing.base.concat(landing.dev).map(p => p[1]).join("\\|") + "\\)-\\([0-9][^-_]*\\)[^-]*-[^-]*-[^-]*$/pkg:\\1\\t\\2/p'\n"] stdout: StdioCollector { onStreamFinished: { const out = {}; for (const line of text.split("\n")) { const tab = line.indexOf("\t"); if (tab > 0 && line.slice(tab + 1) !== "") out[line.slice(0, tab)] = line.slice(tab + 1); } landing.sys = out; } } } // Seconds as hours below two days, then days, as the conky card. function age(s) { if (!(s >= 0)) return "-"; return s < 172800 ? Math.floor(s / 3600) + "h" : Math.floor(s / 86400) + "d"; } function pkgs(list) { return list.map(p => [p[0], sys["pkg:" + p[1]] ?? "-"]); } readonly property real now: Date.now() / 1000 readonly property real changelogAge: sys.changelog > 0 ? now - sys.changelog : -1 readonly property var sections: [ { title: "System", rows: [ ["Version", (sys.version ?? "-").replace(/^Slackware\s+/, "")], ["Packages", sys.packages ?? "-"], ["Kernel", sys.kernel ?? "-"], ["Installed", sys.birth > 0 ? age(now - sys.birth) + " ago" : "-"], ["Host", sys.host ?? "-"], ["Uptime", sys.uptime ? age(+sys.uptime) : "-"], ["CPU", sys.cpu ?? "-"], ["GPU", (sys.gpu ?? "-").replace(" Corporation", "")], ["Memory", sys.memory ? (sys.memory / 1048576).toFixed(1) + " GiB" : "-"], ["Hyprland", sys.hyprland ?? "-"], ["Quickshell", sys.quickshell ?? "-"], ] }, { title: "Base libraries", rows: pkgs(base) }, { title: "Development", rows: pkgs(dev) }, ] Flickable { id: landingFlick anchors.fill: parent contentHeight: about.height clip: true boundsBehavior: Flickable.StopAtBounds ScrollBar.vertical: ScrollBar {} Column { id: about // Centred while it fits, top-aligned once it scrolls. x: (landingFlick.width - width) / 2 y: Math.max(0, (landingFlick.height - height) / 2) width: Math.min(landingFlick.width - 16, 640) spacing: 12 Text { width: parent.width horizontalAlignment: Text.AlignHCenter text: "Keystone" font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 10; bold: true; letterSpacing: 2 } color: Theme.accent } 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" + "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 } Rectangle { width: parent.width height: facts.implicitHeight + 28 radius: 10 color: Theme.surface Column { id: facts anchors { fill: parent; margins: 14 } spacing: 4 // The headline, as on the conky card: how long // since slackpkg's ChangeLog changed, green under // a day, yellow under a week, red past it. Item { width: parent.width height: 36 Text { id: slackIcon anchors.verticalCenter: parent.verticalCenter // nf-linux-slackware, present in Inconsolata Nerd Font. text: "" font { family: Theme.iconFamily; pixelSize: Theme.fontSize + 8 } color: Theme.accent } Column { anchors { left: slackIcon.right; leftMargin: 10; verticalCenter: parent.verticalCenter } Text { text: "SLACKWARE" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3; bold: true; letterSpacing: 1 } color: Theme.text } Text { text: "last slackpkg update" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } color: Theme.subtext } } Text { anchors { right: parent.right; verticalCenter: parent.verticalCenter } text: landing.age(landing.changelogAge) font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 8; bold: true } color: landing.changelogAge < 0 ? Theme.subtext : landing.changelogAge < 86400 ? Theme.green : landing.changelogAge < 604800 ? Theme.yellow : Theme.red } } Repeater { model: landing.sections Column { id: section required property var modelData width: facts.width spacing: 4 Text { topPadding: 10 bottomPadding: 2 text: section.modelData.title.toUpperCase() font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5; bold: true; letterSpacing: 1 } color: Theme.overlay } // Two columns, filled row by row. Grid { id: cells columns: 2 columnSpacing: 24 rowSpacing: 4 readonly property real cell: (facts.width - columnSpacing) / 2 Repeater { model: section.modelData.rows Item { required property var modelData width: cells.cell height: 20 Text { id: key text: parent.modelData[0] font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } color: Theme.subtext } Text { anchors { right: parent.right; left: key.right; leftMargin: 12 } horizontalAlignment: Text.AlignRight elide: Text.ElideLeft text: parent.modelData[1] font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } color: Theme.text } } } } } } } } } } } 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 } } } } } }