diff options
Diffstat (limited to 'appearance')
| -rw-r--r-- | appearance/AppearancePanel.qml | 662 | ||||
| -rw-r--r-- | appearance/Field.qml | 50 | ||||
| -rw-r--r-- | appearance/Hypridle.qml | 161 | ||||
| -rw-r--r-- | appearance/Hyprsunset.qml | 326 | ||||
| -rw-r--r-- | appearance/Icons.qml | 334 | ||||
| -rw-r--r-- | appearance/IconsTab.qml | 225 | ||||
| -rw-r--r-- | appearance/IdleTab.qml | 100 | ||||
| -rw-r--r-- | appearance/MockScreen.qml | 94 | ||||
| -rw-r--r-- | appearance/README.md | 184 | ||||
| -rw-r--r-- | appearance/Steps.qml | 114 | ||||
| -rw-r--r-- | appearance/StepsWindow.qml | 197 | ||||
| -rw-r--r-- | appearance/SunsetTab.qml | 169 | ||||
| -rw-r--r-- | appearance/Tab.qml | 45 | ||||
| l--------- | appearance/Theme.qml | 1 | ||||
| -rw-r--r-- | appearance/Toggle.qml | 56 | ||||
| -rw-r--r-- | appearance/Udt.qml | 152 | ||||
| -rw-r--r-- | appearance/Wallpapers.qml | 243 | ||||
| -rw-r--r-- | appearance/shell.qml | 33 |
18 files changed, 0 insertions, 3146 deletions
diff --git a/appearance/AppearancePanel.qml b/appearance/AppearancePanel.qml deleted file mode 100644 index 77053b3..0000000 --- a/appearance/AppearancePanel.qml +++ /dev/null @@ -1,662 +0,0 @@ -// 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: "" - - readonly property var tabs: ["wallpaper", "theme", "sunset", "idle", "icons"] - readonly property var tabLabels: ({ - wallpaper: "Wallpaper", theme: "Theme", sunset: "Sunset", - idle: "Idle", icons: "Icons", - }) - - // 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 = ""; - // Schemes and wallpapers can both have changed since this shell - // started, which for an autostarted one is the whole session. - Udt.refresh(); - Wallpapers.refresh(); - Hyprsunset.refresh(); - Hypridle.refresh(); - Icons.refresh(); - 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 Hyprland, waybar, kitty and conky; these are - // what it cannot reach. - root.notice = ok - ? `${scheme} applied. Restart Qt, GTK, btop and Joplin; neovim needs :colorscheme udt.` - : `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) { - const i = root.tabs.indexOf(root.tab); - root.tab = root.tabs[(i + 1) % root.tabs.length]; - 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 - - // Centred against the panel, not against the hint: the row - // reads as the drawer's own chrome rather than the first - // line of a tab's content. - Item { - width: parent.width - height: tabBar.implicitHeight - - Row { - id: tabBar - anchors.horizontalCenter: parent.horizontalCenter - spacing: 8 - Repeater { - model: root.tabs - Tab { - required property var modelData - text: root.tabLabels[modelData] - selected: root.tab === modelData - onClicked: root.tab = modelData - } - } - } - - Text { - anchors.right: parent.right - 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 - // Existing tabs are inline Components; the new ones are - // files. sourceComponent wins when it is non-null. - sourceComponent: root.tab === "theme" ? themeTab - : root.tab === "wallpaper" ? wallpaperTab : null - source: root.tab === "sunset" ? Qt.resolvedUrl("SunsetTab.qml") - : root.tab === "idle" ? Qt.resolvedUrl("IdleTab.qml") - : root.tab === "icons" ? Qt.resolvedUrl("IconsTab.qml") : "" - } - } - } - } - } - - // --- 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 - } - } - - // A folder is a header row followed, while open, by its - // thumbnails a row at a time, all in one flat ListView, so only - // the rows in view (and cacheBuffer around them) exist and - // decode: a large folder loads as it scrolls. Each row holds a - // group key and the index of its first file, start -1 for the - // header. A toggle inserts or removes just that folder's rows, - // so the rows around it, their decoded thumbnails and the - // scroll position all survive it. - ListView { - id: wallList - width: parent.width - height: parent.height - y - clip: true - readonly property int cols: Math.max(1, Math.floor(width / 300)) - readonly property real cell: Math.floor(width / cols) - readonly property real thumbHeight: cell * 9 / 16 + 26 - // The column is laid out after creation and reads negative - // until then, which the buffer refuses with a warning. - cacheBuffer: Math.max(0, thumbHeight * 2) - model: ListModel { id: rows } - - function group(key) { - return Wallpapers.groups.find(g => g.key === key) ?? { key: key, name: "", files: [] }; - } - function rowsOf(g) { - const out = []; - for (let i = 0; i < g.files.length; i += cols) out.push({ key: g.key, start: i }); - return out; - } - function rebuild() { - rows.clear(); - for (const g of Wallpapers.groups) { - rows.append({ key: g.key, start: -1 }); - if (Wallpapers.expanded[g.key]) rows.append(rowsOf(g)); - } - } - function toggle(index) { - const g = group(rows.get(index).key); - const body = rowsOf(g); - if (Wallpapers.expanded[g.key]) rows.remove(index + 1, body.length); - else body.forEach((r, i) => rows.insert(index + 1 + i, r)); - Wallpapers.toggle(g.key); - } - - // The width settles after creation, and a new column count - // regroups every row, so both rebuild. - Component.onCompleted: rebuild() - onColsChanged: rebuild() - Connections { - target: Wallpapers - function onGroupsChanged() { wallList.rebuild(); } - } - - delegate: Item { - id: row - required property int index - required property string key - required property int start - readonly property var group: wallList.group(key) - readonly property bool open: !!Wallpapers.expanded[key] - width: ListView.view.width - height: start < 0 ? 40 : wallList.thumbHeight - - Rectangle { - visible: row.start < 0 - anchors { fill: parent; topMargin: 6 } - radius: 8 - color: Qt.alpha(Theme.surface, headerHover.hovered ? 0.8 : 0.4) - - HoverHandler { id: headerHover } - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: wallList.toggle(row.index) - } - - Text { - id: chevron - anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter } - text: row.open ? "\uf077" : "\uf078" - font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 } - color: Theme.subtext - } - Text { - anchors { left: chevron.right; leftMargin: 10; verticalCenter: parent.verticalCenter } - text: row.group.name - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } - color: Theme.text - } - Text { - anchors { right: parent.right; rightMargin: 12; verticalCenter: parent.verticalCenter } - text: row.group.files.length - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - color: Theme.overlay - } - } - - Row { - visible: row.start >= 0 - - Repeater { - model: row.start < 0 ? [] : row.group.files.slice(row.start, row.start + wallList.cols) - - delegate: Item { - required property string modelData - width: wallList.cell - height: wallList.thumbHeight - - 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) - // Staged wins the border; otherwise it - // says how well the image fills the - // screen it would go to. - border.width: 2 - border.color: staged ? Theme.accent - : Wallpapers.qualityColor(modelData, Wallpapers.target) - 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: wallList.cell - } - - Text { - width: parent.width - elide: Text.ElideMiddle - text: Wallpapers.label(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.label(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 - } - - // Why the image under consideration, hovered or else the - // one staged for the target screen, will look worse than - // its thumbnail. - Text { - readonly property string subject: root.hovering !== "" ? root.hovering - : Wallpapers.target === "V" ? Wallpapers.pendingV : Wallpapers.pendingH - anchors.horizontalCenter: parent.horizontalCenter - width: mockPane.width - visible: text !== "" - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.Wrap - text: subject !== "" ? Wallpapers.qualityNote(subject, Wallpapers.target) : "" - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } - color: Wallpapers.qualityColor(subject, Wallpapers.target) - } - - // wallhaven-dl searches wallhaven.cc and finds larger - // copies of low-res wallpapers. Launched through its - // desktop entry, which needs no ~/bin on quickshell's PATH - // and keeps the app's own taskbar icon. The panel closes - // so the app window is not opened under the overlay. - Tab { - anchors.horizontalCenter: parent.horizontalCenter - text: "Open wallhaven-dl" - onClicked: { - Quickshell.execDetached(["gtk-launch", "wallhaven-dl"]); - root.close(); - } - } - - 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/Field.qml b/appearance/Field.qml deleted file mode 100644 index 004bda6..0000000 --- a/appearance/Field.qml +++ /dev/null @@ -1,50 +0,0 @@ -// 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: field - property alias value: input.text - property string placeholder: "" - signal edited - - implicitWidth: 70 - height: 24 - radius: 5 - color: Qt.alpha(Theme.surface, 0.5) - border.width: 1 - border.color: input.activeFocus ? Qt.alpha(Theme.accent, 0.6) : Qt.alpha(Theme.text, 0.12) - - Text { - anchors.fill: parent - anchors.leftMargin: 6 - anchors.rightMargin: 6 - verticalAlignment: Text.AlignVCenter - visible: input.text === "" - text: field.placeholder - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - color: Theme.overlay - } - - TextInput { - id: input - anchors.fill: parent - anchors.leftMargin: 6 - anchors.rightMargin: 6 - verticalAlignment: TextInput.AlignVCenter - clip: true - selectByMouse: true - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - color: Theme.text - onEditingFinished: field.edited() - } -} diff --git a/appearance/Hypridle.qml b/appearance/Hypridle.qml deleted file mode 100644 index 1696f5f..0000000 --- a/appearance/Hypridle.qml +++ /dev/null @@ -1,161 +0,0 @@ -// 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 hypridle side. Commands are fixed: only each listener's timeout and -// whether it is active are editable. Everything before the first listener is -// kept verbatim so the general block and the rationale comments survive. -Singleton { - id: root - - readonly property string home: Quickshell.env("HOME") - readonly property string confPath: `${home}/.config/hypr/hypridle.conf` - - property string prefix: "" - property var listeners: [] - property string tail: "" - property string notice: "" - property bool loaded: false - - function getField(body, key) { - const m = body.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`, "m")); - return m ? m[1] : null; - } - - // A disabled listener is written every line prefixed with "# ". Strip it - // before reading fields. - function uncomment(body) { - return body.split("\n").map(l => l.replace(/^([ \t]*)#[ \t]?/, "$1")).join("\n"); - } - - // The closing brace is matched at the start of a line, because the dpms - // command contains a brace mid-line and a non-greedy match would stop - // inside it. A disabled listener has both its opening line and its closing - // brace commented, so the closing pattern allows a leading "#". The first - // listener's leading trivia is empty because its text is the prefix. - function parse(text) { - const re = /^([ \t]*)(#?)[ \t]*listener\s*\{([\s\S]*?)^[ \t]*#?[ \t]*\}/gm; - const found = []; - let firstStart = -1; - let prevEnd = 0; - let m; - while ((m = re.exec(text)) !== null) { - const start = m.index; - if (firstStart < 0) { firstStart = start; prevEnd = start; } - const leading = text.slice(prevEnd, start); - const enabled = m[2] !== "#"; - const body = enabled ? m[3] : root.uncomment(m[3]); - const t = root.getField(body, "timeout"); - found.push({ - leading: leading, - timeout: t === null ? 0 : parseInt(t, 10), - onTimeout: root.getField(body, "on-timeout") ?? "", - onResume: root.getField(body, "on-resume") ?? "", - enabled: enabled, - }); - prevEnd = start + m[0].length; - } - if (firstStart < 0) return { prefix: text, listeners: [], tail: "" }; - return { prefix: text.slice(0, firstStart), listeners: found, tail: text.slice(prevEnd) }; - } - - function serialize(prefix, list, tail) { - let out = prefix; - for (const l of list) { - const lines = ["listener {", ` timeout = ${l.timeout}`, - ` on-timeout = ${l.onTimeout}`]; - if (l.onResume) lines.push(` on-resume = ${l.onResume}`); - lines.push("}"); - const body = l.enabled ? lines.join("\n") : lines.map(x => "# " + x).join("\n"); - out += l.leading + body; - } - return out + tail; - } - - function describe(l) { - const cmd = l.onTimeout; - if (cmd.indexOf("notify-send") === 0) return "notify before lock"; - if (cmd.indexOf("loginctl lock-session") >= 0) return "lock session"; - if (cmd.indexOf("dpms") >= 0) return "monitors off"; - if (cmd.indexOf("loginctl suspend") >= 0) return "suspend"; - return cmd; - } - - function selftest(): string { - const fixture = String.raw`general { - lock_cmd = pidof hyprlock || hyprlock -} - -# 10:00s - screen lock -listener { - timeout = 600 - on-timeout = loginctl lock-session -} - -# 10:30s - monitor off -listener { - timeout = 630 - on-timeout = hyprctl dispatch "hl.dsp.dpms({ state = \"off\" })" - on-resume = hyprctl dispatch "hl.dsp.dpms({ state = \"on\" })" -} -`; - const p = root.parse(fixture); - if (p.listeners.length !== 2) return `SELFTEST Hypridle FAIL: ${p.listeners.length} listeners`; - if (p.listeners[0].timeout !== 600 || p.listeners[0].enabled !== true) - return "SELFTEST Hypridle FAIL: first listener"; - if (p.listeners[1].onTimeout.indexOf(String.raw`state = \"off\"`) < 0) - return "SELFTEST Hypridle FAIL: dpms command truncated"; - if (p.listeners[1].onResume.indexOf(String.raw`state = \"on\"`) < 0) - return "SELFTEST Hypridle FAIL: dpms on-resume lost"; - if (root.serialize(p.prefix, p.listeners, p.tail) !== fixture) - return "SELFTEST Hypridle FAIL: round-trip not byte-identical"; - const off = p.listeners.slice(); - off[1] = Object.assign({}, off[1], { enabled: false }); - const p2 = root.parse(root.serialize(p.prefix, off, p.tail)); - if (p2.listeners.length !== 2 || p2.listeners[1].enabled !== false || - p2.listeners[1].timeout !== 630) - return "SELFTEST Hypridle FAIL: disabled listener not round-tripped"; - return "SELFTEST Hypridle PASS"; - } - - function refresh() { - confProc.running = false; - confProc.running = true; - } - - Process { - id: confProc - command: ["cat", root.confPath] - stdout: StdioCollector { - onStreamFinished: { - const p = root.parse(text); - root.prefix = p.prefix; - root.listeners = p.listeners; - root.tail = p.tail; - root.loaded = true; - } - } - } - - FileView { id: confFile; path: root.confPath } - - function save() { - if (!root.loaded) return; // not loaded yet: writing would truncate the file - confFile.setText(root.serialize(root.prefix, root.listeners, root.tail)); - Quickshell.execDetached(["sh", "-c", "pkill -x hypridle; setsid -f hypridle"]); - root.notice = "saved; idle timers reset"; - } -} diff --git a/appearance/Hyprsunset.qml b/appearance/Hyprsunset.qml deleted file mode 100644 index fc0d44a..0000000 --- a/appearance/Hyprsunset.qml +++ /dev/null @@ -1,326 +0,0 @@ -// 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 hyprsunset side: profiles in ~/.config/hypr/hyprsunset.conf, the -// location in hyprsunset-qt's own config, and the daemon. The file format is -// byte-for-byte what ~/Programming/GIT/sunset-qt writes, so both tools edit -// the same file. -Singleton { - id: root - - readonly property string home: Quickshell.env("HOME") - readonly property string confPath: `${home}/.config/hypr/hyprsunset.conf` - readonly property string appConfPath: `${home}/.config/hyprsunset-qt/config` - - property var profiles: [] - property string lat: "" - property string lon: "" - property bool autoDetect: true - property string daemonCommand: "hyprsunset" - property string cachePathRaw: "~/.config/hyprsunset-qt/sun.json" - property string cachePath: `${home}/.config/hyprsunset-qt/sun.json` - property string notice: "" - property string sunSummary: "" - property bool busy: false - property bool confLoaded: false - property bool appLoaded: false - - readonly property string header: - "# Managed by hyprsunset-qt. Edits here are overwritten on save.\n" - - // QML has no String.matchAll; everything here is exec loops. - function getField(body, key) { - const m = body.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`, "m")); - return m ? m[1] : null; - } - - function parseProfiles(text) { - const list = []; - const re = /profile\s*\{([\s\S]*?)\}/g; - let m; - while ((m = re.exec(text)) !== null) { - const body = m[1]; - const t = root.getField(body, "temperature"); - const g = root.getField(body, "gamma"); - list.push({ - time: root.getField(body, "time") ?? "", - identity: (root.getField(body, "identity") ?? "").toLowerCase() === "true", - temperature: t === null ? null : parseInt(t, 10), - gamma: g === null ? null : parseFloat(g), - }); - } - return list; - } - - // Byte-for-byte the same construction as sunset-qt's config.serialize(): - // a list of parts joined with newlines, so a no-op save writes the file - // back exactly as it was. - function serializeProfiles(list) { - const di = root.dayIndex(list); - const ni = root.nightIndex(list); - const parts = [root.header]; - for (let i = 0; i < list.length; i++) { - const p = list[i]; - if (i === di) parts.push("\n# day profile -- sunrise"); - else if (i === ni) parts.push("\n# night profile -- sunset"); - else parts.push("\n# profile"); - const lines = ["profile {", ` time = ${p.time}`]; - if (p.identity) lines.push(" identity = true"); - if (p.temperature !== null && p.temperature !== undefined) - lines.push(` temperature = ${p.temperature}`); - if (p.gamma !== null && p.gamma !== undefined) - lines.push(` gamma = ${p.gamma}`); - lines.push("}"); - parts.push(lines.join("\n")); - } - return parts.join("\n") + "\n"; - } - - function dayIndex(list) { - for (let i = 0; i < list.length; i++) if (list[i].identity) return i; - return null; - } - - function nightIndex(list) { - for (let i = 0; i < list.length; i++) - if (list[i].temperature !== null && list[i].temperature !== undefined) return i; - return null; - } - - function validTime(s) { return /^([01]?\d|2[0-3]):([0-5]\d)$/.test(s); } - function validTemperature(t) { return t >= 1000 && t <= 20000; } - function validGamma(g) { return g >= 0.0 && g <= 2.0; } - - function localHM(iso) { - const d = new Date(iso); - if (isNaN(d.getTime())) return ""; - return ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2); - } - - function selftest(): string { - // A file exactly as hyprsunset-qt writes it. String.raw keeps the - // backslashes, none here, but keeps this fixture readable. - // Two blank lines after the header: sunset-qt's serializer joins parts - // with a newline and each part starts with one, so the file really has - // them. - const fixture = String.raw`# Managed by hyprsunset-qt. Edits here are overwritten on save. - - -# day profile -- sunrise -profile { - time = 05:42 - identity = true -} - -# night profile -- sunset -profile { - time = 21:02 - temperature = 5500 - gamma = 0.8 -} -`; - const parsed = root.parseProfiles(fixture); - if (parsed.length !== 2) return `SELFTEST Hyprsunset FAIL: parsed ${parsed.length} profiles`; - if (parsed[0].time !== "05:42" || parsed[0].identity !== true) - return "SELFTEST Hyprsunset FAIL: day profile wrong"; - if (parsed[1].temperature !== 5500 || parsed[1].gamma !== 0.8) - return "SELFTEST Hyprsunset FAIL: night profile wrong"; - if (root.dayIndex(parsed) !== 0 || root.nightIndex(parsed) !== 1) - return "SELFTEST Hyprsunset FAIL: day/night index wrong"; - if (root.serializeProfiles(parsed) !== fixture) - return "SELFTEST Hyprsunset FAIL: round-trip not byte-identical"; - if (!root.validTime("05:42") || root.validTime("24:00") || root.validTime("5:6")) - return "SELFTEST Hyprsunset FAIL: validTime"; - if (!root.validTemperature(1000) || root.validTemperature(20001)) - return "SELFTEST Hyprsunset FAIL: validTemperature"; - if (!root.validGamma(0.8) || root.validGamma(2.1)) - return "SELFTEST Hyprsunset FAIL: validGamma"; - return "SELFTEST Hyprsunset PASS"; - } - - function refresh() { - confProc.running = false; - confProc.running = true; - appProc.running = false; - appProc.running = true; - } - - Process { - id: confProc - command: ["cat", root.confPath] - stdout: StdioCollector { - onStreamFinished: { - root.profiles = root.parseProfiles(text); - root.confLoaded = true; - } - } - } - - // hyprsunset-qt's own settings: location + daemon command. - function parseAppConf(text) { - const out = {}; - let section = ""; - for (const raw of text.split("\n")) { - const line = raw.trim(); - if (!line || line.startsWith("#") || line.startsWith(";")) continue; - const sec = line.match(/^\[(.+)\]$/); - if (sec) { section = sec[1]; continue; } - const kv = line.match(/^([^=]+)=\s*(.*)$/); - if (kv) out[`${section}.${kv[1].trim()}`] = kv[2].trim(); - } - return out; - } - - function expandTilde(p) { - return p.startsWith("~/") ? root.home + p.slice(1) : p; - } - - Process { - id: appProc - command: ["cat", root.appConfPath] - stdout: StdioCollector { - onStreamFinished: { - const c = root.parseAppConf(text); - root.lat = c["location.lat"] ?? ""; - root.lon = c["location.lon"] ?? ""; - root.autoDetect = (c["location.auto_detect"] ?? "true") === "true"; - root.cachePathRaw = c["cache.path"] ?? "~/.config/hyprsunset-qt/sun.json"; - root.cachePath = root.expandTilde(root.cachePathRaw); - root.daemonCommand = c["daemon.command"] ?? "hyprsunset"; - root.appLoaded = true; - } - } - } - - FileView { id: confFile; path: root.confPath } - FileView { id: appFile; path: root.appConfPath } - FileView { id: cacheFile; path: root.cachePath } - - function appConfText() { - return `[location]\nlat = ${root.lat}\nlon = ${root.lon}\n` + - `auto_detect = ${root.autoDetect}\n\n` + - `[cache]\npath = ${root.cachePathRaw}\n\n` + - `[daemon]\ncommand = ${root.daemonCommand}\n\n`; - } - function saveSettings() { appFile.setText(root.appConfText()); } - - // hyprsunset holds Hyprland's CTM manager exclusively, and pkill only - // sends SIGTERM, so the replacement must wait for the old process to - // actually exit or it dies with "A CTM manager is already running". - function restart() { - Quickshell.execDetached(["sh", "-c", - `pkill -x hyprsunset; ` + - `for i in $(seq 50); do ` + - `pgrep -x hyprsunset >/dev/null 2>&1 || break; sleep 0.1; done; ` + - `setsid -f ${root.daemonCommand}`]); - } - - function save() { - if (!root.confLoaded || !root.appLoaded) return; - for (const p of root.profiles) { - if (!root.validTime(p.time)) { root.notice = `invalid time: ${p.time}`; return; } - if (p.temperature !== null && p.temperature !== undefined && - !root.validTemperature(p.temperature)) { - root.notice = `invalid temperature: ${p.temperature}`; return; - } - if (p.gamma !== null && p.gamma !== undefined && !root.validGamma(p.gamma)) { - root.notice = `invalid gamma: ${p.gamma}`; return; - } - } - confFile.setText(root.serializeProfiles(root.profiles)); - root.saveSettings(); - root.restart(); - root.notice = "saved + restarted"; - } - - // Live preview via the daemon's IPC. Identity wins, then temperature, then - // gamma, matching hyprsunset-qt. Nothing is written. - function preview() { - const i = root.nightIndex(root.profiles); - const target = i !== null ? root.profiles[i] - : (root.profiles.length ? root.profiles[0] : null); - if (!target) return; - if (target.identity) { - previewProc.command = ["hyprctl", "hyprsunset", "identity"]; - } else { - const parts = []; - if (target.temperature !== null && target.temperature !== undefined) - parts.push(`hyprctl hyprsunset temperature ${target.temperature}`); - if (target.gamma !== null && target.gamma !== undefined) - parts.push(`hyprctl hyprsunset gamma ${Math.round(target.gamma * 100)}`); - if (!parts.length) return; - previewProc.command = ["sh", "-c", parts.join("; ")]; - } - previewProc.running = false; - previewProc.running = true; - } - Process { id: previewProc } - - function detect() { - detectProc.running = false; - detectProc.running = true; - } - Process { - id: detectProc - command: ["curl", "-fsS", "http://ip-api.com/json"] - stdout: StdioCollector { - onStreamFinished: { - try { - const d = JSON.parse(text); - root.lat = String(d.lat); - root.lon = String(d.lon); - root.saveSettings(); - root.notice = `located ${root.lat}, ${root.lon}`; - } catch (e) { root.notice = `detect failed: ${e}`; } - } - } - } - - // sunrise-sunset.org is behind Cloudflare and 403s curl's default UA. - function fetchSun() { - const url = "https://api.sunrise-sunset.org/json?lat=" + - encodeURIComponent(root.lat) + "&lng=" + encodeURIComponent(root.lon) + - "&formatted=0"; - fetchProc.command = ["curl", "-fsS", "-A", - "Mozilla/5.0 (X11; Linux x86_64) hyprsunset-qt", url]; - fetchProc.running = false; - fetchProc.running = true; - root.busy = true; - } - Process { - id: fetchProc - stdout: StdioCollector { - onStreamFinished: { - root.busy = false; - let data; - try { data = JSON.parse(text); } - catch (e) { root.notice = `fetch failed: ${e}`; return; } - cacheFile.setText(JSON.stringify(data, null, 2)); - const r = data.results ?? {}; - const rise = r.sunrise ? root.localHM(r.sunrise) : ""; - const set = r.sunset ? root.localHM(r.sunset) : ""; - root.sunSummary = `sunrise ${rise || "—"} sunset ${set || "—"}`; - const di = root.dayIndex(root.profiles); - const ni = root.nightIndex(root.profiles); - const next = root.profiles.slice(); - if (di !== null && rise) next[di] = Object.assign({}, next[di], { time: rise }); - if (ni !== null && set) next[ni] = Object.assign({}, next[ni], { time: set }); - root.profiles = next; - } - } - } -} diff --git a/appearance/Icons.qml b/appearance/Icons.qml deleted file mode 100644 index a6eaec5..0000000 --- a/appearance/Icons.qml +++ /dev/null @@ -1,334 +0,0 @@ -// 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 icon and cursor themes installed on this machine. Previews are resolved -// from each theme, not just the active one: Quickshell.iconPath can only read -// the platform theme or QS_ICON_THEME, both fixed at load. -Singleton { - id: root - - readonly property string home: Quickshell.env("HOME") - readonly property string iconScript: ` -import sys -import gi -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk -SAMPLES = ["folder","text-x-generic","image-x-generic","network-wireless", - "audio-x-generic","video-x-generic","battery-full","printer"] -t = Gtk.IconTheme.new() -for name in sys.argv[1:]: - t.set_custom_theme(name) - for s in SAMPLES: - info = t.lookup_icon(s, 32, 0) - if info: - print("%s\\t%s\\t%s" % (name, s, info.get_filename())) - print("%s\\tEND\\t" % name) -` - // Four shapes per theme, so a card shows the cases that matter (arrow, - // hand, text, horizontal resize) instead of one ambiguous pointer. Each - // shape falls back through common aliases and through every storage form a - // theme may use: a hyprcursor .hlc zip, a shape directory with an SVG, or - // a legacy Xcursor binary that goes through xcur2png. - readonly property string cursorScript: ` -import sys, os, glob, shutil, zipfile, subprocess -outdir = sys.argv[1] -SAMPLES = { - "left_ptr": ["left_ptr", "default", "pointer"], - "hand2": ["hand2", "pointer", "hand"], - "xterm": ["xterm", "text", "ibeam"], - "resize": ["sb_h_double_arrow", "size_hor", "ew-resize", "h_double_arrow"], -} -roots = [os.path.expanduser("~/.icons"), - os.path.expanduser("~/.local/share/icons"), - "/usr/share/icons"] - -def pick(d, names): - for n in names: - for cand in (f"{d}/hyprcursors/{n}.hlc", f"{d}/hyprcursors/{n}/{n}.svg", - f"{d}/{n}.hlc", f"{d}/{n}/{n}.svg", f"{d}/cursors/{n}"): - if os.path.isfile(cand): - return cand - return None - -os.makedirs(outdir, exist_ok=True) -for name in sys.argv[2:]: - d = next((os.path.join(r, name) for r in roots if os.path.isdir(os.path.join(r, name))), None) - if not d: - continue - for shape, aliases in SAMPLES.items(): - c = pick(d, aliases) - if not c: - continue - out = f"{outdir}/{name}-{shape}.png" - try: - if c.endswith(".hlc"): - with zipfile.ZipFile(c) as z: - members = [e for e in z.namelist() if e.endswith(".svg")] - if not members: - members = [e for e in z.namelist() if e.endswith(".png")] - if not members: - continue - with open(out, "wb") as f: - f.write(z.read(members[0])) - elif c.endswith(".svg"): - shutil.copyfile(c, out) - else: - raw = f"{outdir}/raw-{name}-{shape}" - shutil.rmtree(raw, ignore_errors=True) - os.makedirs(raw, exist_ok=True) - subprocess.run(["xcur2png", "-d", raw, "-c", f"{raw}/out.conf", c], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - pngs = sorted(glob.glob(f"{raw}/*_*.png")) - if not pngs: - continue - shutil.copyfile(pngs[-1], out) - print(f"{name}\\t{shape}\\t{out}") - except Exception: - continue -` - - property var iconThemes: [] - property var cursorThemes: [] - property string currentIcon: "" - property string currentCursor: "" - property int cursorSize: 24 - property var iconPreview: ({}) - property var cursorPreview: ({}) - property bool scanning: true - property string notice: "" - - // Pure: classify works on entries shaped { name, index, cursors, manifest } - // so it can be tested without touching the filesystem. - function classify(entries) { - const icons = [], cursors = []; - for (const e of entries) { - if (e.cursors || e.manifest) cursors.push(e.name); - if (e.index && /Directories=\S/.test(e.index)) icons.push(e.name); - } - const uniq = a => a.filter((v, i) => a.indexOf(v) === i).sort(); - return { icons: uniq(icons), cursors: uniq(cursors) }; - } - - function selftest(): string { - const entries = [ - { name: "Material-Black-Plum-Suru", index: "Directories=32x32/apps\n", cursors: false, manifest: false }, - { name: "hypr_bibata-modern-amber", index: "", cursors: false, manifest: true }, - { name: "default", index: "Inherits=Bibata-Modern-Amber\n", cursors: false, manifest: false }, - { name: "breeze_cursors", index: "", cursors: true, manifest: false }, - { name: "Adwaita", index: "Directories=16x16/apps\n", cursors: true, manifest: false }, - ]; - const r = root.classify(entries); - if (r.icons.length !== 2 || r.icons.indexOf("Adwaita") < 0 || - r.icons.indexOf("Material-Black-Plum-Suru") < 0) - return `SELFTEST Icons FAIL: icons ${JSON.stringify(r.icons)}`; - if (r.cursors.length !== 3 || r.cursors.indexOf("hypr_bibata-modern-amber") < 0 || - r.cursors.indexOf("breeze_cursors") < 0 || r.cursors.indexOf("Adwaita") < 0) - return `SELFTEST Icons FAIL: cursors ${JSON.stringify(r.cursors)}`; - return "SELFTEST Icons PASS"; - } - - function refresh() { - scanProc.running = false; - scanProc.running = true; - curProc.running = false; - curProc.running = true; - } - - // One shell pass over every theme root. Format per line: - // name|hasIndex|cursors|manifest|Directories= - Process { - id: scanProc - command: ["sh", "-c", - `for d in ${root.home}/.icons/* ${root.home}/.local/share/icons/* ` + - `/usr/share/icons/*; do [ -d "$d" ] || continue; ` + - `[ -L "$d" ] && continue; ` + - `i=0; c=0; m=0; dirs=""; ` + - `[ -d "$d/cursors" ] && c=1; ` + - `[ -f "$d/manifest.hl" ] && m=1; ` + - `if [ -f "$d/index.theme" ]; then i=1; dirs=$(sed -n 's/^Directories=//p' "$d/index.theme"); fi; ` + - `echo "$(basename "$d")|$i|$c|$m|$dirs"; done`] - stdout: StdioCollector { - onStreamFinished: { - const entries = []; - for (const line of text.trim().split("\n")) { - const p = line.split("|"); - if (p.length < 5) continue; - entries.push({ name: p[0], index: p[1] === "1" ? "Directories=" + (p[4] || "x") : "", - cursors: p[2] === "1", manifest: p[3] === "1" }); - } - const r = root.classify(entries); - root.iconThemes = r.icons; - root.cursorThemes = r.cursors; - root.scanning = false; - if (root.iconThemes.length) root.previewIcons(); - if (root.cursorThemes.length) root.previewCursors(); - } - } - } - - // One python process for every icon theme, so opening the tab costs one - // spawn, not one per theme. - function previewIcons() { - if (!root.iconThemes.length) return; - iconProc.command = ["python3", "-c", root.iconScript].concat(root.iconThemes); - iconProc.running = false; - iconProc.running = true; - } - Process { - id: iconProc - stdout: StdioCollector { - onStreamFinished: { - const next = {}; - for (const line of text.split("\n")) { - const p = line.split("\t"); - if (p.length < 2 || p[1] === "END" || !p[2]) continue; - if (!next[p[0]]) next[p[0]] = {}; - next[p[0]][p[1]] = p[2]; - } - root.iconPreview = next; - } - } - } - - // gsettings for the current values. - Process { - id: curProc - command: ["sh", "-c", - `gsettings get org.gnome.desktop.interface icon-theme; ` + - // The system's cursor theme is often the `default` alias, a symlink - // to the real theme. The scan skips symlinked dirs, so resolve the - // current value to the target name or nothing would read as current. - `ct=$(gsettings get org.gnome.desktop.interface cursor-theme | sed "s/'//g"); ` + - `for r in ${root.home}/.icons ${root.home}/.local/share/icons /usr/share/icons; do ` + - `[ -L "$r/$ct" ] && ct=$(basename "$(readlink -f "$r/$ct")") && break; done; ` + - `printf '%s\\n' "$ct"; ` + - `gsettings get org.gnome.desktop.interface cursor-size`] - stdout: StdioCollector { - onStreamFinished: { - const lines = text.trim().split("\n"); - root.currentIcon = (lines[0] ?? "").replace(/'/g, ""); - root.currentCursor = (lines[1] ?? "").replace(/'/g, ""); - const size = parseInt(lines[2] ?? "", 10); - if (!isNaN(size)) root.cursorSize = size; - } - } - } - - // One python pass extracts four shapes for EVERY cursor theme and prints - // name<TAB>shape<TAB>path, so the whole row paints on open instead of - // waiting for a hover per card. A theme missing a shape simply shows fewer - // images rather than a broken one. - function previewCursors() { - if (!root.cursorThemes.length) return; - cursorProc.command = ["python3", "-c", root.cursorScript, - Quickshell.cachePath("cursors")].concat(root.cursorThemes); - cursorProc.running = false; - cursorProc.running = true; - } - Process { - id: cursorProc - stdout: StdioCollector { - onStreamFinished: { - const next = {}; - for (const line of text.split("\n")) { - const p = line.split("\t"); - if (p.length < 3 || !p[0] || !p[1] || !p[2]) continue; - if (!next[p[0]]) next[p[0]] = {}; - next[p[0]][p[1]] = p[2]; - } - root.cursorPreview = next; - } - } - } - - // Writes go through FileView so no shell quoting is involved. They are - // 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 } - // 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 } - FileView { id: gtk4File; path: `${root.home}/.config/gtk-4.0/settings.ini`; blockLoading: true } - - // Replaces a `key=value` line in both GTK settings files. A key the file - // does not carry is left alone, the same as the Qt configs. - function rewriteGtk(key, value) { - const files = [gtk3File, gtk4File]; - const re = new RegExp(`^${key}=.*`, "m"); - for (let i = 0; i < files.length; i++) { - const text = files[i].text(); - if (text !== "") files[i].setText(text.replace(re, `${key}=${value}`)); - } - } - - function gsettingsSet(key, value) { - gsetProc.command = ["gsettings", "set", "org.gnome.desktop.interface", key, value]; - gsetProc.running = false; - gsetProc.running = true; - } - Process { id: gsetProc } - - // The SDDM power icons resolve icon names from gsettings when udt-accent - // generates them, not when they run, so they are regenerated here, - // chained after the gsettings write they read. The island resolves its - // icons live and needs nothing. - readonly property string udtRepo: `${root.home}/Programming/GIT/unified-desktop-theme` - - function applyIcon(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`, - "sh", name]); - const files = [qt6File, qt5File]; - for (let i = 0; i < files.length; i++) { - const text = files[i].text(); - if (text !== "") files[i].setText(text.replace(/^icon_theme=.*/m, `icon_theme=${name}`)); - } - root.rewriteGtk("gtk-icon-theme-name", name); - root.currentIcon = name; - 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); - root.currentCursor = name; - root.writeCursor(); - root.rewriteGtk("gtk-cursor-theme-name", name); - root.rewriteGtk("gtk-cursor-theme-size", root.cursorSize); - root.notice = `${name} set live at ${root.cursorSize}px. cursor.lua updated for next login.`; - } - - function setCursorSize(size) { - root.cursorSize = size; - // 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)); - root.writeCursor(); - root.rewriteGtk("gtk-cursor-theme-size", size); - root.notice = `cursor size ${size}px. cursor.lua updated for next login.`; - } -} diff --git a/appearance/IconsTab.qml b/appearance/IconsTab.qml deleted file mode 100644 index c4d82ca..0000000 --- a/appearance/IconsTab.qml +++ /dev/null @@ -1,225 +0,0 @@ -// 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.Widgets -import QtQuick - -Flickable { - contentHeight: col.implicitHeight - clip: true - - // Clicking a cursor card stages it; Apply commits. There is deliberately no - // live hover preview: hyprctl setcursor is not a read-only switch, Hyprland - // persists the theme to org.gnome.desktop.interface cursor-theme, so - // hovering wrote the setting and a close left the last hovered theme set. - // The four rendered shapes on each card are the preview. - property string pendingCursor: "" - - Column { - id: col - width: parent.width - spacing: 12 - - Text { - text: Icons.scanning ? "scanning…" : `${Icons.iconThemes.length} icon themes · ${Icons.cursorThemes.length} cursor themes` - color: Theme.overlay - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - } - - Text { - text: "Icon theme" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } - } - - // Flow, not a horizontal strip: the cards wrap onto further rows rather - // than running off the right edge, and the tab scrolls vertically. - Flow { - width: parent.width - spacing: 10 - Repeater { - model: Icons.iconThemes - Rectangle { - id: iconCard - required property string modelData - readonly property bool current: Icons.currentIcon === modelData - width: 230; height: 150; radius: 10 - color: current ? Qt.alpha(Theme.accent, 0.2) : Qt.alpha(Theme.surface, 0.35) - border.width: current ? 2 : 1 - border.color: current ? Theme.accent : "transparent" - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: Icons.applyIcon(modelData) - } - - Column { - width: parent.width - 16 - anchors.centerIn: parent - spacing: 10 - Row { - anchors.horizontalCenter: parent.horizontalCenter - spacing: 8 - Repeater { - model: ["folder", "text-x-generic", "image-x-generic", "network-wireless"] - IconImage { - required property string modelData - readonly property string glyph: (Icons.iconPreview[iconCard.modelData] ?? {})[modelData] ?? "" - // Absolute paths must carry file://, or they - // resolve against the config's qrc base and - // fail; Gtk's builtin icons come back as - // /org/gtk gresource paths Qt cannot open. - source: glyph.indexOf("/") === 0 && glyph.indexOf("/org/gtk/") !== 0 - ? "file://" + glyph : "" - implicitSize: 44 - } - } - } - Text { - width: parent.width - horizontalAlignment: Text.AlignHCenter - text: iconCard.current ? modelData + " current" : modelData - color: Theme.text - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - wrapMode: Text.WrapAnywhere - maximumLineCount: 2 - elide: Text.ElideRight - } - } - } - } - } - - Row { - spacing: 10 - Text { - anchors.verticalCenter: parent.verticalCenter - text: "Cursor theme" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } - } - Item { width: 12; height: 1 } - Text { - anchors.verticalCenter: parent.verticalCenter - text: "Size" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - } - Repeater { - model: [16, 24, 32, 48, 64] - Tab { - required property var modelData - anchors.verticalCenter: parent.verticalCenter - text: String(modelData) - selected: Icons.cursorSize === modelData - onClicked: Icons.setCursorSize(modelData) - } - } - } - - Flow { - width: parent.width - spacing: 10 - Repeater { - model: Icons.cursorThemes - Rectangle { - id: cursorCard - required property string modelData - readonly property bool current: Icons.currentCursor === modelData - readonly property bool staged: pendingCursor === modelData - width: 230; height: 150; radius: 10 - color: current || staged ? Qt.alpha(Theme.accent, 0.2) : Qt.alpha(Theme.surface, 0.35) - border.width: current || staged ? 2 : 1 - border.color: staged ? Theme.accent - : current ? Qt.alpha(Theme.accent, 0.5) - : "transparent" - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: pendingCursor = modelData - } - - Column { - width: parent.width - 16 - anchors.centerIn: parent - spacing: 10 - Row { - anchors.horizontalCenter: parent.horizontalCenter - spacing: 8 - Repeater { - // The cases that tell themes apart: arrow, hand, - // text and horizontal resize. - model: ["left_ptr", "hand2", "xterm", "resize"] - Image { - required property string modelData - readonly property string p: (Icons.cursorPreview[cursorCard.modelData] ?? {})[modelData] ?? "" - width: 44; height: 44 - asynchronous: true - source: p !== "" ? "file://" + p : "" - fillMode: Image.PreserveAspectFit - } - } - } - Text { - width: parent.width - horizontalAlignment: Text.AlignHCenter - text: cursorCard.staged ? modelData + " staged" - : cursorCard.current ? modelData + " current" - : modelData - color: Theme.text - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - wrapMode: Text.WrapAnywhere - maximumLineCount: 2 - elide: Text.ElideRight - } - } - } - } - } - - Row { - spacing: 8 - Text { - anchors.verticalCenter: parent.verticalCenter - text: pendingCursor !== "" ? "click Apply to set" : "click a cursor theme to stage it" - color: Theme.overlay - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - } - Tab { - text: "Apply" - selected: pendingCursor !== "" - enabled: pendingCursor !== "" - onClicked: { - Icons.applyCursor(pendingCursor); - pendingCursor = ""; - } - } - Tab { - text: "Reset" - enabled: pendingCursor !== "" - onClicked: pendingCursor = "" - } - } - - Text { - width: parent.width - wrapMode: Text.Wrap - visible: Icons.notice !== "" - text: Icons.notice - color: Theme.green - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - } - } - -} diff --git a/appearance/IdleTab.qml b/appearance/IdleTab.qml deleted file mode 100644 index 75ce84a..0000000 --- a/appearance/IdleTab.qml +++ /dev/null @@ -1,100 +0,0 @@ -// 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 - -Flickable { - contentHeight: col.implicitHeight - clip: true - - Column { - id: col - width: parent.width - spacing: 10 - - Text { - text: "Commands are fixed. Only the timeout and whether a listener runs are editable." - color: Theme.overlay - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - } - - Repeater { - model: Hypridle.listeners - Rectangle { - required property var modelData - required property int index - width: col.width - implicitHeight: 40 - radius: 8 - color: Qt.alpha(Theme.surface, modelData.enabled ? 0.4 : 0.2) - opacity: modelData.enabled ? 1 : 0.6 - - Row { - anchors.fill: parent - anchors.margins: 8 - spacing: 10 - - Toggle { - anchors.verticalCenter: parent.verticalCenter - checked: modelData.enabled - onToggled: Hypridle.listeners = Hypridle.listeners.map( - (l, i) => i === index ? Object.assign({}, l, { enabled: checked }) : l) - } - Text { - anchors.verticalCenter: parent.verticalCenter - width: 150 - text: Hypridle.describe(modelData) - color: Theme.text - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - } - Field { - anchors.verticalCenter: parent.verticalCenter - width: 70 - value: String(modelData.timeout) - placeholder: "seconds" - onEdited: Hypridle.listeners = Hypridle.listeners.map( - (l, i) => i === index - ? Object.assign({}, l, { timeout: parseInt(value, 10) || 0 }) : l) - } - Text { - anchors.verticalCenter: parent.verticalCenter - text: `${Math.floor(modelData.timeout / 60)}:${String(modelData.timeout % 60).padStart(2, "0")}` - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - } - Text { - anchors.verticalCenter: parent.verticalCenter - width: parent.width - 380 - elide: Text.ElideRight - text: modelData.onTimeout - color: Theme.overlay - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } - } - } - } - } - - Tab { - text: "Save + Restart hypridle" - selected: true - onClicked: Hypridle.save() - } - - Text { - width: parent.width - wrapMode: Text.Wrap - visible: Hypridle.notice !== "" - text: Hypridle.notice - color: Theme.green - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - } - } -} diff --git a/appearance/MockScreen.qml b/appearance/MockScreen.qml deleted file mode 100644 index 7a09ee2..0000000 --- a/appearance/MockScreen.qml +++ /dev/null @@ -1,94 +0,0 @@ -// 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 deleted file mode 100644 index 7524322..0000000 --- a/appearance/README.md +++ /dev/null @@ -1,184 +0,0 @@ -# appearance - -Wallpapers and colour scheme in one drawer. `SUPER+Return` opens it on the -Wallpaper tab; Tab switches tabs, Escape closes. - - ┌─[ Wallpaper ]─[ Theme ]─[ Sunset ]─[ Idle ]─[ Icons ]──┐ - │ Set on [Horizontal] [Vertical] 1161 wallpapers │ - │ ▸ wallpapers 16 ┌──┐ ┌─────┐ │ - │ ▾ SFW 30 │ │ │ │ │ - │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ └┬─┘ └──┬──┘ │ - │ │ │ │ │ │ │ │ │ ═╧═ ══╧══ │ - │ └────┘ └────┘ └────┘ └────┘ enlarged 1.6×: … │ - │ ▸ system 727 [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 Theme tab. - -IPC verbs: `wallpaper`, `theme`, `sunset`, `idle`, `icons`, `steps`. - -## Sunset - -A port of `hyprsunset-qt` (`~/Programming/GIT/sunset-qt`): profiles in -`~/.config/hypr/hyprsunset.conf`, location in -`~/.config/hyprsunset-qt/config`, sunrise/sunset from the same API and cache. -Both apps read and write the identical file format, so either can edit it. - -## Idle - -Timeout and enabled only; the commands in `~/.config/hypr/hypridle.conf` are -fixed. Everything before the first `listener` (the `general` block and the -comments explaining the design) is preserved verbatim. A disabled listener is -written commented out. Save restarts `hypridle`, which resets its timers. - -## Icons - -Switches the icon and cursor theme, and the pointer size. Icon previews come -from a GTK lookup per theme. Cursor previews come from one python pass per -open: for every theme it extracts four shapes (arrow, hand, text, horizontal -resize) out of a hyprcursor `.hlc`, a shape directory's SVG, or a legacy -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, `~/.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. - -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. It is chained after the `gsettings` write in one shell, -since it reads it. Waybar is gone, so the icon switch no longer reruns -waybar-theme-udt's `install.sh` or signals waybar. - -## 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. - -The thumbnails are grouped by folder, each a header that opens and closes -it, closed to begin with and remembered while the shell runs. The local -groups are the top-level folders of `~/Pictures/wallpapers`, with the images -loose in it as a group of its own; after them come the distribution's, -`/usr/share/wallpapers`, as `system` for its loose images and `Plasma` for -its packages. A Plasma package holds one picture at several resolutions, -some in a dark version too, so it shows once: dark where there is one, then -the largest, named after the package rather than its `5120x2880.png`. - -The list is flat, one row of thumbnails per item, so only the rows in view -exist and decode, and a folder of hundreds loads as it scrolls. Opening or -closing a folder inserts or removes just its rows, which keeps the scroll -position and the thumbnails around it. A first version made each folder one -item, which loaded every thumbnail of a folder the moment it opened. - -Each thumbnail's border says how it will look on the targeted screen, from -its real size, which `file` reads from the header after each scan (half a -second for a thousand). `swaybg -m fill` scales to cover, so the measure is -how far it enlarges: not at all is green, up to 1.33x yellow, past that red. -Under the mock screens a note in the same colour says why, for the hovered -image or else the staged one. The staged image keeps the accent border. - -"Open wallhaven-dl" under the mock screens launches the wallhaven-dl app, -which searches wallhaven.cc and finds larger copies of low-res wallpapers, -and closes the panel. It goes through `gtk-launch` and the app's desktop -entry, so `~/bin` need not be on quickshell's PATH. - -`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. -Both the scheme list and the wallpaper scan are redone every time the panel -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 and reloads Hyprland, -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, shown first as "General recap". 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 - -**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/udt/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/Steps.qml b/appearance/Steps.qml deleted file mode 100644 index ec0a5b7..0000000 --- a/appearance/Steps.qml +++ /dev/null @@ -1,114 +0,0 @@ -// 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, label, blocks: [{ cmd, text }] }]: the install.sh recap first, - // then one tab per consumer by name. A file with no label here shows - // under its own name, so a new producer needs no edit. - property var tabs: [] - readonly property var labels: ({ - install: "General recap", grub: "GRUB", sddm: "SDDM", homepage: "Homepage", - }) - 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"); - const name = rec.slice(0, nl); - return { name: name, label: root.labels[name] ?? name, - blocks: root.parse(rec.slice(nl + 1)) }; - }).sort((a, b) => (b.name === "install") - (a.name === "install") - || a.label.localeCompare(b.label)); - 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 deleted file mode 100644 index 8117885..0000000 --- a/appearance/StepsWindow.qml +++ /dev/null @@ -1,197 +0,0 @@ -// 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.label - 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/SunsetTab.qml b/appearance/SunsetTab.qml deleted file mode 100644 index 50f6ad6..0000000 --- a/appearance/SunsetTab.qml +++ /dev/null @@ -1,169 +0,0 @@ -// 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 - -Flickable { - contentHeight: col.implicitHeight - clip: true - - Column { - id: col - width: parent.width - spacing: 12 - - Row { - spacing: 8 - Text { - anchors.verticalCenter: parent.verticalCenter - text: "Lat" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - } - Field { width: 90; value: Hyprsunset.lat; onEdited: Hyprsunset.lat = value; placeholder: "lat" } - Text { - anchors.verticalCenter: parent.verticalCenter - text: "Lon" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - } - Field { width: 90; value: Hyprsunset.lon; onEdited: Hyprsunset.lon = value; placeholder: "lon" } - Toggle { - anchors.verticalCenter: parent.verticalCenter - checked: Hyprsunset.autoDetect - label: "auto" - onToggled: Hyprsunset.autoDetect = checked - } - Tab { text: "Detect"; onClicked: Hyprsunset.detect() } - Tab { text: Hyprsunset.busy ? "fetching…" : "Fetch sun times"; onClicked: Hyprsunset.fetchSun() } - Text { - anchors.verticalCenter: parent.verticalCenter - text: Hyprsunset.sunSummary - color: Theme.overlay - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - } - } - - Repeater { - model: Hyprsunset.profiles - Rectangle { - required property var modelData - required property int index - width: col.width - implicitHeight: 40 - radius: 8 - color: Qt.alpha(Theme.surface, 0.35) - - Row { - anchors.fill: parent - anchors.margins: 8 - spacing: 8 - - Text { - anchors.verticalCenter: parent.verticalCenter - text: index === Hyprsunset.dayIndex(Hyprsunset.profiles) ? "day" - : index === Hyprsunset.nightIndex(Hyprsunset.profiles) ? "night" : "profile" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - } - Field { - anchors.verticalCenter: parent.verticalCenter - width: 60 - value: modelData.time - placeholder: "HH:MM" - onEdited: Hyprsunset.profiles = Hyprsunset.profiles.map( - (p, i) => i === index ? Object.assign({}, p, { time: value }) : p) - } - Toggle { - anchors.verticalCenter: parent.verticalCenter - checked: modelData.identity - label: "identity" - onToggled: Hyprsunset.profiles = Hyprsunset.profiles.map( - (p, i) => i === index ? Object.assign({}, p, { identity: checked }) : p) - } - Toggle { - anchors.verticalCenter: parent.verticalCenter - checked: modelData.temperature !== null && modelData.temperature !== undefined - label: "temp" - onToggled: Hyprsunset.profiles = Hyprsunset.profiles.map( - (p, i) => i === index ? Object.assign({}, p, - { temperature: checked ? (p.temperature ?? 5500) : null }) : p) - } - Field { - anchors.verticalCenter: parent.verticalCenter - width: 70 - value: modelData.temperature === null || modelData.temperature === undefined - ? "" : String(modelData.temperature) - placeholder: "5500" - onEdited: Hyprsunset.profiles = Hyprsunset.profiles.map( - (p, i) => i === index ? Object.assign({}, p, - { temperature: value === "" ? null : parseInt(value, 10) }) : p) - } - Toggle { - anchors.verticalCenter: parent.verticalCenter - checked: modelData.gamma !== null && modelData.gamma !== undefined - label: "gamma" - onToggled: Hyprsunset.profiles = Hyprsunset.profiles.map( - (p, i) => i === index ? Object.assign({}, p, - { gamma: checked ? (p.gamma ?? 1.0) : null }) : p) - } - Field { - anchors.verticalCenter: parent.verticalCenter - width: 60 - value: modelData.gamma === null || modelData.gamma === undefined - ? "" : String(modelData.gamma) - placeholder: "0.8" - onEdited: Hyprsunset.profiles = Hyprsunset.profiles.map( - (p, i) => i === index ? Object.assign({}, p, - { gamma: value === "" ? null : parseFloat(value) }) : p) - } - Item { width: 8; height: 1 } - Tab { - anchors.verticalCenter: parent.verticalCenter - text: "✕" - onClicked: Hyprsunset.profiles = - Hyprsunset.profiles.filter((p, i) => i !== index) - } - // The Row is pinned to the card width, so parent.width is - // real, but bound it anyway: a narrow enough panel would - // make the subtraction negative and put the Row in a - // negative-width state. - Item { width: Math.max(0, parent.width - 640); height: 1 } - } - } - } - - Row { - spacing: 8 - Tab { - text: "+ Add profile" - onClicked: Hyprsunset.profiles = - Hyprsunset.profiles.concat([{ time: "0:00", identity: false, - temperature: null, gamma: null }]) - } - Tab { text: "Live preview"; onClicked: Hyprsunset.preview() } - Tab { - text: "Save + Restart" - selected: true - onClicked: Hyprsunset.save() - } - } - - Text { - width: parent.width - wrapMode: Text.Wrap - visible: Hyprsunset.notice !== "" - text: Hyprsunset.notice - color: Hyprsunset.notice.indexOf("invalid") === 0 ? Theme.red : Theme.green - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - } - } -} diff --git a/appearance/Tab.qml b/appearance/Tab.qml deleted file mode 100644 index bb2d210..0000000 --- a/appearance/Tab.qml +++ /dev/null @@ -1,45 +0,0 @@ -// 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 deleted file mode 120000 index 3d2e40f..0000000 --- a/appearance/Theme.qml +++ /dev/null @@ -1 +0,0 @@ -../shared/Theme.qml
\ No newline at end of file diff --git a/appearance/Toggle.qml b/appearance/Toggle.qml deleted file mode 100644 index 2bb56a2..0000000 --- a/appearance/Toggle.qml +++ /dev/null @@ -1,56 +0,0 @@ -// 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: toggle - property bool checked: false - property string label: "" - signal toggled - - implicitWidth: row.implicitWidth - implicitHeight: 22 - color: "transparent" - - Row { - id: row - anchors.verticalCenter: parent.verticalCenter - spacing: 6 - Rectangle { - anchors.verticalCenter: parent.verticalCenter - width: 16; height: 16; radius: 4 - color: toggle.checked ? Qt.alpha(Theme.accent, 0.7) : Qt.alpha(Theme.surface, 0.5) - border.width: 1 - border.color: toggle.checked ? Qt.alpha(Theme.accent, 0.9) : Qt.alpha(Theme.text, 0.2) - Text { - anchors.centerIn: parent - visible: toggle.checked - text: "✓" - font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 6; bold: true } - color: Theme.base - } - } - Text { - anchors.verticalCenter: parent.verticalCenter - visible: toggle.label !== "" - text: toggle.label - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - color: Theme.subtext - } - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: { toggle.checked = !toggle.checked; toggle.toggled(); } - } -} diff --git a/appearance/Udt.qml b/appearance/Udt.qml deleted file mode 100644 index cf97ba5..0000000 --- a/appearance/Udt.qml +++ /dev/null @@ -1,152 +0,0 @@ -// 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. - // Rescanned whenever the panel opens: adding a scheme is adding two - // files, and a shell that has been running since before they appeared - // would otherwise keep showing the old list. - function refresh() { - listProc.running = false; - listProc.running = true; - } - - 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; - // install.sh also reloads what is running, and hands its whole output to - // the steps window's `install` tab itself. - 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 >/dev/null 2>&1`]; - applyProc.running = true; - } - - Process { - id: applyProc - property string scheme: "" - onExited: code => { - root.applying = false; - root.applied(applyProc.scheme, code === 0, - code === 0 ? "" : `install.sh exited ${code}, its output is in the steps window`); - } - } -} diff --git a/appearance/Wallpapers.qml b/appearance/Wallpapers.qml deleted file mode 100644 index 9f43675..0000000 --- a/appearance/Wallpapers.qml +++ /dev/null @@ -1,243 +0,0 @@ -// 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` - // The distribution's wallpapers: loose images, and Plasma packages that - // each carry one picture at several resolutions under contents/images/, - // some with a dark version under contents/images_dark/. - readonly property string sysDir: "/usr/share/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. - function refresh() { - scanning = true; - scanProc.running = false; - scanProc.running = true; - } - - Process { - id: scanProc - running: true - // img DIR "DEPTH" [ACTIONS...]: the image files under DIR. DEPTH is - // left unquoted on purpose, so "-maxdepth 2" splits into two words - // and "" vanishes. - command: ["sh", "-c", - `img() { d=$1; o=$2; shift 2; find "$d" $o -type f ` + - `\\( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.webp' \\) "$@" 2>/dev/null; }; ` + - `img ${JSON.stringify(root.dir)} "-maxdepth 2" -not -path '*/.*' -printf '%T@ %p\\n' | sort -rn | cut -d' ' -f2-; ` + - `img ${JSON.stringify(root.sysDir)} "-maxdepth 1" | sort; ` + - `img ${JSON.stringify(root.sysDir)} "" -path '*/contents/images*/*'`] - stdout: StdioCollector { - onStreamFinished: { - root.files = text.trim().split("\n").filter(s => s.length); - root.scanning = false; - sizeProc.running = false; - sizeProc.command = ["sh", "-c", 'printf "%s\\0" "$@" | xargs -0 -r file -N -0', "sh"].concat(root.files); - sizeProc.running = true; - } - } - } - - // Each file's pixel size, by path, from `file`, which reads only the - // header: about half a second for a thousand. -0 ends the name with a - // NUL, since a name can hold the ": " that would otherwise end it. The - // size is the last WxH in the description, because a JPEG's comes after - // its "density 72x72". A file `file` gives no size for has no entry and - // gets no quality colour. - property var sizes: ({}) - Process { - id: sizeProc - stdout: StdioCollector { - onStreamFinished: { - const out = {}; - for (const line of text.split("\n")) { - const nul = line.indexOf("\0"); - if (nul < 0) continue; - const re = /(\d+) ?x ?(\d+)/g; - let m, last = null; - while ((m = re.exec(line.slice(nul + 1))) !== null) last = m; - if (last) out[line.slice(0, nul)] = { w: +last[1], h: +last[2] }; - } - root.sizes = out; - } - } - } - - // The screens wallp sets, as the mock draws them: DP-1 wide, DP-3 turned - // upright. - // ponytail: fixed, like the mock; read Quickshell.screens if a monitor - // changes. - readonly property var panels: ({ H: { w: 2560, h: 1080 }, V: { w: 1080, h: 1920 } }) - - // How far swaybg's `-m fill` has to enlarge an image to cover a screen, - // 0 when the size is unknown. At or under 1 it is downscaled and sharp; - // to 1.33 the softening is slight; past that it shows pixels. - function upscale(path, which) { - const s = sizes[path]; - const p = panels[which]; - return s && s.w > 0 && s.h > 0 ? Math.max(p.w / s.w, p.h / s.h) : 0; - } - function qualityColor(path, which) { - const u = upscale(path, which); - return u === 0 ? "transparent" : u <= 1 ? Theme.green : u <= 1.33 ? Theme.yellow : Theme.red; - } - // Why an image that fits will still look bad, or "" when it will not. - function qualityNote(path, which) { - const u = upscale(path, which); - if (u <= 1) return ""; - const s = sizes[path], p = panels[which]; - return `${s.w}×${s.h} is enlarged ${u.toFixed(2)}× to cover the ${p.w}×${p.h} screen: ` - + (u <= 1.33 ? "each image pixel is spread over more than one screen pixel, so it will look slightly soft." - : "each image pixel becomes a visible block, so it will look pixelated."); - } - - function basename(path) { return path.slice(path.lastIndexOf("/") + 1); } - - // The files by the subfolder they sit in, folders by name, each keeping - // the newest-first order of `files`. Images loose in `dir` itself form a - // group named after it, which sorts first. A folder with no images at - // the scanned depth has no group. The system ones follow: its loose - // images as "system", and the Plasma packages as "Plasma", one image - // each: the dark version where the package has one, else the light, and - // of those the largest by the WxH its file is named after. `key` is what - // `expanded` is indexed by, so a local folder named "system" stays apart. - readonly property var groups: { - const local = {}, system = [], plasma = {}; - for (const f of files) { - if (f.startsWith(dir + "/")) { - const rel = f.slice(dir.length + 1); - const key = rel.includes("/") ? rel.slice(0, rel.indexOf("/")) : ""; - if (!local[key]) local[key] = []; - local[key].push(f); - } else if (f.includes("/contents/images")) { - const pkg = f.slice(sysDir.length + 1).split("/")[0]; - if (!plasma[pkg] || rank(f) > rank(plasma[pkg])) plasma[pkg] = f; - } else { - system.push(f); - } - } - const byName = (a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }); - return Object.keys(local).sort(byName) - .map(k => ({ key: "local/" + k, name: k || basename(dir), files: local[k] })) - .concat([ - { key: "system", name: "system", files: system }, - { key: "plasma", name: "Plasma", files: Object.keys(plasma).sort(byName).map(k => plasma[k]) }, - ]) - .filter(g => g.files.length > 0); - } - - // Dark outranks any light, then size decides. - function rank(path) { - const m = /(\d+)x(\d+)\.\w+$/.exec(path); - return (path.includes("/contents/images_dark/") ? 1e12 : 0) + (m ? m[1] * m[2] : 0); - } - - // What a thumbnail is called: a Plasma package's file is named after its - // resolution, so it goes by the package instead. - function label(path) { - const i = path.indexOf("/contents/images"); - return i < 0 ? basename(path) : basename(path.slice(0, i)); - } - - // Which groups are open, by key. Here rather than in the panel, which is - // destroyed on close, so a folder left open is open next time. Reassigned - // whole, because bindings do not see a key change inside a var. - property var expanded: ({}) - function toggle(key) { - const e = Object.assign({}, expanded); - e[key] = !e[key]; - expanded = e; - } - - // 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 deleted file mode 100644 index e7e6555..0000000 --- a/appearance/shell.qml +++ /dev/null @@ -1,33 +0,0 @@ -// 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 } - StepsWindow { id: stepsWindow } - - IpcHandler { - target: "appearance" - function toggle() { panel.toggle(""); } - function wallpaper() { panel.toggle("wallpaper"); } - function theme() { panel.toggle("theme"); } - 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() + "\n" + Steps.selftest(); - } - } -} |
