diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-29 11:56:15 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-29 11:56:15 +0200 |
| commit | 2d7b18d52ee7e7f1aac132180b7eb3b951735668 (patch) | |
| tree | 735e3a35be639b987800c9ab5e6646504ae3b0cf | |
| parent | 9b83656e3e3d164fc75bbf679cf32357375660c4 (diff) | |
| download | quickshell-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.qml | 6 | ||||
| -rw-r--r-- | appearance/Icons.qml | 43 | ||||
| -rw-r--r-- | appearance/README.md | 46 | ||||
| -rw-r--r-- | appearance/Steps.qml | 106 | ||||
| -rw-r--r-- | appearance/StepsWindow.qml | 197 | ||||
| -rw-r--r-- | appearance/Udt.qml | 14 | ||||
| -rw-r--r-- | appearance/shell.qml | 5 |
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(); } } } |
