aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md6
-rw-r--r--README.md1
-rw-r--r--appearance/AppearancePanel.qml504
-rw-r--r--appearance/MockScreen.qml94
-rw-r--r--appearance/README.md95
-rw-r--r--appearance/Tab.qml45
-rw-r--r--appearance/Theme.qml66
-rw-r--r--appearance/Udt.qml145
-rw-r--r--appearance/Wallpapers.qml115
-rw-r--r--appearance/shell.qml24
10 files changed, 1095 insertions, 0 deletions
diff --git a/AGENTS.md b/AGENTS.md
index e7e7f65..c3cf2ef 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,6 +10,7 @@ them runs alone, and running one does not require the others.
volume-osd/ volume for output and input, plus what is playing
vm-manager/ libvirt drawer: state, live stats, snapshots
+ appearance/ wallpaper picker and colour scheme switcher
Both are started from `~/.config/hypr/sections/autostart.lua` and keep running
for the whole session.
@@ -62,6 +63,11 @@ changing that component. The ones that generalise:
`Keys.onEscapePressed` on a `PanelWindow` never fires.
- **A `Row` sizes to its children, not its parent.** Fixed child widths inside
one overflowed the panel and pushed a button past its edge.
+- **QML's JS engine has no `String.matchAll`.** It throws, and inside a `try`
+ that looks like a parser quietly returning nothing. Use an `exec` loop.
+- **Assigning `running = true` to a `Process` that is already running does
+ nothing.** Reusing one `Process` for a sequence of commands needs
+ `running = false` immediately before each start.
- **libvirt's own memory and disk figures are not what they look like.**
`balloon.current` is memory allocated to the VM and reads full forever;
`block.allocation` is qcow2 growth on the host, not usage inside the guest.
diff --git a/README.md b/README.md
index 1d32f07..d073d93 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@ repos stay independent, this one has no build-time dependency on that one.
volume-osd/ on-screen display for output and input volume
vm-manager/ libvirt VM drawer: state, live stats, snapshots
+ appearance/ wallpaper picker and colour scheme switcher
Each directory has its own README covering what it does and how to run it.
diff --git a/appearance/AppearancePanel.qml b/appearance/AppearancePanel.qml
new file mode 100644
index 0000000..27a0196
--- /dev/null
+++ b/appearance/AppearancePanel.qml
@@ -0,0 +1,504 @@
+// 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: ""
+
+ // 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 = "";
+ 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 nothing, so the panel has to say what is
+ // still showing the old scheme rather than pretend it is done.
+ root.notice = ok
+ ? `${scheme} applied. Reload: hyprctl reload, waybar, kitty, conky. Qt and GTK apps need restarting.`
+ : `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) {
+ root.tab = root.tab === "theme" ? "wallpaper" : "theme";
+ 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
+
+ Row {
+ spacing: 8
+ Tab {
+ text: "Wallpaper"
+ selected: root.tab === "wallpaper"
+ onClicked: root.tab = "wallpaper"
+ }
+ Tab {
+ text: "Theme"
+ selected: root.tab === "theme"
+ onClicked: root.tab = "theme"
+ }
+ Item { width: 12; height: 1 }
+ Text {
+ 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
+ sourceComponent: root.tab === "theme" ? themeTab : wallpaperTab
+ }
+ }
+ }
+ }
+ }
+
+ // --- 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
+ }
+ }
+
+ GridView {
+ width: parent.width
+ height: parent.height - y
+ clip: true
+ cellWidth: Math.floor(width / Math.max(1, Math.floor(width / 300)))
+ cellHeight: cellWidth * 9 / 16 + 26
+ // Several hundred files: only the visible thumbnails decode.
+ cacheBuffer: cellHeight * 2
+ model: Wallpapers.files
+
+ delegate: Item {
+ required property string modelData
+ width: GridView.view.cellWidth
+ height: GridView.view.cellHeight
+
+ 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)
+ border.width: staged ? 2 : 1
+ border.color: staged ? Theme.accent
+ : thumbHover.hovered ? Qt.alpha(Theme.accent, 0.6)
+ : "transparent"
+ 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: 480
+ }
+
+ Text {
+ width: parent.width
+ elide: Text.ElideMiddle
+ text: Wallpapers.basename(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.basename(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
+ }
+
+ 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/MockScreen.qml b/appearance/MockScreen.qml
new file mode 100644
index 0000000..7a09ee2
--- /dev/null
+++ b/appearance/MockScreen.qml
@@ -0,0 +1,94 @@
+// 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
new file mode 100644
index 0000000..f0e9a09
--- /dev/null
+++ b/appearance/README.md
@@ -0,0 +1,95 @@
+# appearance
+
+Wallpapers and colour scheme in one drawer. `SUPER+Return` opens it on the
+Wallpaper tab; Tab switches tabs, Escape closes.
+
+ ┌─[ Wallpaper ]─[ Theme ]──────────────────────────┐
+ │ Set on [Horizontal] [Vertical] 261 wallpapers │
+ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌──┐ ┌─────┐ │
+ │ │ │ │ │ │ │ │ │ │ │ │ │ │
+ │ └────┘ └────┘ └────┘ └────┘ └┬─┘ └──┬──┘ │
+ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ═╧═ ══╧══ │
+ │ │ │ │ │ │ │ │ │ [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 other tab.
+
+## 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.
+
+`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.
+
+Applying writes the `scheme =` line in `~/.config/udt/roles.conf` and runs
+`install.sh`, which regenerates every themed config. **It reloads nothing**,
+because `install.sh` reloads nothing: the panel reports what is still showing
+the old scheme instead of pretending the switch is complete. `hyprctl reload`,
+waybar, kitty and conky each need a nudge, and Qt and GTK apps only reread a
+theme when they restart.
+
+## 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/wal/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/Tab.qml b/appearance/Tab.qml
new file mode 100644
index 0000000..bb2d210
--- /dev/null
+++ b/appearance/Tab.qml
@@ -0,0 +1,45 @@
+// 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
new file mode 100644
index 0000000..423dd49
--- /dev/null
+++ b/appearance/Theme.qml
@@ -0,0 +1,66 @@
+// 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 palette comes from unified-desktop-theme: udt-accent writes
+// ~/.cache/wal/udt-palette.qml on every wallpaper change, holding the
+// Macchiato colours from its palette.rasi plus the accent snapped from the
+// wallpaper. Watching that file is what makes a theme edit show up here.
+//
+// The defaults below are only what renders before the file is read, or on a
+// machine where unified-desktop-theme is not installed.
+Singleton {
+ id: root
+
+ property color base: "#24273a"
+ property color surface: "#363a4f"
+ property color text: "#cad3f5"
+ property color subtext: "#a5adcb"
+ property color red: "#ed8796"
+ property color green: "#a6da95"
+ property color yellow: "#eed49f"
+ property color surfaceAlt: "#494d64"
+ property color overlay: "#6e738d"
+ property color accent: "#b7bdf8"
+
+ readonly property string fontFamily: "Noto Sans"
+ readonly property int fontSize: 16
+
+ // Parsed rather than imported: a generated file cannot be a QML import
+ // without a qmldir next to it, and the cache directory has no reason to
+ // carry one. The format is fixed and machine-written, so a regex is enough.
+ FileView {
+ path: `${Quickshell.env("HOME")}/.cache/wal/udt-palette.qml`
+ watchChanges: true
+ onFileChanged: reload()
+ onLoaded: {
+ const pick = key => {
+ const m = text().match(new RegExp(`property color ${key}: "(#[0-9a-fA-F]{6})"`));
+ return m ? m[1] : null;
+ };
+ root.base = pick("base") ?? root.base;
+ root.surface = pick("surface0") ?? root.surface;
+ root.text = pick("text") ?? root.text;
+ root.subtext = pick("subtext0") ?? root.subtext;
+ root.red = pick("red") ?? root.red;
+ root.green = pick("green") ?? root.green;
+ root.yellow = pick("yellow") ?? root.yellow;
+ root.surfaceAlt = pick("surface1") ?? root.surfaceAlt;
+ root.overlay = pick("overlay0") ?? root.overlay;
+ root.accent = pick("accent") ?? root.accent;
+ }
+ }
+}
diff --git a/appearance/Udt.qml b/appearance/Udt.qml
new file mode 100644
index 0000000..30ae285
--- /dev/null
+++ b/appearance/Udt.qml
@@ -0,0 +1,145 @@
+// 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.
+ 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. Nothing
+ // is reloaded here: install.sh reloads nothing itself, and which apps to
+ // signal is the panel's message to the user rather than its job.
+ 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`];
+ applyProc.running = true;
+ }
+
+ Process {
+ id: applyProc
+ property string scheme: ""
+ stderr: StdioCollector { id: applyErr }
+ onExited: code => {
+ root.applying = false;
+ root.applied(applyProc.scheme, code === 0,
+ code === 0 ? "" : (applyErr.text.trim() || `install.sh exited ${code}`));
+ }
+ }
+}
diff --git a/appearance/Wallpapers.qml b/appearance/Wallpapers.qml
new file mode 100644
index 0000000..0d61f90
--- /dev/null
+++ b/appearance/Wallpapers.qml
@@ -0,0 +1,115 @@
+// 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`
+
+ // 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.
+ Process {
+ running: true
+ command: ["sh", "-c",
+ `find ${JSON.stringify(root.dir)} -maxdepth 2 -type f ` +
+ `\\( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.webp' \\) ` +
+ `-not -path '*/.*' -printf '%T@ %p\\n' 2>/dev/null | sort -rn | cut -d' ' -f2-`]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root.files = text.trim().split("\n").filter(s => s.length);
+ root.scanning = false;
+ }
+ }
+ }
+
+ function basename(path) { return path.slice(path.lastIndexOf("/") + 1); }
+
+ // 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
new file mode 100644
index 0000000..56132c0
--- /dev/null
+++ b/appearance/shell.qml
@@ -0,0 +1,24 @@
+// 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 }
+
+ IpcHandler {
+ target: "appearance"
+ function toggle() { panel.toggle(""); }
+ function wallpaper() { panel.toggle("wallpaper"); }
+ function theme() { panel.toggle("theme"); }
+ }
+}