diff options
| -rw-r--r-- | AGENTS.md | 6 | ||||
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | appearance/AppearancePanel.qml | 504 | ||||
| -rw-r--r-- | appearance/MockScreen.qml | 94 | ||||
| -rw-r--r-- | appearance/README.md | 95 | ||||
| -rw-r--r-- | appearance/Tab.qml | 45 | ||||
| -rw-r--r-- | appearance/Theme.qml | 66 | ||||
| -rw-r--r-- | appearance/Udt.qml | 145 | ||||
| -rw-r--r-- | appearance/Wallpapers.qml | 115 | ||||
| -rw-r--r-- | appearance/shell.qml | 24 |
10 files changed, 1095 insertions, 0 deletions
@@ -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. @@ -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"); } + } +} |
