aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-29 11:56:15 +0200
committerDanilo M. <danix@danix.xyz>2026-09-29 11:56:15 +0200
commit2d7b18d52ee7e7f1aac132180b7eb3b951735668 (patch)
tree735e3a35be639b987800c9ab5e6646504ae3b0cf
parent9b83656e3e3d164fc75bbf679cf32357375660c4 (diff)
downloadquickshell-2d7b18d52ee7e7f1aac132180b7eb3b951735668.tar.gz
quickshell-2d7b18d52ee7e7f1aac132180b7eb3b951735668.zip
feat(appearance): steps window, icon regen, cursor out of hypr-theme
StepsWindow shows unified-desktop-theme's pending manual steps, one tab per consumer, centred on DP-3 on the top layer with on-demand focus so it can stay open while commands are pasted elsewhere. udt-steps writes the files and calls the new `steps` IPC verb; indented lines become command blocks with a copy button. The Theme tab pipes install.sh's output into it. The icon switch now also reruns udt-accent (SDDM power icons) and waybar-theme-udt's install.sh (taskbar theme, module icons), chained after the gsettings write both read. Cursor changes write ~/.config/udt/cursor.lua instead of editing hypr-theme's tracked environment.lua. The theme notice and the Udt.qml comment claimed install.sh reloads nothing; it reloads Hyprland, waybar, kitty and conky now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--appearance/AppearancePanel.qml6
-rw-r--r--appearance/Icons.qml43
-rw-r--r--appearance/README.md46
-rw-r--r--appearance/Steps.qml106
-rw-r--r--appearance/StepsWindow.qml197
-rw-r--r--appearance/Udt.qml14
-rw-r--r--appearance/shell.qml5
7 files changed, 380 insertions, 37 deletions
diff --git a/appearance/AppearancePanel.qml b/appearance/AppearancePanel.qml
index 42bc454..8c41078 100644
--- a/appearance/AppearancePanel.qml
+++ b/appearance/AppearancePanel.qml
@@ -52,10 +52,10 @@ Scope {
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.
+ // install.sh reloads Hyprland, waybar, kitty and conky; these are
+ // what it cannot reach.
root.notice = ok
- ? `${scheme} applied. Reload: hyprctl reload, waybar, kitty, conky. Qt and GTK apps need restarting.`
+ ? `${scheme} applied. Restart Qt and GTK apps; neovim needs :colorscheme udt.`
: `failed: ${message}`;
}
}
diff --git a/appearance/Icons.qml b/appearance/Icons.qml
index 2149f6f..2c4c403 100644
--- a/appearance/Icons.qml
+++ b/appearance/Icons.qml
@@ -259,7 +259,14 @@ for name in sys.argv[2:]:
// 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 }
- FileView { id: envFile; path: `${root.home}/.config/hypr/sections/environment.lua`; 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 }
@@ -283,8 +290,20 @@ for name in sys.argv[2:]:
}
Process { id: gsetProc }
+ // Two consumers resolve icons by name from gsettings when they are
+ // generated, not when they run: the SDDM power icons (udt-accent) and
+ // waybar's taskbar theme and module icons (its install.sh). So they are
+ // regenerated here, chained after the gsettings write they read.
+ readonly property string udtRepo: `${root.home}/Programming/GIT/unified-desktop-theme`
+ readonly property string waybarRepo: `${root.home}/Programming/GIT/waybar-theme-udt`
+
function applyIcon(name) {
- root.gsettingsSet("icon-theme", 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; ` +
+ `[ -x ${JSON.stringify(root.waybarRepo)}/install.sh ] && ${JSON.stringify(root.waybarRepo)}/install.sh && ` +
+ `killall -SIGUSR2 waybar`,
+ "sh", name]);
const files = [qt6File, qt5File];
for (let i = 0; i < files.length; i++) {
const text = files[i].text();
@@ -292,23 +311,18 @@ for name in sys.argv[2:]:
}
root.rewriteGtk("gtk-icon-theme-name", name);
root.currentIcon = name;
- root.notice = `${name} set. Restart apps to see it.`;
+ 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);
- const text = envFile.text();
- // Matches both XCURSOR_THEME and HYPRCURSOR_THEME: both end in
- // CURSOR_THEME", ".
- if (text !== "")
- envFile.setText(text.replace(/(CURSOR_THEME", ")[^"]*/g, (m, p1) => p1 + name)
- .replace(/(CURSOR_SIZE", ")[^"]*/g, (m, p1) => p1 + root.cursorSize));
+ root.currentCursor = name;
+ root.writeCursor();
root.rewriteGtk("gtk-cursor-theme-name", name);
root.rewriteGtk("gtk-cursor-theme-size", root.cursorSize);
- root.currentCursor = name;
- root.notice = `${name} set live at ${root.cursorSize}px. environment.lua updated for next login.`;
+ root.notice = `${name} set live at ${root.cursorSize}px. cursor.lua updated for next login.`;
}
function setCursorSize(size) {
@@ -316,11 +330,8 @@ for name in sys.argv[2:]:
// 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));
- const text = envFile.text();
- // Matches both XCURSOR_SIZE and HYPRCURSOR_SIZE.
- if (text !== "")
- envFile.setText(text.replace(/(CURSOR_SIZE", ")[^"]*/g, (m, p1) => p1 + size));
+ root.writeCursor();
root.rewriteGtk("gtk-cursor-theme-size", size);
- root.notice = `cursor size ${size}px. environment.lua updated for next login.`;
+ root.notice = `cursor size ${size}px. cursor.lua updated for next login.`;
}
}
diff --git a/appearance/README.md b/appearance/README.md
index 85fe5d7..a152a03 100644
--- a/appearance/README.md
+++ b/appearance/README.md
@@ -26,7 +26,7 @@ be running for the keybind to work:
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`.
+IPC verbs: `wallpaper`, `theme`, `sunset`, `idle`, `icons`, `steps`.
## Sunset
@@ -52,12 +52,19 @@ 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, `environment.lua`. The GTK theme names do
+(`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. The theme name is still hardcoded in
-`unified-desktop-theme`, `waybar-theme-udt` and rofi, which is tracked as a
-follow-up in those repos.
+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, and waybar-theme-udt's `install.sh` rewrites the taskbar
+theme and the module icons, then waybar is sent `SIGUSR2`. Both are chained
+after the `gsettings` write in one shell, since both read it.
## Wallpapers
@@ -100,11 +107,30 @@ 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. **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.
+`install.sh`, which regenerates every themed config and reloads Hyprland,
+waybar, 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. 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
diff --git a/appearance/Steps.qml b/appearance/Steps.qml
new file mode 100644
index 0000000..5bab158
--- /dev/null
+++ b/appearance/Steps.qml
@@ -0,0 +1,106 @@
+// 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, blocks: [{ cmd, text }] }], sorted by name.
+ property var tabs: []
+ 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");
+ return { name: rec.slice(0, nl), blocks: root.parse(rec.slice(nl + 1)) };
+ });
+ 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
new file mode 100644
index 0000000..fef1d89
--- /dev/null
+++ b/appearance/StepsWindow.qml
@@ -0,0 +1,197 @@
+// 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.name
+ 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/Udt.qml b/appearance/Udt.qml
index 81e6427..d4747aa 100644
--- a/appearance/Udt.qml
+++ b/appearance/Udt.qml
@@ -127,27 +127,27 @@ Singleton {
}
}
- // 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.
+ // Switching writes the scheme line and regenerates every config;
+ // install.sh also reloads what is running. Its whole output becomes the
+ // `install` tab of the steps window, through udt-steps, so the root steps
+ // it prints can be copied from there.
function apply(scheme) {
if (applying || scheme === current) return;
applying = true;
applyProc.scheme = scheme;
- applyProc.command = ["sh", "-c",
+ applyProc.command = ["bash", "-o", "pipefail", "-c",
`sed -i 's|^scheme *=.*|scheme = ${scheme}|' ${JSON.stringify(root.selector)} && ` +
- `${JSON.stringify(root.repo)}/install.sh`];
+ `${JSON.stringify(root.repo)}/install.sh 2>&1 | ${JSON.stringify(root.repo)}/bin/udt-steps install`];
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}`));
+ code === 0 ? "" : `install.sh exited ${code}, its output is in the steps window`);
}
}
}
diff --git a/appearance/shell.qml b/appearance/shell.qml
index ea87fc3..e7e6555 100644
--- a/appearance/shell.qml
+++ b/appearance/shell.qml
@@ -14,6 +14,7 @@ import Quickshell.Io
ShellRoot {
AppearancePanel { id: panel }
+ StepsWindow { id: stepsWindow }
IpcHandler {
target: "appearance"
@@ -23,8 +24,10 @@ ShellRoot {
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();
+ return Hyprsunset.selftest() + "\n" + Hypridle.selftest() + "\n" + Icons.selftest() + "\n" + Steps.selftest();
}
}
}