aboutsummaryrefslogtreecommitdiffstats
path: root/appearance
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-11 18:30:58 +0200
committerDanilo M. <danix@danix.xyz>2026-09-11 18:30:58 +0200
commit1b252e32d93e1e0207d2a69ee3445e1dbf6505ae (patch)
treed4280dae5cab886898e7e5e674508728e2d5150a /appearance
parent43c31deaa313a8e471ecc3b5a55411a3b93b6f08 (diff)
downloadquickshell-1b252e32d93e1e0207d2a69ee3445e1dbf6505ae.tar.gz
quickshell-1b252e32d93e1e0207d2a69ee3445e1dbf6505ae.zip
feat(appearance): wallpaper picker and scheme switcher
One drawer for how the desktop looks, on SUPER+Return, replacing the qarma file dialog that key used to open. wallp is untouched and still does the work: the panel calls it with H= and V= and it still runs from a terminal. Clicking a thumbnail stages it rather than setting it, so both screens can be composed before anything changes and Apply is a single wallp call. The mock screens show the result: the staged pick where there is one, what is set where there is not, and the hovered thumbnail on the targeted screen. They are drawn at the real proportions from hyprctl monitors, DP-3 upright because it has transform=1, centred against each other as Hyprland has them. The scheme tab reads the palette pair unified-desktop-theme keeps for each scheme, so the swatches are that scheme's real colours and the list needs no edit when a scheme is added. Applying writes roles.conf and runs install.sh, and reloads nothing, because install.sh reloads nothing: the panel says what is still showing the old scheme rather than pretending the switch is done. Two QML traps are documented in AGENTS.md because neither announces itself: there is no String.matchAll, which threw inside a try and left every swatch empty, and assigning running = true to an already-running Process does nothing, which stopped the scheme loader after the first file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7NRsGyF9jGfPYS4zPqpN7
Diffstat (limited to 'appearance')
-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
8 files changed, 1088 insertions, 0 deletions
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"); }
+ }
+}