aboutsummaryrefslogtreecommitdiffstats
path: root/appearance
diff options
context:
space:
mode:
Diffstat (limited to 'appearance')
-rw-r--r--appearance/AppearancePanel.qml662
-rw-r--r--appearance/Field.qml50
-rw-r--r--appearance/Hypridle.qml161
-rw-r--r--appearance/Hyprsunset.qml326
-rw-r--r--appearance/Icons.qml334
-rw-r--r--appearance/IconsTab.qml225
-rw-r--r--appearance/IdleTab.qml100
-rw-r--r--appearance/MockScreen.qml94
-rw-r--r--appearance/README.md184
-rw-r--r--appearance/Steps.qml114
-rw-r--r--appearance/StepsWindow.qml197
-rw-r--r--appearance/SunsetTab.qml169
-rw-r--r--appearance/Tab.qml45
l---------appearance/Theme.qml1
-rw-r--r--appearance/Toggle.qml56
-rw-r--r--appearance/Udt.qml152
-rw-r--r--appearance/Wallpapers.qml243
-rw-r--r--appearance/shell.qml33
18 files changed, 0 insertions, 3146 deletions
diff --git a/appearance/AppearancePanel.qml b/appearance/AppearancePanel.qml
deleted file mode 100644
index 77053b3..0000000
--- a/appearance/AppearancePanel.qml
+++ /dev/null
@@ -1,662 +0,0 @@
-// 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
-
- property string monitor: "DP-1"
- property bool open: false
- property string tab: "wallpaper"
- property string notice: ""
-
- readonly property var tabs: ["wallpaper", "theme", "sunset", "idle", "icons"]
- readonly property var tabLabels: ({
- wallpaper: "Wallpaper", theme: "Theme", sunset: "Sunset",
- idle: "Idle", icons: "Icons",
- })
-
- // What the mock screens show. Hovering a thumbnail previews it on the
- // targeted screen; the other keeps whatever is currently set.
- property string hovering: ""
-
- readonly property var screenObj:
- Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
-
- function show(which) {
- if (which) root.tab = which;
- root.notice = "";
- // Schemes and wallpapers can both have changed since this shell
- // started, which for an autostarted one is the whole session.
- Udt.refresh();
- Wallpapers.refresh();
- Hyprsunset.refresh();
- Hypridle.refresh();
- Icons.refresh();
- root.open = true;
- }
- function close() { root.open = false; }
- function toggle(which) { root.open ? close() : show(which); }
-
- Connections {
- target: Udt
- function onApplied(scheme, ok, message) {
- // install.sh reloads Hyprland, waybar, kitty and conky; these are
- // what it cannot reach.
- root.notice = ok
- ? `${scheme} applied. Restart Qt, GTK, btop and Joplin; neovim needs :colorscheme udt.`
- : `failed: ${message}`;
- }
- }
-
- Connections {
- target: Wallpapers
- function onApplied(ok, message) {
- root.notice = ok ? "" : `wallpaper failed: ${message}`;
- if (ok) root.close();
- }
- }
-
- // Quickshell exits once no window is visible, and this panel is closed
- // most of the time. See AGENTS.md.
- PanelWindow {
- visible: true
- implicitWidth: 1
- implicitHeight: 1
- color: "transparent"
- exclusionMode: ExclusionMode.Ignore
- mask: Region {}
- WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
- }
-
- LazyLoader {
- active: root.open
-
- PanelWindow {
- id: win
- screen: root.screenObj
-
- anchors { top: true; left: true; right: true; bottom: true }
- color: "transparent"
- exclusionMode: ExclusionMode.Ignore
- WlrLayershell.layer: WlrLayer.Overlay
- WlrLayershell.namespace: "quickshell-appearance"
- WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
-
- Rectangle {
- anchors.fill: parent
- color: "#000000"
- opacity: 0.5
- MouseArea { anchors.fill: parent; onClicked: root.close() }
- }
-
- // Keys reach a focused item, never the window itself.
- Item {
- anchors.fill: parent
- focus: true
- Keys.onEscapePressed: root.close()
- Keys.onPressed: event => {
- if (event.key === Qt.Key_Tab) {
- const i = root.tabs.indexOf(root.tab);
- root.tab = root.tabs[(i + 1) % root.tabs.length];
- event.accepted = true;
- }
- }
- }
-
- Rectangle {
- anchors.centerIn: parent
- width: win.width - 120
- height: win.height - 100
- radius: 14
- color: Qt.alpha(Theme.base, 0.72)
- border.width: 1
- border.color: Qt.alpha(Theme.text, 0.12)
-
- MouseArea { anchors.fill: parent }
-
- Column {
- anchors.fill: parent
- anchors.margins: 20
- spacing: 16
-
- // Centred against the panel, not against the hint: the row
- // reads as the drawer's own chrome rather than the first
- // line of a tab's content.
- Item {
- width: parent.width
- height: tabBar.implicitHeight
-
- Row {
- id: tabBar
- anchors.horizontalCenter: parent.horizontalCenter
- spacing: 8
- Repeater {
- model: root.tabs
- Tab {
- required property var modelData
- text: root.tabLabels[modelData]
- selected: root.tab === modelData
- onClicked: root.tab = modelData
- }
- }
- }
-
- Text {
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- text: "Tab switches · Esc closes"
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- color: Theme.overlay
- }
- }
-
- Text {
- visible: root.notice !== ""
- width: parent.width
- wrapMode: Text.Wrap
- text: root.notice
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
- color: root.notice.indexOf("failed") === 0 ? Theme.red : Theme.green
- }
-
- Loader {
- width: parent.width
- height: parent.height - y
- // Existing tabs are inline Components; the new ones are
- // files. sourceComponent wins when it is non-null.
- sourceComponent: root.tab === "theme" ? themeTab
- : root.tab === "wallpaper" ? wallpaperTab : null
- source: root.tab === "sunset" ? Qt.resolvedUrl("SunsetTab.qml")
- : root.tab === "idle" ? Qt.resolvedUrl("IdleTab.qml")
- : root.tab === "icons" ? Qt.resolvedUrl("IconsTab.qml") : ""
- }
- }
- }
- }
- }
-
- // --- theme ------------------------------------------------------------
-
- Component {
- id: themeTab
-
- Flickable {
- contentHeight: col.implicitHeight
- clip: true
-
- Column {
- id: col
- width: parent.width
- spacing: 10
-
- Repeater {
- model: Udt.names
-
- Rectangle {
- required property string modelData
- readonly property var sw: Udt.swatches[modelData] ?? ({})
- readonly property bool isCurrent: Udt.current === modelData
-
- width: col.width
- implicitHeight: 96
- radius: 10
- color: hover.hovered ? Qt.alpha(Theme.surface, 0.75)
- : Qt.alpha(Theme.surface, 0.35)
- border.width: 1
- border.color: isCurrent ? Qt.alpha(Theme.accent, 0.6) : "transparent"
-
- HoverHandler { id: hover }
- MouseArea {
- anchors.fill: parent
- enabled: !Udt.applying && !isCurrent
- cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
- onClicked: Udt.apply(modelData)
- }
-
- Row {
- anchors.fill: parent
- anchors.margins: 14
- spacing: 16
-
- // A mock of the desktop in that scheme's colours:
- // swatches alone say what the colours are, this
- // says how they sit together.
- Rectangle {
- anchors.verticalCenter: parent.verticalCenter
- width: 150; height: 68
- radius: 6
- color: sw.bg ?? Theme.base
- border.width: 1
- border.color: Qt.alpha(sw.text ?? Theme.text, 0.15)
- clip: true
-
- Column {
- anchors.fill: parent
- anchors.margins: 7
- spacing: 5
-
- Row {
- spacing: 4
- Rectangle {
- width: 26; height: 8; radius: 2
- color: sw.accent ?? Theme.accent
- }
- Rectangle {
- width: 16; height: 8; radius: 2
- color: sw.surface ?? Theme.surface
- }
- Rectangle {
- width: 16; height: 8; radius: 2
- color: sw.surface ?? Theme.surface
- }
- }
-
- Rectangle {
- width: parent.width; height: 20; radius: 3
- color: sw.surface ?? Theme.surface
-
- Rectangle {
- anchors.verticalCenter: parent.verticalCenter
- x: 5; width: 60; height: 5; radius: 2
- color: Qt.alpha(sw.text ?? Theme.text, 0.75)
- }
- Rectangle {
- anchors.verticalCenter: parent.verticalCenter
- anchors.right: parent.right
- anchors.rightMargin: 5
- width: 22; height: 9; radius: 3
- color: sw.accent ?? Theme.accent
- }
- }
-
- Row {
- spacing: 4
- Rectangle { width: 9; height: 9; radius: 5; color: sw.green ?? Theme.green }
- Rectangle { width: 9; height: 9; radius: 5; color: sw.yellow ?? Theme.yellow }
- Rectangle { width: 9; height: 9; radius: 5; color: sw.red ?? Theme.red }
- }
- }
- }
-
- Column {
- anchors.verticalCenter: parent.verticalCenter
- spacing: 8
-
- Row {
- spacing: 8
- Text {
- text: modelData
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
- color: Theme.text
- }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- visible: isCurrent
- text: "current"
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- color: Theme.accent
- }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- visible: Udt.applying
- text: "applying…"
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- color: Theme.overlay
- }
- }
-
- Row {
- spacing: 5
- Repeater {
- model: [sw.bg, sw.surface, sw.text, sw.accent,
- sw.green, sw.yellow, sw.red]
- Rectangle {
- required property var modelData
- visible: modelData !== undefined && modelData !== ""
- width: 22; height: 22; radius: 4
- color: modelData ?? "transparent"
- border.width: 1
- border.color: Qt.alpha(Theme.text, 0.1)
- }
- }
- }
- }
- }
- }
- }
- }
- }
- }
-
- // --- wallpaper --------------------------------------------------------
-
- Component {
- id: wallpaperTab
-
- Row {
- spacing: 20
-
- Column {
- width: parent.width - mockPane.width - parent.spacing
- height: parent.height
- spacing: 12
-
- Row {
- spacing: 8
- Text {
- anchors.verticalCenter: parent.verticalCenter
- text: "Set on"
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
- color: Theme.subtext
- }
- Tab {
- text: "Horizontal"
- selected: Wallpapers.target === "H"
- onClicked: Wallpapers.target = "H"
- }
- Tab {
- text: "Vertical"
- selected: Wallpapers.target === "V"
- onClicked: Wallpapers.target = "V"
- }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- text: Wallpapers.scanning ? "scanning…"
- : Wallpapers.applying ? "applying…"
- : Wallpapers.files.length + " wallpapers"
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- color: Theme.overlay
- }
- }
-
- // A folder is a header row followed, while open, by its
- // thumbnails a row at a time, all in one flat ListView, so only
- // the rows in view (and cacheBuffer around them) exist and
- // decode: a large folder loads as it scrolls. Each row holds a
- // group key and the index of its first file, start -1 for the
- // header. A toggle inserts or removes just that folder's rows,
- // so the rows around it, their decoded thumbnails and the
- // scroll position all survive it.
- ListView {
- id: wallList
- width: parent.width
- height: parent.height - y
- clip: true
- readonly property int cols: Math.max(1, Math.floor(width / 300))
- readonly property real cell: Math.floor(width / cols)
- readonly property real thumbHeight: cell * 9 / 16 + 26
- // The column is laid out after creation and reads negative
- // until then, which the buffer refuses with a warning.
- cacheBuffer: Math.max(0, thumbHeight * 2)
- model: ListModel { id: rows }
-
- function group(key) {
- return Wallpapers.groups.find(g => g.key === key) ?? { key: key, name: "", files: [] };
- }
- function rowsOf(g) {
- const out = [];
- for (let i = 0; i < g.files.length; i += cols) out.push({ key: g.key, start: i });
- return out;
- }
- function rebuild() {
- rows.clear();
- for (const g of Wallpapers.groups) {
- rows.append({ key: g.key, start: -1 });
- if (Wallpapers.expanded[g.key]) rows.append(rowsOf(g));
- }
- }
- function toggle(index) {
- const g = group(rows.get(index).key);
- const body = rowsOf(g);
- if (Wallpapers.expanded[g.key]) rows.remove(index + 1, body.length);
- else body.forEach((r, i) => rows.insert(index + 1 + i, r));
- Wallpapers.toggle(g.key);
- }
-
- // The width settles after creation, and a new column count
- // regroups every row, so both rebuild.
- Component.onCompleted: rebuild()
- onColsChanged: rebuild()
- Connections {
- target: Wallpapers
- function onGroupsChanged() { wallList.rebuild(); }
- }
-
- delegate: Item {
- id: row
- required property int index
- required property string key
- required property int start
- readonly property var group: wallList.group(key)
- readonly property bool open: !!Wallpapers.expanded[key]
- width: ListView.view.width
- height: start < 0 ? 40 : wallList.thumbHeight
-
- Rectangle {
- visible: row.start < 0
- anchors { fill: parent; topMargin: 6 }
- radius: 8
- color: Qt.alpha(Theme.surface, headerHover.hovered ? 0.8 : 0.4)
-
- HoverHandler { id: headerHover }
- MouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
- onClicked: wallList.toggle(row.index)
- }
-
- Text {
- id: chevron
- anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter }
- text: row.open ? "\uf077" : "\uf078"
- font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
- color: Theme.subtext
- }
- Text {
- anchors { left: chevron.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
- text: row.group.name
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
- color: Theme.text
- }
- Text {
- anchors { right: parent.right; rightMargin: 12; verticalCenter: parent.verticalCenter }
- text: row.group.files.length
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- color: Theme.overlay
- }
- }
-
- Row {
- visible: row.start >= 0
-
- Repeater {
- model: row.start < 0 ? [] : row.group.files.slice(row.start, row.start + wallList.cols)
-
- delegate: Item {
- required property string modelData
- width: wallList.cell
- height: wallList.thumbHeight
-
- Rectangle {
- readonly property bool staged:
- Wallpapers.pendingH === modelData || Wallpapers.pendingV === modelData
-
- anchors.fill: parent
- anchors.margins: 6
- radius: 8
- color: Qt.alpha(Theme.surface, thumbHover.hovered ? 0.8 : 0.3)
- // Staged wins the border; otherwise it
- // says how well the image fills the
- // screen it would go to.
- border.width: 2
- border.color: staged ? Theme.accent
- : Wallpapers.qualityColor(modelData, Wallpapers.target)
- clip: true
-
- HoverHandler {
- id: thumbHover
- // Hovering previews this image on the mock
- // screen it would actually land on.
- onHoveredChanged:
- root.hovering = hovered ? modelData
- : (root.hovering === modelData ? "" : root.hovering)
- }
- MouseArea {
- anchors.fill: parent
- enabled: !Wallpapers.applying
- cursorShape: Qt.PointingHandCursor
- onClicked: Wallpapers.pick(modelData)
- }
-
- Column {
- anchors.fill: parent
- anchors.margins: 6
- spacing: 4
-
- Image {
- width: parent.width
- height: parent.height - 18
- source: "file://" + modelData
- asynchronous: true
- cache: false
- fillMode: Image.PreserveAspectCrop
- // Decode at display size: full-resolution
- // wallpapers would be megabytes each in memory.
- sourceSize.width: wallList.cell
- }
-
- Text {
- width: parent.width
- elide: Text.ElideMiddle
- text: Wallpapers.label(modelData)
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
- color: Theme.subtext
- }
- }
- }
- }
- }
- }
- }
- }
- }
-
- // The two screens as they sit on the desk: DP-3 rotated upright on
- // the left, DP-1 wide on the right, centred against each other the
- // way Hyprland has them. Hovering a thumbnail fills the screen it
- // would be set on; the other keeps what is on it now.
- Item {
- id: mockPane
- // A third of the panel rather than a fixed width, so the
- // preview keeps its share when the panel is resized.
- width: Math.round(parent.width * 0.33)
- height: parent.height
-
- // Divisor is the two panels side by side plus the gap; the
- // bezels and stands are drawn outside that, hence the margin.
- readonly property real scale: (width * 0.92) / (1080 + 2560 + 160)
-
- Column {
- anchors.centerIn: parent
- spacing: 14
-
- Row {
- spacing: Math.round(160 * mockPane.scale)
-
- MockScreen {
- anchors.verticalCenter: parent.verticalCenter
- label: "DP-3"
- targeted: Wallpapers.target === "V"
- source: (targeted && root.hovering !== "")
- ? root.hovering : Wallpapers.shown("V")
- // DP-3 has transform=1, so it stands upright.
- panelWidth: Math.round(1080 * mockPane.scale)
- panelHeight: Math.round(1920 * mockPane.scale)
- }
-
- MockScreen {
- anchors.verticalCenter: parent.verticalCenter
- label: "DP-1"
- targeted: Wallpapers.target === "H"
- source: (targeted && root.hovering !== "")
- ? root.hovering : Wallpapers.shown("H")
- panelWidth: Math.round(2560 * mockPane.scale)
- panelHeight: Math.round(1080 * mockPane.scale)
- }
- }
-
- Text {
- anchors.horizontalCenter: parent.horizontalCenter
- width: mockPane.width
- horizontalAlignment: Text.AlignHCenter
- wrapMode: Text.Wrap
- text: root.hovering !== "" ? Wallpapers.label(root.hovering)
- : Wallpapers.dirty ? "click Apply to set"
- : "click a wallpaper to stage it"
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- color: Theme.overlay
- }
-
- // Why the image under consideration, hovered or else the
- // one staged for the target screen, will look worse than
- // its thumbnail.
- Text {
- readonly property string subject: root.hovering !== "" ? root.hovering
- : Wallpapers.target === "V" ? Wallpapers.pendingV : Wallpapers.pendingH
- anchors.horizontalCenter: parent.horizontalCenter
- width: mockPane.width
- visible: text !== ""
- horizontalAlignment: Text.AlignHCenter
- wrapMode: Text.Wrap
- text: subject !== "" ? Wallpapers.qualityNote(subject, Wallpapers.target) : ""
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
- color: Wallpapers.qualityColor(subject, Wallpapers.target)
- }
-
- // wallhaven-dl searches wallhaven.cc and finds larger
- // copies of low-res wallpapers. Launched through its
- // desktop entry, which needs no ~/bin on quickshell's PATH
- // and keeps the app's own taskbar icon. The panel closes
- // so the app window is not opened under the overlay.
- Tab {
- anchors.horizontalCenter: parent.horizontalCenter
- text: "Open wallhaven-dl"
- onClicked: {
- Quickshell.execDetached(["gtk-launch", "wallhaven-dl"]);
- root.close();
- }
- }
-
- Row {
- anchors.horizontalCenter: parent.horizontalCenter
- spacing: 8
-
- Tab {
- text: Wallpapers.applying ? "applying…" : "Apply"
- selected: Wallpapers.dirty && !Wallpapers.applying
- enabled: Wallpapers.dirty && !Wallpapers.applying
- onClicked: Wallpapers.apply()
- }
- Tab {
- text: "Reset"
- enabled: Wallpapers.dirty && !Wallpapers.applying
- onClicked: Wallpapers.clearPending()
- }
- }
- }
- }
- }
- }
-}
diff --git a/appearance/Field.qml b/appearance/Field.qml
deleted file mode 100644
index 004bda6..0000000
--- a/appearance/Field.qml
+++ /dev/null
@@ -1,50 +0,0 @@
-// 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 QtQuick
-
-Rectangle {
- id: field
- property alias value: input.text
- property string placeholder: ""
- signal edited
-
- implicitWidth: 70
- height: 24
- radius: 5
- color: Qt.alpha(Theme.surface, 0.5)
- border.width: 1
- border.color: input.activeFocus ? Qt.alpha(Theme.accent, 0.6) : Qt.alpha(Theme.text, 0.12)
-
- Text {
- anchors.fill: parent
- anchors.leftMargin: 6
- anchors.rightMargin: 6
- verticalAlignment: Text.AlignVCenter
- visible: input.text === ""
- text: field.placeholder
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- color: Theme.overlay
- }
-
- TextInput {
- id: input
- anchors.fill: parent
- anchors.leftMargin: 6
- anchors.rightMargin: 6
- verticalAlignment: TextInput.AlignVCenter
- clip: true
- selectByMouse: true
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
- color: Theme.text
- onEditingFinished: field.edited()
- }
-}
diff --git a/appearance/Hypridle.qml b/appearance/Hypridle.qml
deleted file mode 100644
index 1696f5f..0000000
--- a/appearance/Hypridle.qml
+++ /dev/null
@@ -1,161 +0,0 @@
-// 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.
-
-pragma Singleton
-
-import Quickshell
-import Quickshell.Io
-import QtQuick
-
-// The hypridle side. Commands are fixed: only each listener's timeout and
-// whether it is active are editable. Everything before the first listener is
-// kept verbatim so the general block and the rationale comments survive.
-Singleton {
- id: root
-
- readonly property string home: Quickshell.env("HOME")
- readonly property string confPath: `${home}/.config/hypr/hypridle.conf`
-
- property string prefix: ""
- property var listeners: []
- property string tail: ""
- property string notice: ""
- property bool loaded: false
-
- function getField(body, key) {
- const m = body.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`, "m"));
- return m ? m[1] : null;
- }
-
- // A disabled listener is written every line prefixed with "# ". Strip it
- // before reading fields.
- function uncomment(body) {
- return body.split("\n").map(l => l.replace(/^([ \t]*)#[ \t]?/, "$1")).join("\n");
- }
-
- // The closing brace is matched at the start of a line, because the dpms
- // command contains a brace mid-line and a non-greedy match would stop
- // inside it. A disabled listener has both its opening line and its closing
- // brace commented, so the closing pattern allows a leading "#". The first
- // listener's leading trivia is empty because its text is the prefix.
- function parse(text) {
- const re = /^([ \t]*)(#?)[ \t]*listener\s*\{([\s\S]*?)^[ \t]*#?[ \t]*\}/gm;
- const found = [];
- let firstStart = -1;
- let prevEnd = 0;
- let m;
- while ((m = re.exec(text)) !== null) {
- const start = m.index;
- if (firstStart < 0) { firstStart = start; prevEnd = start; }
- const leading = text.slice(prevEnd, start);
- const enabled = m[2] !== "#";
- const body = enabled ? m[3] : root.uncomment(m[3]);
- const t = root.getField(body, "timeout");
- found.push({
- leading: leading,
- timeout: t === null ? 0 : parseInt(t, 10),
- onTimeout: root.getField(body, "on-timeout") ?? "",
- onResume: root.getField(body, "on-resume") ?? "",
- enabled: enabled,
- });
- prevEnd = start + m[0].length;
- }
- if (firstStart < 0) return { prefix: text, listeners: [], tail: "" };
- return { prefix: text.slice(0, firstStart), listeners: found, tail: text.slice(prevEnd) };
- }
-
- function serialize(prefix, list, tail) {
- let out = prefix;
- for (const l of list) {
- const lines = ["listener {", ` timeout = ${l.timeout}`,
- ` on-timeout = ${l.onTimeout}`];
- if (l.onResume) lines.push(` on-resume = ${l.onResume}`);
- lines.push("}");
- const body = l.enabled ? lines.join("\n") : lines.map(x => "# " + x).join("\n");
- out += l.leading + body;
- }
- return out + tail;
- }
-
- function describe(l) {
- const cmd = l.onTimeout;
- if (cmd.indexOf("notify-send") === 0) return "notify before lock";
- if (cmd.indexOf("loginctl lock-session") >= 0) return "lock session";
- if (cmd.indexOf("dpms") >= 0) return "monitors off";
- if (cmd.indexOf("loginctl suspend") >= 0) return "suspend";
- return cmd;
- }
-
- function selftest(): string {
- const fixture = String.raw`general {
- lock_cmd = pidof hyprlock || hyprlock
-}
-
-# 10:00s - screen lock
-listener {
- timeout = 600
- on-timeout = loginctl lock-session
-}
-
-# 10:30s - monitor off
-listener {
- timeout = 630
- on-timeout = hyprctl dispatch "hl.dsp.dpms({ state = \"off\" })"
- on-resume = hyprctl dispatch "hl.dsp.dpms({ state = \"on\" })"
-}
-`;
- const p = root.parse(fixture);
- if (p.listeners.length !== 2) return `SELFTEST Hypridle FAIL: ${p.listeners.length} listeners`;
- if (p.listeners[0].timeout !== 600 || p.listeners[0].enabled !== true)
- return "SELFTEST Hypridle FAIL: first listener";
- if (p.listeners[1].onTimeout.indexOf(String.raw`state = \"off\"`) < 0)
- return "SELFTEST Hypridle FAIL: dpms command truncated";
- if (p.listeners[1].onResume.indexOf(String.raw`state = \"on\"`) < 0)
- return "SELFTEST Hypridle FAIL: dpms on-resume lost";
- if (root.serialize(p.prefix, p.listeners, p.tail) !== fixture)
- return "SELFTEST Hypridle FAIL: round-trip not byte-identical";
- const off = p.listeners.slice();
- off[1] = Object.assign({}, off[1], { enabled: false });
- const p2 = root.parse(root.serialize(p.prefix, off, p.tail));
- if (p2.listeners.length !== 2 || p2.listeners[1].enabled !== false ||
- p2.listeners[1].timeout !== 630)
- return "SELFTEST Hypridle FAIL: disabled listener not round-tripped";
- return "SELFTEST Hypridle PASS";
- }
-
- function refresh() {
- confProc.running = false;
- confProc.running = true;
- }
-
- Process {
- id: confProc
- command: ["cat", root.confPath]
- stdout: StdioCollector {
- onStreamFinished: {
- const p = root.parse(text);
- root.prefix = p.prefix;
- root.listeners = p.listeners;
- root.tail = p.tail;
- root.loaded = true;
- }
- }
- }
-
- FileView { id: confFile; path: root.confPath }
-
- function save() {
- if (!root.loaded) return; // not loaded yet: writing would truncate the file
- confFile.setText(root.serialize(root.prefix, root.listeners, root.tail));
- Quickshell.execDetached(["sh", "-c", "pkill -x hypridle; setsid -f hypridle"]);
- root.notice = "saved; idle timers reset";
- }
-}
diff --git a/appearance/Hyprsunset.qml b/appearance/Hyprsunset.qml
deleted file mode 100644
index fc0d44a..0000000
--- a/appearance/Hyprsunset.qml
+++ /dev/null
@@ -1,326 +0,0 @@
-// 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.
-
-pragma Singleton
-
-import Quickshell
-import Quickshell.Io
-import QtQuick
-
-// The hyprsunset side: profiles in ~/.config/hypr/hyprsunset.conf, the
-// location in hyprsunset-qt's own config, and the daemon. The file format is
-// byte-for-byte what ~/Programming/GIT/sunset-qt writes, so both tools edit
-// the same file.
-Singleton {
- id: root
-
- readonly property string home: Quickshell.env("HOME")
- readonly property string confPath: `${home}/.config/hypr/hyprsunset.conf`
- readonly property string appConfPath: `${home}/.config/hyprsunset-qt/config`
-
- property var profiles: []
- property string lat: ""
- property string lon: ""
- property bool autoDetect: true
- property string daemonCommand: "hyprsunset"
- property string cachePathRaw: "~/.config/hyprsunset-qt/sun.json"
- property string cachePath: `${home}/.config/hyprsunset-qt/sun.json`
- property string notice: ""
- property string sunSummary: ""
- property bool busy: false
- property bool confLoaded: false
- property bool appLoaded: false
-
- readonly property string header:
- "# Managed by hyprsunset-qt. Edits here are overwritten on save.\n"
-
- // QML has no String.matchAll; everything here is exec loops.
- function getField(body, key) {
- const m = body.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`, "m"));
- return m ? m[1] : null;
- }
-
- function parseProfiles(text) {
- const list = [];
- const re = /profile\s*\{([\s\S]*?)\}/g;
- let m;
- while ((m = re.exec(text)) !== null) {
- const body = m[1];
- const t = root.getField(body, "temperature");
- const g = root.getField(body, "gamma");
- list.push({
- time: root.getField(body, "time") ?? "",
- identity: (root.getField(body, "identity") ?? "").toLowerCase() === "true",
- temperature: t === null ? null : parseInt(t, 10),
- gamma: g === null ? null : parseFloat(g),
- });
- }
- return list;
- }
-
- // Byte-for-byte the same construction as sunset-qt's config.serialize():
- // a list of parts joined with newlines, so a no-op save writes the file
- // back exactly as it was.
- function serializeProfiles(list) {
- const di = root.dayIndex(list);
- const ni = root.nightIndex(list);
- const parts = [root.header];
- for (let i = 0; i < list.length; i++) {
- const p = list[i];
- if (i === di) parts.push("\n# day profile -- sunrise");
- else if (i === ni) parts.push("\n# night profile -- sunset");
- else parts.push("\n# profile");
- const lines = ["profile {", ` time = ${p.time}`];
- if (p.identity) lines.push(" identity = true");
- if (p.temperature !== null && p.temperature !== undefined)
- lines.push(` temperature = ${p.temperature}`);
- if (p.gamma !== null && p.gamma !== undefined)
- lines.push(` gamma = ${p.gamma}`);
- lines.push("}");
- parts.push(lines.join("\n"));
- }
- return parts.join("\n") + "\n";
- }
-
- function dayIndex(list) {
- for (let i = 0; i < list.length; i++) if (list[i].identity) return i;
- return null;
- }
-
- function nightIndex(list) {
- for (let i = 0; i < list.length; i++)
- if (list[i].temperature !== null && list[i].temperature !== undefined) return i;
- return null;
- }
-
- function validTime(s) { return /^([01]?\d|2[0-3]):([0-5]\d)$/.test(s); }
- function validTemperature(t) { return t >= 1000 && t <= 20000; }
- function validGamma(g) { return g >= 0.0 && g <= 2.0; }
-
- function localHM(iso) {
- const d = new Date(iso);
- if (isNaN(d.getTime())) return "";
- return ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2);
- }
-
- function selftest(): string {
- // A file exactly as hyprsunset-qt writes it. String.raw keeps the
- // backslashes, none here, but keeps this fixture readable.
- // Two blank lines after the header: sunset-qt's serializer joins parts
- // with a newline and each part starts with one, so the file really has
- // them.
- const fixture = String.raw`# Managed by hyprsunset-qt. Edits here are overwritten on save.
-
-
-# day profile -- sunrise
-profile {
- time = 05:42
- identity = true
-}
-
-# night profile -- sunset
-profile {
- time = 21:02
- temperature = 5500
- gamma = 0.8
-}
-`;
- const parsed = root.parseProfiles(fixture);
- if (parsed.length !== 2) return `SELFTEST Hyprsunset FAIL: parsed ${parsed.length} profiles`;
- if (parsed[0].time !== "05:42" || parsed[0].identity !== true)
- return "SELFTEST Hyprsunset FAIL: day profile wrong";
- if (parsed[1].temperature !== 5500 || parsed[1].gamma !== 0.8)
- return "SELFTEST Hyprsunset FAIL: night profile wrong";
- if (root.dayIndex(parsed) !== 0 || root.nightIndex(parsed) !== 1)
- return "SELFTEST Hyprsunset FAIL: day/night index wrong";
- if (root.serializeProfiles(parsed) !== fixture)
- return "SELFTEST Hyprsunset FAIL: round-trip not byte-identical";
- if (!root.validTime("05:42") || root.validTime("24:00") || root.validTime("5:6"))
- return "SELFTEST Hyprsunset FAIL: validTime";
- if (!root.validTemperature(1000) || root.validTemperature(20001))
- return "SELFTEST Hyprsunset FAIL: validTemperature";
- if (!root.validGamma(0.8) || root.validGamma(2.1))
- return "SELFTEST Hyprsunset FAIL: validGamma";
- return "SELFTEST Hyprsunset PASS";
- }
-
- function refresh() {
- confProc.running = false;
- confProc.running = true;
- appProc.running = false;
- appProc.running = true;
- }
-
- Process {
- id: confProc
- command: ["cat", root.confPath]
- stdout: StdioCollector {
- onStreamFinished: {
- root.profiles = root.parseProfiles(text);
- root.confLoaded = true;
- }
- }
- }
-
- // hyprsunset-qt's own settings: location + daemon command.
- function parseAppConf(text) {
- const out = {};
- let section = "";
- for (const raw of text.split("\n")) {
- const line = raw.trim();
- if (!line || line.startsWith("#") || line.startsWith(";")) continue;
- const sec = line.match(/^\[(.+)\]$/);
- if (sec) { section = sec[1]; continue; }
- const kv = line.match(/^([^=]+)=\s*(.*)$/);
- if (kv) out[`${section}.${kv[1].trim()}`] = kv[2].trim();
- }
- return out;
- }
-
- function expandTilde(p) {
- return p.startsWith("~/") ? root.home + p.slice(1) : p;
- }
-
- Process {
- id: appProc
- command: ["cat", root.appConfPath]
- stdout: StdioCollector {
- onStreamFinished: {
- const c = root.parseAppConf(text);
- root.lat = c["location.lat"] ?? "";
- root.lon = c["location.lon"] ?? "";
- root.autoDetect = (c["location.auto_detect"] ?? "true") === "true";
- root.cachePathRaw = c["cache.path"] ?? "~/.config/hyprsunset-qt/sun.json";
- root.cachePath = root.expandTilde(root.cachePathRaw);
- root.daemonCommand = c["daemon.command"] ?? "hyprsunset";
- root.appLoaded = true;
- }
- }
- }
-
- FileView { id: confFile; path: root.confPath }
- FileView { id: appFile; path: root.appConfPath }
- FileView { id: cacheFile; path: root.cachePath }
-
- function appConfText() {
- return `[location]\nlat = ${root.lat}\nlon = ${root.lon}\n` +
- `auto_detect = ${root.autoDetect}\n\n` +
- `[cache]\npath = ${root.cachePathRaw}\n\n` +
- `[daemon]\ncommand = ${root.daemonCommand}\n\n`;
- }
- function saveSettings() { appFile.setText(root.appConfText()); }
-
- // hyprsunset holds Hyprland's CTM manager exclusively, and pkill only
- // sends SIGTERM, so the replacement must wait for the old process to
- // actually exit or it dies with "A CTM manager is already running".
- function restart() {
- Quickshell.execDetached(["sh", "-c",
- `pkill -x hyprsunset; ` +
- `for i in $(seq 50); do ` +
- `pgrep -x hyprsunset >/dev/null 2>&1 || break; sleep 0.1; done; ` +
- `setsid -f ${root.daemonCommand}`]);
- }
-
- function save() {
- if (!root.confLoaded || !root.appLoaded) return;
- for (const p of root.profiles) {
- if (!root.validTime(p.time)) { root.notice = `invalid time: ${p.time}`; return; }
- if (p.temperature !== null && p.temperature !== undefined &&
- !root.validTemperature(p.temperature)) {
- root.notice = `invalid temperature: ${p.temperature}`; return;
- }
- if (p.gamma !== null && p.gamma !== undefined && !root.validGamma(p.gamma)) {
- root.notice = `invalid gamma: ${p.gamma}`; return;
- }
- }
- confFile.setText(root.serializeProfiles(root.profiles));
- root.saveSettings();
- root.restart();
- root.notice = "saved + restarted";
- }
-
- // Live preview via the daemon's IPC. Identity wins, then temperature, then
- // gamma, matching hyprsunset-qt. Nothing is written.
- function preview() {
- const i = root.nightIndex(root.profiles);
- const target = i !== null ? root.profiles[i]
- : (root.profiles.length ? root.profiles[0] : null);
- if (!target) return;
- if (target.identity) {
- previewProc.command = ["hyprctl", "hyprsunset", "identity"];
- } else {
- const parts = [];
- if (target.temperature !== null && target.temperature !== undefined)
- parts.push(`hyprctl hyprsunset temperature ${target.temperature}`);
- if (target.gamma !== null && target.gamma !== undefined)
- parts.push(`hyprctl hyprsunset gamma ${Math.round(target.gamma * 100)}`);
- if (!parts.length) return;
- previewProc.command = ["sh", "-c", parts.join("; ")];
- }
- previewProc.running = false;
- previewProc.running = true;
- }
- Process { id: previewProc }
-
- function detect() {
- detectProc.running = false;
- detectProc.running = true;
- }
- Process {
- id: detectProc
- command: ["curl", "-fsS", "http://ip-api.com/json"]
- stdout: StdioCollector {
- onStreamFinished: {
- try {
- const d = JSON.parse(text);
- root.lat = String(d.lat);
- root.lon = String(d.lon);
- root.saveSettings();
- root.notice = `located ${root.lat}, ${root.lon}`;
- } catch (e) { root.notice = `detect failed: ${e}`; }
- }
- }
- }
-
- // sunrise-sunset.org is behind Cloudflare and 403s curl's default UA.
- function fetchSun() {
- const url = "https://api.sunrise-sunset.org/json?lat=" +
- encodeURIComponent(root.lat) + "&lng=" + encodeURIComponent(root.lon) +
- "&formatted=0";
- fetchProc.command = ["curl", "-fsS", "-A",
- "Mozilla/5.0 (X11; Linux x86_64) hyprsunset-qt", url];
- fetchProc.running = false;
- fetchProc.running = true;
- root.busy = true;
- }
- Process {
- id: fetchProc
- stdout: StdioCollector {
- onStreamFinished: {
- root.busy = false;
- let data;
- try { data = JSON.parse(text); }
- catch (e) { root.notice = `fetch failed: ${e}`; return; }
- cacheFile.setText(JSON.stringify(data, null, 2));
- const r = data.results ?? {};
- const rise = r.sunrise ? root.localHM(r.sunrise) : "";
- const set = r.sunset ? root.localHM(r.sunset) : "";
- root.sunSummary = `sunrise ${rise || "—"} sunset ${set || "—"}`;
- const di = root.dayIndex(root.profiles);
- const ni = root.nightIndex(root.profiles);
- const next = root.profiles.slice();
- if (di !== null && rise) next[di] = Object.assign({}, next[di], { time: rise });
- if (ni !== null && set) next[ni] = Object.assign({}, next[ni], { time: set });
- root.profiles = next;
- }
- }
- }
-}
diff --git a/appearance/Icons.qml b/appearance/Icons.qml
deleted file mode 100644
index a6eaec5..0000000
--- a/appearance/Icons.qml
+++ /dev/null
@@ -1,334 +0,0 @@
-// 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.
-
-pragma Singleton
-
-import Quickshell
-import Quickshell.Io
-import QtQuick
-
-// The icon and cursor themes installed on this machine. Previews are resolved
-// from each theme, not just the active one: Quickshell.iconPath can only read
-// the platform theme or QS_ICON_THEME, both fixed at load.
-Singleton {
- id: root
-
- readonly property string home: Quickshell.env("HOME")
- readonly property string iconScript: `
-import sys
-import gi
-gi.require_version("Gtk", "3.0")
-from gi.repository import Gtk
-SAMPLES = ["folder","text-x-generic","image-x-generic","network-wireless",
- "audio-x-generic","video-x-generic","battery-full","printer"]
-t = Gtk.IconTheme.new()
-for name in sys.argv[1:]:
- t.set_custom_theme(name)
- for s in SAMPLES:
- info = t.lookup_icon(s, 32, 0)
- if info:
- print("%s\\t%s\\t%s" % (name, s, info.get_filename()))
- print("%s\\tEND\\t" % name)
-`
- // Four shapes per theme, so a card shows the cases that matter (arrow,
- // hand, text, horizontal resize) instead of one ambiguous pointer. Each
- // shape falls back through common aliases and through every storage form a
- // theme may use: a hyprcursor .hlc zip, a shape directory with an SVG, or
- // a legacy Xcursor binary that goes through xcur2png.
- readonly property string cursorScript: `
-import sys, os, glob, shutil, zipfile, subprocess
-outdir = sys.argv[1]
-SAMPLES = {
- "left_ptr": ["left_ptr", "default", "pointer"],
- "hand2": ["hand2", "pointer", "hand"],
- "xterm": ["xterm", "text", "ibeam"],
- "resize": ["sb_h_double_arrow", "size_hor", "ew-resize", "h_double_arrow"],
-}
-roots = [os.path.expanduser("~/.icons"),
- os.path.expanduser("~/.local/share/icons"),
- "/usr/share/icons"]
-
-def pick(d, names):
- for n in names:
- for cand in (f"{d}/hyprcursors/{n}.hlc", f"{d}/hyprcursors/{n}/{n}.svg",
- f"{d}/{n}.hlc", f"{d}/{n}/{n}.svg", f"{d}/cursors/{n}"):
- if os.path.isfile(cand):
- return cand
- return None
-
-os.makedirs(outdir, exist_ok=True)
-for name in sys.argv[2:]:
- d = next((os.path.join(r, name) for r in roots if os.path.isdir(os.path.join(r, name))), None)
- if not d:
- continue
- for shape, aliases in SAMPLES.items():
- c = pick(d, aliases)
- if not c:
- continue
- out = f"{outdir}/{name}-{shape}.png"
- try:
- if c.endswith(".hlc"):
- with zipfile.ZipFile(c) as z:
- members = [e for e in z.namelist() if e.endswith(".svg")]
- if not members:
- members = [e for e in z.namelist() if e.endswith(".png")]
- if not members:
- continue
- with open(out, "wb") as f:
- f.write(z.read(members[0]))
- elif c.endswith(".svg"):
- shutil.copyfile(c, out)
- else:
- raw = f"{outdir}/raw-{name}-{shape}"
- shutil.rmtree(raw, ignore_errors=True)
- os.makedirs(raw, exist_ok=True)
- subprocess.run(["xcur2png", "-d", raw, "-c", f"{raw}/out.conf", c],
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
- pngs = sorted(glob.glob(f"{raw}/*_*.png"))
- if not pngs:
- continue
- shutil.copyfile(pngs[-1], out)
- print(f"{name}\\t{shape}\\t{out}")
- except Exception:
- continue
-`
-
- property var iconThemes: []
- property var cursorThemes: []
- property string currentIcon: ""
- property string currentCursor: ""
- property int cursorSize: 24
- property var iconPreview: ({})
- property var cursorPreview: ({})
- property bool scanning: true
- property string notice: ""
-
- // Pure: classify works on entries shaped { name, index, cursors, manifest }
- // so it can be tested without touching the filesystem.
- function classify(entries) {
- const icons = [], cursors = [];
- for (const e of entries) {
- if (e.cursors || e.manifest) cursors.push(e.name);
- if (e.index && /Directories=\S/.test(e.index)) icons.push(e.name);
- }
- const uniq = a => a.filter((v, i) => a.indexOf(v) === i).sort();
- return { icons: uniq(icons), cursors: uniq(cursors) };
- }
-
- function selftest(): string {
- const entries = [
- { name: "Material-Black-Plum-Suru", index: "Directories=32x32/apps\n", cursors: false, manifest: false },
- { name: "hypr_bibata-modern-amber", index: "", cursors: false, manifest: true },
- { name: "default", index: "Inherits=Bibata-Modern-Amber\n", cursors: false, manifest: false },
- { name: "breeze_cursors", index: "", cursors: true, manifest: false },
- { name: "Adwaita", index: "Directories=16x16/apps\n", cursors: true, manifest: false },
- ];
- const r = root.classify(entries);
- if (r.icons.length !== 2 || r.icons.indexOf("Adwaita") < 0 ||
- r.icons.indexOf("Material-Black-Plum-Suru") < 0)
- return `SELFTEST Icons FAIL: icons ${JSON.stringify(r.icons)}`;
- if (r.cursors.length !== 3 || r.cursors.indexOf("hypr_bibata-modern-amber") < 0 ||
- r.cursors.indexOf("breeze_cursors") < 0 || r.cursors.indexOf("Adwaita") < 0)
- return `SELFTEST Icons FAIL: cursors ${JSON.stringify(r.cursors)}`;
- return "SELFTEST Icons PASS";
- }
-
- function refresh() {
- scanProc.running = false;
- scanProc.running = true;
- curProc.running = false;
- curProc.running = true;
- }
-
- // One shell pass over every theme root. Format per line:
- // name|hasIndex|cursors|manifest|Directories=
- Process {
- id: scanProc
- command: ["sh", "-c",
- `for d in ${root.home}/.icons/* ${root.home}/.local/share/icons/* ` +
- `/usr/share/icons/*; do [ -d "$d" ] || continue; ` +
- `[ -L "$d" ] && continue; ` +
- `i=0; c=0; m=0; dirs=""; ` +
- `[ -d "$d/cursors" ] && c=1; ` +
- `[ -f "$d/manifest.hl" ] && m=1; ` +
- `if [ -f "$d/index.theme" ]; then i=1; dirs=$(sed -n 's/^Directories=//p' "$d/index.theme"); fi; ` +
- `echo "$(basename "$d")|$i|$c|$m|$dirs"; done`]
- stdout: StdioCollector {
- onStreamFinished: {
- const entries = [];
- for (const line of text.trim().split("\n")) {
- const p = line.split("|");
- if (p.length < 5) continue;
- entries.push({ name: p[0], index: p[1] === "1" ? "Directories=" + (p[4] || "x") : "",
- cursors: p[2] === "1", manifest: p[3] === "1" });
- }
- const r = root.classify(entries);
- root.iconThemes = r.icons;
- root.cursorThemes = r.cursors;
- root.scanning = false;
- if (root.iconThemes.length) root.previewIcons();
- if (root.cursorThemes.length) root.previewCursors();
- }
- }
- }
-
- // One python process for every icon theme, so opening the tab costs one
- // spawn, not one per theme.
- function previewIcons() {
- if (!root.iconThemes.length) return;
- iconProc.command = ["python3", "-c", root.iconScript].concat(root.iconThemes);
- iconProc.running = false;
- iconProc.running = true;
- }
- Process {
- id: iconProc
- stdout: StdioCollector {
- onStreamFinished: {
- const next = {};
- for (const line of text.split("\n")) {
- const p = line.split("\t");
- if (p.length < 2 || p[1] === "END" || !p[2]) continue;
- if (!next[p[0]]) next[p[0]] = {};
- next[p[0]][p[1]] = p[2];
- }
- root.iconPreview = next;
- }
- }
- }
-
- // gsettings for the current values.
- Process {
- id: curProc
- command: ["sh", "-c",
- `gsettings get org.gnome.desktop.interface icon-theme; ` +
- // The system's cursor theme is often the `default` alias, a symlink
- // to the real theme. The scan skips symlinked dirs, so resolve the
- // current value to the target name or nothing would read as current.
- `ct=$(gsettings get org.gnome.desktop.interface cursor-theme | sed "s/'//g"); ` +
- `for r in ${root.home}/.icons ${root.home}/.local/share/icons /usr/share/icons; do ` +
- `[ -L "$r/$ct" ] && ct=$(basename "$(readlink -f "$r/$ct")") && break; done; ` +
- `printf '%s\\n' "$ct"; ` +
- `gsettings get org.gnome.desktop.interface cursor-size`]
- stdout: StdioCollector {
- onStreamFinished: {
- const lines = text.trim().split("\n");
- root.currentIcon = (lines[0] ?? "").replace(/'/g, "");
- root.currentCursor = (lines[1] ?? "").replace(/'/g, "");
- const size = parseInt(lines[2] ?? "", 10);
- if (!isNaN(size)) root.cursorSize = size;
- }
- }
- }
-
- // One python pass extracts four shapes for EVERY cursor theme and prints
- // name<TAB>shape<TAB>path, so the whole row paints on open instead of
- // waiting for a hover per card. A theme missing a shape simply shows fewer
- // images rather than a broken one.
- function previewCursors() {
- if (!root.cursorThemes.length) return;
- cursorProc.command = ["python3", "-c", root.cursorScript,
- Quickshell.cachePath("cursors")].concat(root.cursorThemes);
- cursorProc.running = false;
- cursorProc.running = true;
- }
- Process {
- id: cursorProc
- stdout: StdioCollector {
- onStreamFinished: {
- const next = {};
- for (const line of text.split("\n")) {
- const p = line.split("\t");
- if (p.length < 3 || !p[0] || !p[1] || !p[2]) continue;
- if (!next[p[0]]) next[p[0]] = {};
- next[p[0]][p[1]] = p[2];
- }
- root.cursorPreview = next;
- }
- }
- }
-
- // Writes go through FileView so no shell quoting is involved. They are
- // preloaded because setText on an unloaded FileView would write empty.
- FileView { id: qt6File; path: `${root.home}/.config/qt6ct/qt6ct.conf`; blockLoading: true }
- FileView { id: qt5File; path: `${root.home}/.config/qt5ct/qt5ct.conf`; blockLoading: true }
- // The cursor lives in its own file, not in hypr-theme's environment.lua:
- // which cursor a machine runs is local state, and editing a tracked file
- // left hypr-theme dirty after every switch. environment.lua dofile()s it.
- // Written whole, so it needs no preload.
- FileView { id: cursorFile; path: `${root.home}/.config/udt/cursor.lua` }
- function writeCursor() {
- cursorFile.setText(`return { theme = ${JSON.stringify(root.currentCursor)}, size = ${root.cursorSize} }\n`);
- }
- // GTK3 and GTK4 read their own settings.ini, with the theme names written
- // out; gsettings alone does not switch them.
- FileView { id: gtk3File; path: `${root.home}/.config/gtk-3.0/settings.ini`; blockLoading: true }
- FileView { id: gtk4File; path: `${root.home}/.config/gtk-4.0/settings.ini`; blockLoading: true }
-
- // Replaces a `key=value` line in both GTK settings files. A key the file
- // does not carry is left alone, the same as the Qt configs.
- function rewriteGtk(key, value) {
- const files = [gtk3File, gtk4File];
- const re = new RegExp(`^${key}=.*`, "m");
- for (let i = 0; i < files.length; i++) {
- const text = files[i].text();
- if (text !== "") files[i].setText(text.replace(re, `${key}=${value}`));
- }
- }
-
- function gsettingsSet(key, value) {
- gsetProc.command = ["gsettings", "set", "org.gnome.desktop.interface", key, value];
- gsetProc.running = false;
- gsetProc.running = true;
- }
- Process { id: gsetProc }
-
- // The SDDM power icons resolve icon names from gsettings when udt-accent
- // generates them, not when they run, so they are regenerated here,
- // chained after the gsettings write they read. The island resolves its
- // icons live and needs nothing.
- readonly property string udtRepo: `${root.home}/Programming/GIT/unified-desktop-theme`
-
- function applyIcon(name) {
- Quickshell.execDetached(["sh", "-c",
- `gsettings set org.gnome.desktop.interface icon-theme "$1" || exit; ` +
- `[ -e ~/.cache/udt/wpaper ] && ${JSON.stringify(root.udtRepo)}/bin/udt-accent ~/.cache/udt/wpaper`,
- "sh", name]);
- const files = [qt6File, qt5File];
- for (let i = 0; i < files.length; i++) {
- const text = files[i].text();
- if (text !== "") files[i].setText(text.replace(/^icon_theme=.*/m, `icon_theme=${name}`));
- }
- root.rewriteGtk("gtk-icon-theme-name", name);
- root.currentIcon = name;
- root.notice = `${name} set. Waybar and the SDDM icons follow; restart other apps to see it.`;
- }
-
- function applyCursor(name) {
- // Live switch first, then persistence.
- Quickshell.execDetached(["hyprctl", "setcursor", name, String(root.cursorSize)]);
- root.gsettingsSet("cursor-theme", name);
- root.currentCursor = name;
- root.writeCursor();
- root.rewriteGtk("gtk-cursor-theme-name", name);
- root.rewriteGtk("gtk-cursor-theme-size", root.cursorSize);
- root.notice = `${name} set live at ${root.cursorSize}px. cursor.lua updated for next login.`;
- }
-
- function setCursorSize(size) {
- root.cursorSize = size;
- // Live on the current theme, then GTK and the next login's env.
- Quickshell.execDetached(["hyprctl", "setcursor", root.currentCursor, String(size)]);
- root.gsettingsSet("cursor-size", String(size));
- root.writeCursor();
- root.rewriteGtk("gtk-cursor-theme-size", size);
- root.notice = `cursor size ${size}px. cursor.lua updated for next login.`;
- }
-}
diff --git a/appearance/IconsTab.qml b/appearance/IconsTab.qml
deleted file mode 100644
index c4d82ca..0000000
--- a/appearance/IconsTab.qml
+++ /dev/null
@@ -1,225 +0,0 @@
-// 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.Widgets
-import QtQuick
-
-Flickable {
- contentHeight: col.implicitHeight
- clip: true
-
- // Clicking a cursor card stages it; Apply commits. There is deliberately no
- // live hover preview: hyprctl setcursor is not a read-only switch, Hyprland
- // persists the theme to org.gnome.desktop.interface cursor-theme, so
- // hovering wrote the setting and a close left the last hovered theme set.
- // The four rendered shapes on each card are the preview.
- property string pendingCursor: ""
-
- Column {
- id: col
- width: parent.width
- spacing: 12
-
- Text {
- text: Icons.scanning ? "scanning…" : `${Icons.iconThemes.length} icon themes · ${Icons.cursorThemes.length} cursor themes`
- color: Theme.overlay
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- }
-
- Text {
- text: "Icon theme"
- color: Theme.subtext
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
- }
-
- // Flow, not a horizontal strip: the cards wrap onto further rows rather
- // than running off the right edge, and the tab scrolls vertically.
- Flow {
- width: parent.width
- spacing: 10
- Repeater {
- model: Icons.iconThemes
- Rectangle {
- id: iconCard
- required property string modelData
- readonly property bool current: Icons.currentIcon === modelData
- width: 230; height: 150; radius: 10
- color: current ? Qt.alpha(Theme.accent, 0.2) : Qt.alpha(Theme.surface, 0.35)
- border.width: current ? 2 : 1
- border.color: current ? Theme.accent : "transparent"
-
- MouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
- onClicked: Icons.applyIcon(modelData)
- }
-
- Column {
- width: parent.width - 16
- anchors.centerIn: parent
- spacing: 10
- Row {
- anchors.horizontalCenter: parent.horizontalCenter
- spacing: 8
- Repeater {
- model: ["folder", "text-x-generic", "image-x-generic", "network-wireless"]
- IconImage {
- required property string modelData
- readonly property string glyph: (Icons.iconPreview[iconCard.modelData] ?? {})[modelData] ?? ""
- // Absolute paths must carry file://, or they
- // resolve against the config's qrc base and
- // fail; Gtk's builtin icons come back as
- // /org/gtk gresource paths Qt cannot open.
- source: glyph.indexOf("/") === 0 && glyph.indexOf("/org/gtk/") !== 0
- ? "file://" + glyph : ""
- implicitSize: 44
- }
- }
- }
- Text {
- width: parent.width
- horizontalAlignment: Text.AlignHCenter
- text: iconCard.current ? modelData + " current" : modelData
- color: Theme.text
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
- wrapMode: Text.WrapAnywhere
- maximumLineCount: 2
- elide: Text.ElideRight
- }
- }
- }
- }
- }
-
- Row {
- spacing: 10
- Text {
- anchors.verticalCenter: parent.verticalCenter
- text: "Cursor theme"
- color: Theme.subtext
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
- }
- Item { width: 12; height: 1 }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- text: "Size"
- color: Theme.subtext
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
- }
- Repeater {
- model: [16, 24, 32, 48, 64]
- Tab {
- required property var modelData
- anchors.verticalCenter: parent.verticalCenter
- text: String(modelData)
- selected: Icons.cursorSize === modelData
- onClicked: Icons.setCursorSize(modelData)
- }
- }
- }
-
- Flow {
- width: parent.width
- spacing: 10
- Repeater {
- model: Icons.cursorThemes
- Rectangle {
- id: cursorCard
- required property string modelData
- readonly property bool current: Icons.currentCursor === modelData
- readonly property bool staged: pendingCursor === modelData
- width: 230; height: 150; radius: 10
- color: current || staged ? Qt.alpha(Theme.accent, 0.2) : Qt.alpha(Theme.surface, 0.35)
- border.width: current || staged ? 2 : 1
- border.color: staged ? Theme.accent
- : current ? Qt.alpha(Theme.accent, 0.5)
- : "transparent"
-
- MouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
- onClicked: pendingCursor = modelData
- }
-
- Column {
- width: parent.width - 16
- anchors.centerIn: parent
- spacing: 10
- Row {
- anchors.horizontalCenter: parent.horizontalCenter
- spacing: 8
- Repeater {
- // The cases that tell themes apart: arrow, hand,
- // text and horizontal resize.
- model: ["left_ptr", "hand2", "xterm", "resize"]
- Image {
- required property string modelData
- readonly property string p: (Icons.cursorPreview[cursorCard.modelData] ?? {})[modelData] ?? ""
- width: 44; height: 44
- asynchronous: true
- source: p !== "" ? "file://" + p : ""
- fillMode: Image.PreserveAspectFit
- }
- }
- }
- Text {
- width: parent.width
- horizontalAlignment: Text.AlignHCenter
- text: cursorCard.staged ? modelData + " staged"
- : cursorCard.current ? modelData + " current"
- : modelData
- color: Theme.text
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
- wrapMode: Text.WrapAnywhere
- maximumLineCount: 2
- elide: Text.ElideRight
- }
- }
- }
- }
- }
-
- Row {
- spacing: 8
- Text {
- anchors.verticalCenter: parent.verticalCenter
- text: pendingCursor !== "" ? "click Apply to set" : "click a cursor theme to stage it"
- color: Theme.overlay
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- }
- Tab {
- text: "Apply"
- selected: pendingCursor !== ""
- enabled: pendingCursor !== ""
- onClicked: {
- Icons.applyCursor(pendingCursor);
- pendingCursor = "";
- }
- }
- Tab {
- text: "Reset"
- enabled: pendingCursor !== ""
- onClicked: pendingCursor = ""
- }
- }
-
- Text {
- width: parent.width
- wrapMode: Text.Wrap
- visible: Icons.notice !== ""
- text: Icons.notice
- color: Theme.green
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
- }
- }
-
-}
diff --git a/appearance/IdleTab.qml b/appearance/IdleTab.qml
deleted file mode 100644
index 75ce84a..0000000
--- a/appearance/IdleTab.qml
+++ /dev/null
@@ -1,100 +0,0 @@
-// 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 QtQuick
-
-Flickable {
- contentHeight: col.implicitHeight
- clip: true
-
- Column {
- id: col
- width: parent.width
- spacing: 10
-
- Text {
- text: "Commands are fixed. Only the timeout and whether a listener runs are editable."
- color: Theme.overlay
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- }
-
- Repeater {
- model: Hypridle.listeners
- Rectangle {
- required property var modelData
- required property int index
- width: col.width
- implicitHeight: 40
- radius: 8
- color: Qt.alpha(Theme.surface, modelData.enabled ? 0.4 : 0.2)
- opacity: modelData.enabled ? 1 : 0.6
-
- Row {
- anchors.fill: parent
- anchors.margins: 8
- spacing: 10
-
- Toggle {
- anchors.verticalCenter: parent.verticalCenter
- checked: modelData.enabled
- onToggled: Hypridle.listeners = Hypridle.listeners.map(
- (l, i) => i === index ? Object.assign({}, l, { enabled: checked }) : l)
- }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- width: 150
- text: Hypridle.describe(modelData)
- color: Theme.text
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
- }
- Field {
- anchors.verticalCenter: parent.verticalCenter
- width: 70
- value: String(modelData.timeout)
- placeholder: "seconds"
- onEdited: Hypridle.listeners = Hypridle.listeners.map(
- (l, i) => i === index
- ? Object.assign({}, l, { timeout: parseInt(value, 10) || 0 }) : l)
- }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- text: `${Math.floor(modelData.timeout / 60)}:${String(modelData.timeout % 60).padStart(2, "0")}`
- color: Theme.subtext
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- width: parent.width - 380
- elide: Text.ElideRight
- text: modelData.onTimeout
- color: Theme.overlay
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
- }
- }
- }
- }
-
- Tab {
- text: "Save + Restart hypridle"
- selected: true
- onClicked: Hypridle.save()
- }
-
- Text {
- width: parent.width
- wrapMode: Text.Wrap
- visible: Hypridle.notice !== ""
- text: Hypridle.notice
- color: Theme.green
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
- }
- }
-}
diff --git a/appearance/MockScreen.qml b/appearance/MockScreen.qml
deleted file mode 100644
index 7a09ee2..0000000
--- a/appearance/MockScreen.qml
+++ /dev/null
@@ -1,94 +0,0 @@
-// 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 QtQuick
-
-// One monitor in the mock desk layout: a bezel, a stand, and the panel at the
-// screen's real aspect ratio. Drawn rather than loaded from an SVG so it takes
-// its colours from the theme and stays sharp at any scale.
-Item {
- id: mock
-
- property string label: ""
- property string source: ""
- property bool targeted: false
-
- // The panel's size; the bezel and stand are added around it.
- property real panelWidth: 100
- property real panelHeight: 100
-
- readonly property real bezel: Math.max(4, Math.round(panelWidth * 0.02))
- // Proportional to width rather than height: the vertical monitor is tall
- // and narrow, and a share of its height would give it an absurd stand.
- readonly property real standHeight: Math.max(14, Math.round(panelWidth * 0.1))
-
- implicitWidth: panelWidth + bezel * 2
- implicitHeight: panelHeight + bezel * 2 + standHeight
-
- Column {
- anchors.horizontalCenter: parent.horizontalCenter
- spacing: 0
-
- // Case.
- Rectangle {
- width: mock.panelWidth + mock.bezel * 2
- height: mock.panelHeight + mock.bezel * 2
- radius: Math.max(3, mock.bezel)
- color: "#1a1a1e"
- border.width: 1
- border.color: mock.targeted ? Theme.accent : Qt.alpha("#ffffff", 0.14)
-
- // Screen.
- Rectangle {
- anchors.centerIn: parent
- width: mock.panelWidth
- height: mock.panelHeight
- color: Theme.base
- clip: true
-
- Image {
- anchors.fill: parent
- source: mock.source === "" ? "" : "file://" + mock.source
- asynchronous: true
- cache: false
- // swaybg is called with -m fill, so the mock crops the same way.
- fillMode: Image.PreserveAspectCrop
- sourceSize.width: 640
- }
-
- Text {
- anchors.centerIn: parent
- visible: mock.source === ""
- text: mock.label
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- color: Theme.overlay
- }
- }
- }
-
- // Neck.
- Rectangle {
- anchors.horizontalCenter: parent.horizontalCenter
- width: Math.max(8, Math.round(mock.panelWidth * 0.09))
- height: Math.round(mock.standHeight * 0.55)
- color: "#26262e"
- }
-
- // Foot.
- Rectangle {
- anchors.horizontalCenter: parent.horizontalCenter
- width: Math.max(30, Math.round(mock.panelWidth * 0.32))
- height: Math.max(5, Math.round(mock.standHeight * 0.45))
- radius: height / 2
- color: "#2c2c35"
- }
- }
-}
diff --git a/appearance/README.md b/appearance/README.md
deleted file mode 100644
index 7524322..0000000
--- a/appearance/README.md
+++ /dev/null
@@ -1,184 +0,0 @@
-# appearance
-
-Wallpapers and colour scheme in one drawer. `SUPER+Return` opens it on the
-Wallpaper tab; Tab switches tabs, Escape closes.
-
- ┌─[ Wallpaper ]─[ Theme ]─[ Sunset ]─[ Idle ]─[ Icons ]──┐
- │ Set on [Horizontal] [Vertical] 1161 wallpapers │
- │ ▸ wallpapers 16 ┌──┐ ┌─────┐ │
- │ ▾ SFW 30 │ │ │ │ │
- │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ └┬─┘ └──┬──┘ │
- │ │ │ │ │ │ │ │ │ ═╧═ ══╧══ │
- │ └────┘ └────┘ └────┘ └────┘ enlarged 1.6×: … │
- │ ▸ system 727 [Apply] [Reset] │
- └────────────────────────────────────────────────────────┘
-
-## Running it
-
- qs -p .
-
-It is started from `autostart.lua` and reached over IPC, so the shell has to
-be running for the keybind to work:
-
- hl.bind(mainMod .. " + Return", hl.dsp.exec_cmd(
- "qs -p ~/Programming/GIT/quickshell/appearance ipc call appearance wallpaper"))
-
-Write that path out in full in the real config: `exec_cmd` has no shell to
-expand `~`. `ipc call appearance theme` opens the Theme tab.
-
-IPC verbs: `wallpaper`, `theme`, `sunset`, `idle`, `icons`, `steps`.
-
-## Sunset
-
-A port of `hyprsunset-qt` (`~/Programming/GIT/sunset-qt`): profiles in
-`~/.config/hypr/hyprsunset.conf`, location in
-`~/.config/hyprsunset-qt/config`, sunrise/sunset from the same API and cache.
-Both apps read and write the identical file format, so either can edit it.
-
-## Idle
-
-Timeout and enabled only; the commands in `~/.config/hypr/hypridle.conf` are
-fixed. Everything before the first `listener` (the `general` block and the
-comments explaining the design) is preserved verbatim. A disabled listener is
-written commented out. Save restarts `hypridle`, which resets its timers.
-
-## Icons
-
-Switches the icon and cursor theme, and the pointer size. Icon previews come
-from a GTK lookup per theme. Cursor previews come from one python pass per
-open: for every theme it extracts four shapes (arrow, hand, text, horizontal
-resize) out of a hyprcursor `.hlc`, a shape directory's SVG, or a legacy
-Xcursor binary via `xcur2png`, so a card shows the cases that tell themes
-apart. Hovering a card changes the real cursor and reverts it on leave.
-
-Applying writes `gsettings` (which GTK3 and GTK4 both read), the Qt configs
-(`qt6ct`/`qt5ct`), and, for cursors, `~/.config/udt/cursor.lua`, which
-hypr-theme's `environment.lua` `dofile()`s at login. A local file rather than
-an edit to `environment.lua`, which is tracked and was left dirty by every
-cursor switch. The GTK theme names do
-not come from `gsettings` alone here, so the drawer also rewrites
-`gtk-3.0/settings.ini` and `gtk-4.0/settings.ini` to match. Apps and a relogin
-are needed to see the rest.
-
-An icon switch also regenerates the two consumers that resolve icons by name
-when they are generated rather than when they run: `udt-accent` rewrites the
-SDDM power icons. It is chained after the `gsettings` write in one shell,
-since it reads it. Waybar is gone, so the icon switch no longer reruns
-waybar-theme-udt's `install.sh` or signals waybar.
-
-## Wallpapers
-
-Clicking a thumbnail **stages** it rather than setting it, so both screens can
-be composed before anything changes, and Apply then makes a single
-`wallp --set H=… V=…` call. The mock screens on the right show what the desk
-would look like: the staged pick where there is one, what is currently set
-where there is not, and the hovered thumbnail on the targeted screen while the
-pointer is over it.
-
-The thumbnails are grouped by folder, each a header that opens and closes
-it, closed to begin with and remembered while the shell runs. The local
-groups are the top-level folders of `~/Pictures/wallpapers`, with the images
-loose in it as a group of its own; after them come the distribution's,
-`/usr/share/wallpapers`, as `system` for its loose images and `Plasma` for
-its packages. A Plasma package holds one picture at several resolutions,
-some in a dark version too, so it shows once: dark where there is one, then
-the largest, named after the package rather than its `5120x2880.png`.
-
-The list is flat, one row of thumbnails per item, so only the rows in view
-exist and decode, and a folder of hundreds loads as it scrolls. Opening or
-closing a folder inserts or removes just its rows, which keeps the scroll
-position and the thumbnails around it. A first version made each folder one
-item, which loaded every thumbnail of a folder the moment it opened.
-
-Each thumbnail's border says how it will look on the targeted screen, from
-its real size, which `file` reads from the header after each scan (half a
-second for a thousand). `swaybg -m fill` scales to cover, so the measure is
-how far it enlarges: not at all is green, up to 1.33x yellow, past that red.
-Under the mock screens a note in the same colour says why, for the hovered
-image or else the staged one. The staged image keeps the accent border.
-
-"Open wallhaven-dl" under the mock screens launches the wallhaven-dl app,
-which searches wallhaven.cc and finds larger copies of low-res wallpapers,
-and closes the panel. It goes through `gtk-launch` and the app's desktop
-entry, so `~/bin` need not be on quickshell's PATH.
-
-`wallp` does the actual work, including running `udt-accent`, so the accent
-follows the new wallpaper exactly as it does from a terminal. This panel
-replaces its qarma file dialog, not the script.
-
-The two monitors are drawn at their real proportions, read from
-`hyprctl monitors`: DP-1 is 2560x1080 and DP-3 is 1920x1080 with
-`transform=1`, which makes it 1080x1920 on the desk. They are centred against
-each other because that is how Hyprland has them, both spanning y=0.
-
-The bezel and stand are Rectangles rather than an SVG: no asset to ship, sharp
-at any size, and the case stays a fixed near-black while the accent marks
-which screen is targeted. Their proportions come from the panel **width**, not
-its height. Scaling the stand off height gave the wide monitor a 20px stand
-that was invisible and would have given the vertical one an absurd long neck.
-
-## Themes
-
-Each scheme shows its real colours, parsed from the pair of files
-unified-desktop-theme keeps for it: `palette/<name>.conf` holds the colours
-under the scheme's own names, `palette/roles-<name>.conf` says what each
-colour is for, so a role is resolved by looking its value up as a key in the
-first file. Beside the swatches is a small mock of a panel painted in that
-scheme, which says how the colours sit together rather than only what they
-are.
-
-The list comes from the palette directory rather than a hardcoded set, so
-adding a scheme to unified-desktop-theme is enough to make it appear here.
-Both the scheme list and the wallpaper scan are redone every time the panel
-opens: this shell is autostarted and runs all session, so a scan done only at
-startup would go stale the first time anything was added.
-
-Applying writes the `scheme =` line in `~/.config/udt/roles.conf` and runs
-`install.sh`, which regenerates every themed config and reloads Hyprland,
-kitty and conky. Its whole output becomes the `install` tab of the
-steps window below. The notice names what is left: Qt and GTK apps only reread
-a theme when they restart, and neovim needs `:colorscheme udt`.
-
-## Steps
-
-A separate window, `StepsWindow.qml`, for the manual steps
-unified-desktop-theme cannot run itself: the root copies into `/boot` and
-`/usr/share/sddm`, the finish on the homepage host, and the last `install.sh`
-log, shown first as "General recap". Its `udt-steps` writes one file per consumer under `~/.cache/udt/steps/`
-and calls the `steps` verb, which reloads the tabs and opens the window if any
-remain; an empty file set closes it. Nothing is watched: the poke is the
-signal. The same verb reopens it after Close.
-
-Centred on DP-3, on the `top` layer with on-demand keyboard focus, so it can
-stay open while commands are pasted into a terminal elsewhere. A line indented
-two spaces is a command block with a Copy button (`wl-copy`), and the text is
-selectable too. Done deletes the tab's file; the producer also clears it once
-its consumer is up to date.
-
-The window's height comes from its content, so the scroll area is capped
-against the screen and never against the window: binding it to the window's
-own height collapsed the window to its margins.
-
-## Two QML traps met here
-
-**There is no `String.matchAll`.** QML's JS engine does not have it. It threw
-inside a `try` and left every swatch empty with nothing in the log. The
-palette parser uses an `exec` loop instead.
-
-**Assigning `running = true` to a Process that is already running does
-nothing.** The scheme loader reuses one `Process` for each scheme in turn and
-stopped after the first until it set `running = false` immediately before.
-
-## Theme and blur
-
-`Theme.qml` is the shared one: the palette comes from
-`~/.cache/udt/udt-palette.qml` and is watched. Frosting is Hyprland's, matched
-on this window's namespace:
-
- hl.layer_rule({
- name = "blur-appearance",
- match = { namespace = "^(quickshell-appearance)$" },
- blur = true,
- xray = false,
- ignore_alpha = 0.1,
- })
diff --git a/appearance/Steps.qml b/appearance/Steps.qml
deleted file mode 100644
index ec0a5b7..0000000
--- a/appearance/Steps.qml
+++ /dev/null
@@ -1,114 +0,0 @@
-// 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.
-
-pragma Singleton
-
-import Quickshell
-import Quickshell.Io
-import QtQuick
-
-// The manual steps unified-desktop-theme cannot run itself: root copies into
-// /boot and /usr/share/sddm, the finish on the homepage host, and the last
-// install.sh log. Each is a file in ~/.cache/udt/steps/, written by
-// udt-steps, which then calls this shell's `steps` IPC verb. Nothing is
-// watched: the poke is the signal, and a shell started later finds the files
-// on its first reload.
-Singleton {
- id: root
-
- readonly property string dir: `${Quickshell.env("HOME")}/.cache/udt/steps`
-
- // [{ name, label, blocks: [{ cmd, text }] }]: the install.sh recap first,
- // then one tab per consumer by name. A file with no label here shows
- // under its own name, so a new producer needs no edit.
- property var tabs: []
- readonly property var labels: ({
- install: "General recap", grub: "GRUB", sddm: "SDDM", homepage: "Homepage",
- })
- signal loaded
-
- // A line indented two spaces is a command, and a run of them is one block
- // with its own copy button. Blank lines split prose into paragraphs. The
- // format is what the producers already print to a terminal.
- function parse(text) {
- const out = [];
- const lines = text.split("\n");
- for (let i = 0; i < lines.length; i++) {
- const line = lines[i];
- const cmd = line.startsWith(" ");
- const last = out.length ? out[out.length - 1] : null;
- if (!cmd && line.trim() === "") {
- if (last) last.closed = true;
- continue;
- }
- const body = cmd ? line.slice(2) : line;
- if (last && last.cmd === cmd && !last.closed) last.lines.push(body);
- else out.push({ cmd: cmd, lines: [body], closed: false });
- }
- return out.map(b => ({ cmd: b.cmd, text: b.lines.join("\n") }));
- }
-
- function commands(tab) {
- return tab.blocks.filter(b => b.cmd).map(b => b.text).join("\n");
- }
-
- function copy(text) {
- Quickshell.execDetached(["wl-copy", "--", text]);
- }
-
- function refresh() {
- listProc.running = false;
- listProc.running = true;
- }
-
- function dismiss(name) {
- rmProc.command = ["rm", "-f", `${root.dir}/${name}`];
- rmProc.running = true;
- }
-
- // One pass over every file, each prefixed by an ASCII record separator
- // and its name. The names are udt-steps' [a-z0-9-], and its temporary
- // files are dotfiles, which the glob skips.
- Process {
- id: listProc
- command: ["sh", "-c",
- `cd ${JSON.stringify(root.dir)} 2>/dev/null || exit 0; ` +
- `for f in *; do [ -f "$f" ] && printf '\\036%s\\n' "$f" && cat "$f"; done`]
- stdout: StdioCollector {
- onStreamFinished: {
- root.tabs = text.split("\x1e").filter(s => s.length).map(rec => {
- const nl = rec.indexOf("\n");
- const name = rec.slice(0, nl);
- return { name: name, label: root.labels[name] ?? name,
- blocks: root.parse(rec.slice(nl + 1)) };
- }).sort((a, b) => (b.name === "install") - (a.name === "install")
- || a.label.localeCompare(b.label));
- root.loaded();
- }
- }
- }
-
- Process {
- id: rmProc
- onExited: root.refresh()
- }
-
- function selftest(): string {
- const b = root.parse("Intro line\nstill intro\n cmd one\n cmd two\n\nNext para\n cmd three\n");
- if (b.length !== 4 || b[0].cmd || b[0].text !== "Intro line\nstill intro" ||
- !b[1].cmd || b[1].text !== "cmd one\ncmd two" || b[2].text !== "Next para" ||
- b[3].text !== "cmd three")
- return `SELFTEST Steps FAIL: ${JSON.stringify(b)}`;
- if (root.commands({ blocks: b }) !== "cmd one\ncmd two\ncmd three")
- return "SELFTEST Steps FAIL: commands";
- return "SELFTEST Steps PASS";
- }
-}
diff --git a/appearance/StepsWindow.qml b/appearance/StepsWindow.qml
deleted file mode 100644
index 8117885..0000000
--- a/appearance/StepsWindow.qml
+++ /dev/null
@@ -1,197 +0,0 @@
-// 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
-
-// One tab per consumer with manual steps pending. Centred on the vertical
-// screen and kept open on purpose: the commands are pasted into a root shell
-// elsewhere, so the window takes keyboard focus only when clicked and stays
-// until closed. Done deletes a tab's file; the producer also clears it once
-// the consumer is up to date.
-Scope {
- id: root
-
- property string monitor: "DP-3"
- property bool open: false
- property string tab: ""
-
- readonly property var screenObj:
- Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
- readonly property var current: Steps.tabs.find(t => t.name === root.tab) ?? Steps.tabs[0] ?? null
-
- // A poke from udt-steps: reload, and open when something is pending.
- // Also what a keybind calls to bring the window back after closing it.
- function poke() {
- root.opening = true;
- Steps.refresh();
- }
- property bool opening: false
-
- Connections {
- target: Steps
- function onLoaded() {
- if (Steps.tabs.length === 0) root.open = false;
- else if (root.opening) root.open = true;
- root.opening = false;
- }
- }
-
- LazyLoader {
- active: root.open
-
- PanelWindow {
- id: win
- screen: root.screenObj
- // No anchors: a layer surface anchored nowhere is centred.
- implicitWidth: 960
- implicitHeight: body.implicitHeight + 40
- color: "transparent"
- exclusionMode: ExclusionMode.Ignore
- WlrLayershell.layer: WlrLayer.Top
- WlrLayershell.namespace: "quickshell-steps"
- WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
-
- Rectangle {
- anchors.fill: parent
- radius: 14
- color: Qt.alpha(Theme.base, 0.92)
- border.width: 1
- border.color: Qt.alpha(Theme.text, 0.12)
-
- Item {
- anchors.fill: parent
- focus: true
- Keys.onEscapePressed: root.open = false
- }
-
- Column {
- id: body
- anchors { top: parent.top; left: parent.left; right: parent.right; margins: 20 }
- spacing: 16
-
- Item {
- width: parent.width
- height: tabBar.implicitHeight
-
- Row {
- id: tabBar
- spacing: 8
- Repeater {
- model: Steps.tabs
- Tab {
- required property var modelData
- text: modelData.label
- selected: root.current !== null && root.current.name === modelData.name
- onClicked: root.tab = modelData.name
- }
- }
- }
-
- Tab {
- anchors.right: parent.right
- text: "Close"
- onClicked: root.open = false
- }
- }
-
- Flickable {
- width: parent.width
- // Sized from the screen, never from the window: the
- // window's height comes from this, and a binding back
- // to it collapsed the whole thing to its margins.
- height: Math.min(content.implicitHeight,
- win.screen.height - 240 - tabBar.implicitHeight - actions.implicitHeight - 32)
- contentHeight: content.implicitHeight
- clip: true
-
- Column {
- id: content
- width: parent.width
- spacing: 12
-
- Repeater {
- model: root.current ? root.current.blocks : []
-
- Loader {
- required property var modelData
- width: content.width
- sourceComponent: modelData.cmd ? cmdBlock : prose
- property var block: modelData
- }
- }
- }
- }
-
- Row {
- id: actions
- spacing: 8
- anchors.right: parent.right
- Tab {
- text: "Copy all commands"
- enabled: root.current !== null && Steps.commands(root.current) !== ""
- onClicked: Steps.copy(Steps.commands(root.current))
- }
- Tab {
- text: "Done"
- enabled: root.current !== null
- onClicked: Steps.dismiss(root.current.name)
- }
- }
- }
- }
-
- Component {
- id: prose
- Text {
- text: block.text
- wrapMode: Text.Wrap
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 }
- color: Theme.text
- }
- }
-
- Component {
- id: cmdBlock
- Rectangle {
- implicitHeight: Math.max(code.implicitHeight, copyBtn.implicitHeight) + 24
- radius: 8
- color: Qt.alpha(Theme.crust, 0.8)
- border.width: 1
- border.color: Qt.alpha(Theme.accent, 0.3)
-
- // Selectable as well as copyable: sometimes one line of a
- // block is what is wanted.
- TextEdit {
- id: code
- anchors { left: parent.left; right: copyBtn.left; verticalCenter: parent.verticalCenter; margins: 12 }
- text: block.text
- readOnly: true
- selectByMouse: true
- wrapMode: TextEdit.WrapAnywhere
- font { family: "Inconsolata Nerd Font Mono"; pixelSize: Theme.fontSize - 1 }
- color: Theme.text
- selectionColor: Qt.alpha(Theme.accent, 0.4)
- selectedTextColor: Theme.text
- }
-
- Tab {
- id: copyBtn
- anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter }
- text: "Copy"
- onClicked: Steps.copy(block.text)
- }
- }
- }
- }
- }
-}
diff --git a/appearance/SunsetTab.qml b/appearance/SunsetTab.qml
deleted file mode 100644
index 50f6ad6..0000000
--- a/appearance/SunsetTab.qml
+++ /dev/null
@@ -1,169 +0,0 @@
-// 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 QtQuick
-
-Flickable {
- contentHeight: col.implicitHeight
- clip: true
-
- Column {
- id: col
- width: parent.width
- spacing: 12
-
- Row {
- spacing: 8
- Text {
- anchors.verticalCenter: parent.verticalCenter
- text: "Lat"
- color: Theme.subtext
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
- }
- Field { width: 90; value: Hyprsunset.lat; onEdited: Hyprsunset.lat = value; placeholder: "lat" }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- text: "Lon"
- color: Theme.subtext
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
- }
- Field { width: 90; value: Hyprsunset.lon; onEdited: Hyprsunset.lon = value; placeholder: "lon" }
- Toggle {
- anchors.verticalCenter: parent.verticalCenter
- checked: Hyprsunset.autoDetect
- label: "auto"
- onToggled: Hyprsunset.autoDetect = checked
- }
- Tab { text: "Detect"; onClicked: Hyprsunset.detect() }
- Tab { text: Hyprsunset.busy ? "fetching…" : "Fetch sun times"; onClicked: Hyprsunset.fetchSun() }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- text: Hyprsunset.sunSummary
- color: Theme.overlay
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- }
- }
-
- Repeater {
- model: Hyprsunset.profiles
- Rectangle {
- required property var modelData
- required property int index
- width: col.width
- implicitHeight: 40
- radius: 8
- color: Qt.alpha(Theme.surface, 0.35)
-
- Row {
- anchors.fill: parent
- anchors.margins: 8
- spacing: 8
-
- Text {
- anchors.verticalCenter: parent.verticalCenter
- text: index === Hyprsunset.dayIndex(Hyprsunset.profiles) ? "day"
- : index === Hyprsunset.nightIndex(Hyprsunset.profiles) ? "night" : "profile"
- color: Theme.subtext
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- }
- Field {
- anchors.verticalCenter: parent.verticalCenter
- width: 60
- value: modelData.time
- placeholder: "HH:MM"
- onEdited: Hyprsunset.profiles = Hyprsunset.profiles.map(
- (p, i) => i === index ? Object.assign({}, p, { time: value }) : p)
- }
- Toggle {
- anchors.verticalCenter: parent.verticalCenter
- checked: modelData.identity
- label: "identity"
- onToggled: Hyprsunset.profiles = Hyprsunset.profiles.map(
- (p, i) => i === index ? Object.assign({}, p, { identity: checked }) : p)
- }
- Toggle {
- anchors.verticalCenter: parent.verticalCenter
- checked: modelData.temperature !== null && modelData.temperature !== undefined
- label: "temp"
- onToggled: Hyprsunset.profiles = Hyprsunset.profiles.map(
- (p, i) => i === index ? Object.assign({}, p,
- { temperature: checked ? (p.temperature ?? 5500) : null }) : p)
- }
- Field {
- anchors.verticalCenter: parent.verticalCenter
- width: 70
- value: modelData.temperature === null || modelData.temperature === undefined
- ? "" : String(modelData.temperature)
- placeholder: "5500"
- onEdited: Hyprsunset.profiles = Hyprsunset.profiles.map(
- (p, i) => i === index ? Object.assign({}, p,
- { temperature: value === "" ? null : parseInt(value, 10) }) : p)
- }
- Toggle {
- anchors.verticalCenter: parent.verticalCenter
- checked: modelData.gamma !== null && modelData.gamma !== undefined
- label: "gamma"
- onToggled: Hyprsunset.profiles = Hyprsunset.profiles.map(
- (p, i) => i === index ? Object.assign({}, p,
- { gamma: checked ? (p.gamma ?? 1.0) : null }) : p)
- }
- Field {
- anchors.verticalCenter: parent.verticalCenter
- width: 60
- value: modelData.gamma === null || modelData.gamma === undefined
- ? "" : String(modelData.gamma)
- placeholder: "0.8"
- onEdited: Hyprsunset.profiles = Hyprsunset.profiles.map(
- (p, i) => i === index ? Object.assign({}, p,
- { gamma: value === "" ? null : parseFloat(value) }) : p)
- }
- Item { width: 8; height: 1 }
- Tab {
- anchors.verticalCenter: parent.verticalCenter
- text: "✕"
- onClicked: Hyprsunset.profiles =
- Hyprsunset.profiles.filter((p, i) => i !== index)
- }
- // The Row is pinned to the card width, so parent.width is
- // real, but bound it anyway: a narrow enough panel would
- // make the subtraction negative and put the Row in a
- // negative-width state.
- Item { width: Math.max(0, parent.width - 640); height: 1 }
- }
- }
- }
-
- Row {
- spacing: 8
- Tab {
- text: "+ Add profile"
- onClicked: Hyprsunset.profiles =
- Hyprsunset.profiles.concat([{ time: "0:00", identity: false,
- temperature: null, gamma: null }])
- }
- Tab { text: "Live preview"; onClicked: Hyprsunset.preview() }
- Tab {
- text: "Save + Restart"
- selected: true
- onClicked: Hyprsunset.save()
- }
- }
-
- Text {
- width: parent.width
- wrapMode: Text.Wrap
- visible: Hyprsunset.notice !== ""
- text: Hyprsunset.notice
- color: Hyprsunset.notice.indexOf("invalid") === 0 ? Theme.red : Theme.green
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
- }
- }
-}
diff --git a/appearance/Tab.qml b/appearance/Tab.qml
deleted file mode 100644
index bb2d210..0000000
--- a/appearance/Tab.qml
+++ /dev/null
@@ -1,45 +0,0 @@
-// 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 QtQuick
-
-Rectangle {
- id: tab
- property alias text: label.text
- property bool selected: false
- property bool enabled: true
- signal clicked
-
- implicitWidth: label.implicitWidth + 24
- implicitHeight: label.implicitHeight + 14
- radius: 7
- opacity: enabled ? 1 : 0.4
- color: selected ? Qt.alpha(Theme.accent, 0.25)
- : area.containsMouse ? Qt.alpha(Theme.surface, 0.8)
- : Qt.alpha(Theme.surface, 0.35)
- border.width: 1
- border.color: selected ? Qt.alpha(Theme.accent, 0.5) : "transparent"
-
- Text {
- id: label
- anchors.centerIn: parent
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
- color: tab.selected ? Theme.text : Theme.subtext
- }
-
- MouseArea {
- id: area
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: tab.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
- onClicked: if (tab.enabled) tab.clicked()
- }
-}
diff --git a/appearance/Theme.qml b/appearance/Theme.qml
deleted file mode 120000
index 3d2e40f..0000000
--- a/appearance/Theme.qml
+++ /dev/null
@@ -1 +0,0 @@
-../shared/Theme.qml \ No newline at end of file
diff --git a/appearance/Toggle.qml b/appearance/Toggle.qml
deleted file mode 100644
index 2bb56a2..0000000
--- a/appearance/Toggle.qml
+++ /dev/null
@@ -1,56 +0,0 @@
-// 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 QtQuick
-
-Rectangle {
- id: toggle
- property bool checked: false
- property string label: ""
- signal toggled
-
- implicitWidth: row.implicitWidth
- implicitHeight: 22
- color: "transparent"
-
- Row {
- id: row
- anchors.verticalCenter: parent.verticalCenter
- spacing: 6
- Rectangle {
- anchors.verticalCenter: parent.verticalCenter
- width: 16; height: 16; radius: 4
- color: toggle.checked ? Qt.alpha(Theme.accent, 0.7) : Qt.alpha(Theme.surface, 0.5)
- border.width: 1
- border.color: toggle.checked ? Qt.alpha(Theme.accent, 0.9) : Qt.alpha(Theme.text, 0.2)
- Text {
- anchors.centerIn: parent
- visible: toggle.checked
- text: "✓"
- font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 6; bold: true }
- color: Theme.base
- }
- }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- visible: toggle.label !== ""
- text: toggle.label
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- color: Theme.subtext
- }
- }
-
- MouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
- onClicked: { toggle.checked = !toggle.checked; toggle.toggled(); }
- }
-}
diff --git a/appearance/Udt.qml b/appearance/Udt.qml
deleted file mode 100644
index cf97ba5..0000000
--- a/appearance/Udt.qml
+++ /dev/null
@@ -1,152 +0,0 @@
-// 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.
-
-pragma Singleton
-
-import Quickshell
-import Quickshell.Io
-import QtQuick
-
-// The unified-desktop-theme side: which schemes exist, what they look like,
-// and switching between them.
-Singleton {
- id: root
-
- readonly property string home: Quickshell.env("HOME")
- readonly property string repo: `${home}/Programming/GIT/unified-desktop-theme`
- readonly property string selector: `${home}/.config/udt/roles.conf`
-
- property string current: ""
- // name -> { bg, surface, text, accent, red, green, yellow }
- property var swatches: ({})
- property list<string> names: []
-
- property bool applying: false
- signal applied(string scheme, bool ok, string message)
-
- // A scheme is two files: <name>.conf holds colours under the scheme's own
- // names, roles-<name>.conf says what each colour is for. Resolving a role
- // means looking its value up as a key in the first file.
- function parseSwatch(rolesText, paletteText) {
- // QML's JS engine has no String.matchAll, so this is an exec loop.
- const colours = {};
- const re = /^(\w+)\s*=\s*(#[0-9a-fA-F]{6})/gm;
- let m;
- while ((m = re.exec(paletteText)) !== null) colours[m[1]] = m[2];
-
- const role = key => {
- const m = rolesText.match(new RegExp(`^${key}\\s*=\\s*(\\S+)`, "m"));
- // A role can carry a /alpha or *shade suffix; the base colour is
- // what a swatch shows.
- return m ? colours[m[1].split(/[/*]/)[0]] ?? "" : "";
- };
-
- return {
- bg: role("bg"), surface: role("surface"), text: role("fg"),
- accent: role("accent"), red: role("critical"),
- green: role("success"), yellow: role("warning"),
- };
- }
-
- // Which scheme is live. Watched, so switching it from an editor moves the
- // panel's marker too.
- FileView {
- path: root.selector
- watchChanges: true
- onFileChanged: reload()
- onLoaded: {
- const m = text().match(/^\s*scheme\s*=\s*(\S+)/m);
- if (m) root.current = m[1];
- }
- }
-
- // The scheme list comes from the palette directory rather than the comment
- // in roles.conf: adding a scheme is adding two files, and this then needs
- // no edit at all.
- // Rescanned whenever the panel opens: adding a scheme is adding two
- // files, and a shell that has been running since before they appeared
- // would otherwise keep showing the old list.
- function refresh() {
- listProc.running = false;
- listProc.running = true;
- }
-
- Process {
- id: listProc
- running: true
- command: ["sh", "-c",
- `ls ${root.repo}/palette/roles-*.conf 2>/dev/null | ` +
- `sed 's|.*/roles-||; s|\\.conf$||' | sort`]
- stdout: StdioCollector {
- onStreamFinished: {
- root.names = text.trim().split("\n").filter(s => s.length);
- loadProc.next = 0;
- loadProc.loadNext();
- }
- }
- }
-
- Process {
- id: loadProc
- property int next: 0
- property string scheme: ""
-
- function loadNext() {
- if (next >= root.names.length) return;
- scheme = root.names[next];
- next++;
- // Both files at once, split on a marker: one process per scheme
- // rather than two, and the pair is always consistent.
- command = ["sh", "-c",
- `cat ${root.repo}/palette/roles-${scheme}.conf; ` +
- `echo '===SPLIT==='; cat ${root.repo}/palette/${scheme}.conf`];
- // Assigning true to an already-true `running` does nothing, and
- // this Process is reused for every scheme in turn.
- running = false;
- running = true;
- }
-
- stdout: StdioCollector {
- onStreamFinished: {
- const [rolesText, paletteText] = text.split("===SPLIT===");
- if (rolesText && paletteText) {
- const next = Object.assign({}, root.swatches);
- next[loadProc.scheme] = root.parseSwatch(rolesText, paletteText);
- root.swatches = next;
- }
- loadProc.loadNext();
- }
- }
- }
-
- // Switching writes the scheme line and regenerates every config;
- // install.sh also reloads what is running, and hands its whole output to
- // the steps window's `install` tab itself.
- function apply(scheme) {
- if (applying || scheme === current) return;
- applying = true;
- applyProc.scheme = scheme;
- applyProc.command = ["sh", "-c",
- `sed -i 's|^scheme *=.*|scheme = ${scheme}|' ${JSON.stringify(root.selector)} && ` +
- `${JSON.stringify(root.repo)}/install.sh >/dev/null 2>&1`];
- applyProc.running = true;
- }
-
- Process {
- id: applyProc
- property string scheme: ""
- onExited: code => {
- root.applying = false;
- root.applied(applyProc.scheme, code === 0,
- code === 0 ? "" : `install.sh exited ${code}, its output is in the steps window`);
- }
- }
-}
diff --git a/appearance/Wallpapers.qml b/appearance/Wallpapers.qml
deleted file mode 100644
index 9f43675..0000000
--- a/appearance/Wallpapers.qml
+++ /dev/null
@@ -1,243 +0,0 @@
-// 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.
-
-pragma Singleton
-
-import Quickshell
-import Quickshell.Io
-import QtQuick
-
-// The wallpaper side. Setting one is `wallp`'s job: it knows about the two
-// outputs, the saved state and the accent run, and none of that is worth
-// reimplementing here.
-Singleton {
- id: root
-
- readonly property string home: Quickshell.env("HOME")
- readonly property string dir: `${home}/Pictures/wallpapers`
- // The distribution's wallpapers: loose images, and Plasma packages that
- // each carry one picture at several resolutions under contents/images/,
- // some with a dark version under contents/images_dark/.
- readonly property string sysDir: "/usr/share/wallpapers"
-
- // Which screen a pick applies to. wallp calls them H and V.
- property string target: "H"
-
- // Staged picks, empty until something is chosen. Selecting does not set
- // anything: both screens can be composed and applied together, which is
- // also a single wallp call rather than two.
- property string pendingH: ""
- property string pendingV: ""
- readonly property bool dirty: pendingH !== "" || pendingV !== ""
-
- function pick(path) {
- if (target === "V") pendingV = path;
- else pendingH = path;
- }
-
- function clearPending() { pendingH = ""; pendingV = ""; }
-
- // What a screen should show: the staged pick if there is one, else what
- // is actually set.
- function shown(which) {
- if (which === "V") return pendingV !== "" ? pendingV : currentV;
- return pendingH !== "" ? pendingH : currentH;
- }
-
- property list<string> files: []
- property bool scanning: true
- property bool applying: false
- signal applied(bool ok, string message)
-
- // Sorted by most recent first: the wallpaper wanted now is usually one
- // added recently. -maxdepth keeps a stray git checkout from being walked.
- function refresh() {
- scanning = true;
- scanProc.running = false;
- scanProc.running = true;
- }
-
- Process {
- id: scanProc
- running: true
- // img DIR "DEPTH" [ACTIONS...]: the image files under DIR. DEPTH is
- // left unquoted on purpose, so "-maxdepth 2" splits into two words
- // and "" vanishes.
- command: ["sh", "-c",
- `img() { d=$1; o=$2; shift 2; find "$d" $o -type f ` +
- `\\( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.webp' \\) "$@" 2>/dev/null; }; ` +
- `img ${JSON.stringify(root.dir)} "-maxdepth 2" -not -path '*/.*' -printf '%T@ %p\\n' | sort -rn | cut -d' ' -f2-; ` +
- `img ${JSON.stringify(root.sysDir)} "-maxdepth 1" | sort; ` +
- `img ${JSON.stringify(root.sysDir)} "" -path '*/contents/images*/*'`]
- stdout: StdioCollector {
- onStreamFinished: {
- root.files = text.trim().split("\n").filter(s => s.length);
- root.scanning = false;
- sizeProc.running = false;
- sizeProc.command = ["sh", "-c", 'printf "%s\\0" "$@" | xargs -0 -r file -N -0', "sh"].concat(root.files);
- sizeProc.running = true;
- }
- }
- }
-
- // Each file's pixel size, by path, from `file`, which reads only the
- // header: about half a second for a thousand. -0 ends the name with a
- // NUL, since a name can hold the ": " that would otherwise end it. The
- // size is the last WxH in the description, because a JPEG's comes after
- // its "density 72x72". A file `file` gives no size for has no entry and
- // gets no quality colour.
- property var sizes: ({})
- Process {
- id: sizeProc
- stdout: StdioCollector {
- onStreamFinished: {
- const out = {};
- for (const line of text.split("\n")) {
- const nul = line.indexOf("\0");
- if (nul < 0) continue;
- const re = /(\d+) ?x ?(\d+)/g;
- let m, last = null;
- while ((m = re.exec(line.slice(nul + 1))) !== null) last = m;
- if (last) out[line.slice(0, nul)] = { w: +last[1], h: +last[2] };
- }
- root.sizes = out;
- }
- }
- }
-
- // The screens wallp sets, as the mock draws them: DP-1 wide, DP-3 turned
- // upright.
- // ponytail: fixed, like the mock; read Quickshell.screens if a monitor
- // changes.
- readonly property var panels: ({ H: { w: 2560, h: 1080 }, V: { w: 1080, h: 1920 } })
-
- // How far swaybg's `-m fill` has to enlarge an image to cover a screen,
- // 0 when the size is unknown. At or under 1 it is downscaled and sharp;
- // to 1.33 the softening is slight; past that it shows pixels.
- function upscale(path, which) {
- const s = sizes[path];
- const p = panels[which];
- return s && s.w > 0 && s.h > 0 ? Math.max(p.w / s.w, p.h / s.h) : 0;
- }
- function qualityColor(path, which) {
- const u = upscale(path, which);
- return u === 0 ? "transparent" : u <= 1 ? Theme.green : u <= 1.33 ? Theme.yellow : Theme.red;
- }
- // Why an image that fits will still look bad, or "" when it will not.
- function qualityNote(path, which) {
- const u = upscale(path, which);
- if (u <= 1) return "";
- const s = sizes[path], p = panels[which];
- return `${s.w}×${s.h} is enlarged ${u.toFixed(2)}× to cover the ${p.w}×${p.h} screen: `
- + (u <= 1.33 ? "each image pixel is spread over more than one screen pixel, so it will look slightly soft."
- : "each image pixel becomes a visible block, so it will look pixelated.");
- }
-
- function basename(path) { return path.slice(path.lastIndexOf("/") + 1); }
-
- // The files by the subfolder they sit in, folders by name, each keeping
- // the newest-first order of `files`. Images loose in `dir` itself form a
- // group named after it, which sorts first. A folder with no images at
- // the scanned depth has no group. The system ones follow: its loose
- // images as "system", and the Plasma packages as "Plasma", one image
- // each: the dark version where the package has one, else the light, and
- // of those the largest by the WxH its file is named after. `key` is what
- // `expanded` is indexed by, so a local folder named "system" stays apart.
- readonly property var groups: {
- const local = {}, system = [], plasma = {};
- for (const f of files) {
- if (f.startsWith(dir + "/")) {
- const rel = f.slice(dir.length + 1);
- const key = rel.includes("/") ? rel.slice(0, rel.indexOf("/")) : "";
- if (!local[key]) local[key] = [];
- local[key].push(f);
- } else if (f.includes("/contents/images")) {
- const pkg = f.slice(sysDir.length + 1).split("/")[0];
- if (!plasma[pkg] || rank(f) > rank(plasma[pkg])) plasma[pkg] = f;
- } else {
- system.push(f);
- }
- }
- const byName = (a, b) => a.localeCompare(b, undefined, { sensitivity: "base" });
- return Object.keys(local).sort(byName)
- .map(k => ({ key: "local/" + k, name: k || basename(dir), files: local[k] }))
- .concat([
- { key: "system", name: "system", files: system },
- { key: "plasma", name: "Plasma", files: Object.keys(plasma).sort(byName).map(k => plasma[k]) },
- ])
- .filter(g => g.files.length > 0);
- }
-
- // Dark outranks any light, then size decides.
- function rank(path) {
- const m = /(\d+)x(\d+)\.\w+$/.exec(path);
- return (path.includes("/contents/images_dark/") ? 1e12 : 0) + (m ? m[1] * m[2] : 0);
- }
-
- // What a thumbnail is called: a Plasma package's file is named after its
- // resolution, so it goes by the package instead.
- function label(path) {
- const i = path.indexOf("/contents/images");
- return i < 0 ? basename(path) : basename(path.slice(0, i));
- }
-
- // Which groups are open, by key. Here rather than in the panel, which is
- // destroyed on close, so a folder left open is open next time. Reassigned
- // whole, because bindings do not see a key change inside a var.
- property var expanded: ({})
- function toggle(key) {
- const e = Object.assign({}, expanded);
- e[key] = !e[key];
- expanded = e;
- }
-
- // What is on each screen now. wallp writes these on every set, so
- // watching them keeps the mock honest even when set from a terminal.
- property string currentH: ""
- property string currentV: ""
-
- FileView {
- path: `${root.home}/.config/wallp/wall_h`
- watchChanges: true
- onFileChanged: reload()
- onLoaded: root.currentH = text().trim()
- }
-
- FileView {
- path: `${root.home}/.config/wallp/wall_v`
- watchChanges: true
- onFileChanged: reload()
- onLoaded: root.currentV = text().trim()
- }
-
- // wallp does the work, including running udt-accent, so the accent follows
- // the new wallpaper exactly as it does from the command line. Both screens
- // go in one call: wallp accepts H= and V= together.
- function apply() {
- if (applying || !dirty) return;
- applying = true;
- const args = [];
- if (pendingH !== "") args.push(`H=${JSON.stringify(pendingH)}`);
- if (pendingV !== "") args.push(`V=${JSON.stringify(pendingV)}`);
- applyProc.command = ["sh", "-c", `wallp --set ${args.join(" ")} >/dev/null 2>&1`];
- applyProc.running = true;
- }
-
- Process {
- id: applyProc
- stderr: StdioCollector { id: applyErr }
- onExited: code => {
- root.applying = false;
- if (code === 0) root.clearPending();
- root.applied(code === 0, code === 0 ? "" : (applyErr.text.trim() || `wallp exited ${code}`));
- }
- }
-}
diff --git a/appearance/shell.qml b/appearance/shell.qml
deleted file mode 100644
index e7e6555..0000000
--- a/appearance/shell.qml
+++ /dev/null
@@ -1,33 +0,0 @@
-// 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.Io
-
-ShellRoot {
- AppearancePanel { id: panel }
- StepsWindow { id: stepsWindow }
-
- IpcHandler {
- target: "appearance"
- function toggle() { panel.toggle(""); }
- function wallpaper() { panel.toggle("wallpaper"); }
- function theme() { panel.toggle("theme"); }
- function sunset() { panel.toggle("sunset"); }
- function idle() { panel.toggle("idle"); }
- function icons() { panel.toggle("icons"); }
- // udt-steps calls this after writing a steps file; a keybind can too.
- function steps() { stepsWindow.poke(); }
- function selftest(): string {
- return Hyprsunset.selftest() + "\n" + Hypridle.selftest() + "\n" + Icons.selftest() + "\n" + Steps.selftest();
- }
- }
-}