# appearance Sunset / Idle / Icons Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add Sunset, Idle and Icons tabs to the `appearance/` quickshell drawer. **Architecture:** Each tab is backed by a `pragma Singleton` model (`Hyprsunset.qml`, `Hypridle.qml`, `Icons.qml`) that parses, serializes and applies a config file, mirroring `Udt.qml` / `Wallpapers.qml`. Tab UI lives in `SunsetTab.qml`, `IdleTab.qml`, `IconsTab.qml`, loaded by the existing `Loader`. Models are built and self-tested first, UI after, so every commit loads. **Tech Stack:** Quickshell 0.3.1 / Qt6 QML, `Quickshell.Io` (Process, FileView, IpcHandler), `Quickshell.Widgets.IconImage`. Shell tools already installed: `python3` + `gi`/`Gtk`, `curl`, `unzip`, `xcur2png`, `gsettings`, `hyprctl`, `pkill`, `setsid`. **Spec:** `docs/superpowers/specs/2026-09-15-appearance-sunset-idle-icons-design.md` ## Global Constraints - GPLv2-only header comment at the top of every new source file, verbatim from `appearance/Udt.qml` lines 1-10. - No home paths in committed files: use `Quickshell.env("HOME")` or `~` in documentation. - No new runtime dependencies. No `String.matchAll` (QML's JS engine lacks it): use `exec` loops or `[\s\S]`. - Reusing a `Process` for a second command requires `running = false` immediately before `running = true`. - Writes to config files go through `FileView.setText()` (atomic). Files we write are never read with `watchChanges`. Reads go through `Process { command: [...] }` + `StdioCollector`. - Never `pkill -f`; the process name is `qs` and `-f` matches the agent's own shell. Use `pkill -x`. - Detached daemon starts use `Quickshell.execDetached(["sh", "-c", "..."])`. - The 1x1 keepalive `PanelWindow` in `AppearancePanel.qml` stays untouched. - Selftests are pure functions returning a string starting with `SELFTEST ... PASS` or `SELFTEST ... FAIL`. They must not touch real files. --- ## File structure appearance/Hyprsunset.qml sunset model: parse/serialize/validate/location/apply appearance/Hypridle.qml idle model: parse/serialize/apply appearance/Icons.qml theme lists, previews, apply appearance/Field.qml styled single-line TextInput appearance/Toggle.qml styled checkbox appearance/SunsetTab.qml Sunset tab UI appearance/IdleTab.qml Idle tab UI appearance/IconsTab.qml Icons tab UI appearance/AppearancePanel.qml tab bar, key cycle, Loader, show() refresh appearance/shell.qml IPC verbs + selftest appearance/README.md document the three tabs --- ### Task 1: Hyprsunset model **Files:** - Create: `appearance/Hyprsunset.qml` - Modify: `appearance/shell.qml` **Interfaces:** - Produces: - `Hyprsunset.profiles` : `var[]`, each `{ time: string, identity: bool, temperature: int|null, gamma: real|null }` - `Hyprsunset.parseProfiles(text): var[]` - `Hyprsunset.serializeProfiles(list): string` - `Hyprsunset.dayIndex(list): int|null`, `Hyprsunset.nightIndex(list): int|null` - `Hyprsunset.validTime(s): bool`, `validTemperature(t): bool`, `validGamma(g): bool` - `Hyprsunset.refresh()`, `.save()`, `.preview()`, `.detect()`, `.fetchSun()` - `Hyprsunset.lat`, `.lon`, `.autoDetect`, `.daemonCommand`, `.notice`, `.busy`, `.sunSummary` - `Hyprsunset.selftest(): string` - Consumes: existing `shell.qml` IpcHandler. - [ ] **Step 1: Ensure the appearance shell is running** Run: `pgrep -cx qs` Expected: a number ≥ 1 (the autostarted session). If `0`, ask the user to start it (`qs -p appearance`) before continuing; the agent cannot keep a detached `qs` alive. - [ ] **Step 2: Write the model with a failing selftest** Create `appearance/Hyprsunset.qml`. The parser and serializer are stubbed (`return []` / `return ""`) so the selftest fails; everything else is final. ```qml // Copyright (C) 2026 Danilo M. // // 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 cachePath: `${home}/.config/hyprsunset-qt/sun.json` property string notice: "" property string sunSummary: "" property bool busy: 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) { return []; // STEP 4 fills this in } function serializeProfiles(list) { return ""; // STEP 4 fills this in } 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) } } // 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.cachePath = root.expandTilde(c["cache.path"] ?? "~/.config/hyprsunset-qt/sun.json"); root.daemonCommand = c["daemon.command"] ?? "hyprsunset"; } } } 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 = ~/.config/hyprsunset-qt/sun.json\n\n` + `[daemon]\ncommand = ${root.daemonCommand}\n\n`; } function saveSettings() { appFile.setText(root.appConfText()); } function restart() { Quickshell.execDetached(["sh", "-c", `pkill -x hyprsunset; setsid -f ${root.daemonCommand}`]); } function save() { 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 = 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; } } } } ``` - [ ] **Step 3: Add the selftest IPC and run it to verify it fails** In `appearance/shell.qml`, add to the `IpcHandler`: ```qml function selftest(): string { return Hyprsunset.selftest(); } ``` Run: `qs -p appearance ipc call appearance selftest` Expected: `SELFTEST Hyprsunset FAIL: parsed 0 profiles` (If the new singleton is not visible after save, the qmldir did not rescan; restart the appearance shell. Hot reload does not pick up a new component file by itself. The edit to `shell.qml` should force it.) - [ ] **Step 4: Implement the parser and serializer** Replace the two stub bodies: ```qml 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"; } ``` - [ ] **Step 5: Run the selftest to verify it passes** Run: `qs -p appearance ipc call appearance selftest` Expected: `SELFTEST Hyprsunset PASS` - [ ] **Step 6: Verify against the real file (no write)** Run: `cp ~/.config/hypr/hyprsunset.conf /tmp/hs.conf && cat ~/.config/hypr/hyprsunset.conf` Expected: two profile blocks, matching the fixture shape. If it differs, note it and continue; the no-op check happens in Task 7. - [ ] **Step 7: Commit** ```bash git add appearance/Hyprsunset.qml appearance/shell.qml git commit -m "feat(appearance): add the hyprsunset profile model Parses and serializes ~/.config/hypr/hyprsunset.conf in the exact format hyprsunset-qt writes, so both editors share the file. Location lives in hyprsunset-qt's own INI; fetching mirrors its curl calls and browser User-Agent. Selftest round-trips a fixture byte-for-byte." ``` --- ### Task 2: Hypridle model **Files:** - Create: `appearance/Hypridle.qml` - Modify: `appearance/shell.qml` **Interfaces:** - Produces: - `Hypridle.listeners` : `var[]`, each `{ leading: string, timeout: int, onTimeout: string, onResume: string, enabled: bool }` - `Hypridle.prefix: string`, `Hypridle.tail: string` - `Hypridle.parse(text): { prefix: string, listeners: var[], tail: string }` - `Hypridle.serialize(prefix, listeners, tail): string` - `Hypridle.refresh()`, `.save()`, `.describe(listener): string` - `Hypridle.selftest(): string` - Consumes: nothing from Task 1. **Trap:** the `on-timeout` of the dpms listener contains `{ state = ... }`, so a naive `listener\s*\{(.*?)\}` regex truncates the block. The block ends at the first line that is only whitespace then `}`, which the command's brace never is. - [ ] **Step 1: Write the model with a failing selftest** Create `appearance/Hypridle.qml` (GPLv2 header as in Task 1): ```qml 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: "" 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"); } function parse(text) { return { prefix: text, listeners: [], tail: "" }; // STEP 3 fills this in } function serialize(prefix, list, tail) { return prefix; // STEP 3 fills this in } 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; } } } FileView { id: confFile; path: root.confPath } function save() { confFile.setText(root.serialize(root.prefix, root.listeners, root.tail)); Quickshell.execDetached(["sh", "-c", "pkill -x hypridle; setsid -f hypridle"]); } } ``` - [ ] **Step 2: Extend the selftest IPC and run it to verify it fails** In `appearance/shell.qml`: ```qml function selftest(): string { return Hyprsunset.selftest() + "\n" + Hypridle.selftest(); } ``` Run: `qs -p appearance ipc call appearance selftest` Expected: the Hyprsunset line passes, then `SELFTEST Hypridle FAIL: 0 listeners` - [ ] **Step 3: Implement parse and serialize** ```qml // 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; } ``` - [ ] **Step 4: Run the selftest to verify it passes** Run: `qs -p appearance ipc call appearance selftest` Expected: Hyprsunset PASS then `SELFTEST Hypridle PASS` - [ ] **Step 5: Commit** ```bash git add appearance/Hypridle.qml appearance/shell.qml git commit -m "feat(appearance): add the hypridle listener model Parses the general block and comments before the first listener and keeps them verbatim; only timeouts and the enabled flag are editable, commands are copied. Disabled listeners are written commented out, and the parser matches the closing brace at line start so the dpms command's inline brace does not truncate the block." ``` --- ### Task 3: Icons model **Files:** - Create: `appearance/Icons.qml` - Modify: `appearance/shell.qml` **Interfaces:** - Produces: - `Icons.iconThemes: string[]`, `Icons.cursorThemes: string[]` - `Icons.currentIcon: string`, `Icons.currentCursor: string` - `Icons.iconPreview: var` (`name -> { iconName -> path }`), `Icons.cursorPreview: var` (`name -> path`) - `Icons.classify(entries): { icons: string[], cursors: string[] }` - `Icons.refresh()`, `.previewIcons()`, `.previewCursor(name)`, `.applyIcon(name)`, `.applyCursor(name)` - `Icons.selftest(): string` - Consumes: nothing. - [ ] **Step 1: Write the model with a failing selftest** Create `appearance/Icons.qml` (GPLv2 header as in Task 1): ```qml 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) ` property var iconThemes: [] property var cursorThemes: [] property string currentIcon: "" property string currentCursor: "" 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) { return { icons: [], cursors: [] }; // STEP 3 fills this in } 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 }, ]; const r = root.classify(entries); if (r.icons.length !== 1 || r.icons[0] !== "Material-Black-Plum-Suru") return `SELFTEST Icons FAIL: icons ${JSON.stringify(r.icons)}`; if (r.cursors.length !== 2 || r.cursors.indexOf("hypr_bibata-modern-amber") < 0 || r.cursors.indexOf("breeze_cursors") < 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; ` + `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" ? (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(); } } } // 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; ` + `gsettings get org.gnome.desktop.interface cursor-theme`] stdout: StdioCollector { onStreamFinished: { const lines = text.trim().split("\n"); root.currentIcon = (lines[0] ?? "").replace(/'/g, ""); root.currentCursor = (lines[1] ?? "").replace(/'/g, ""); } } } // A cursor theme is a manifest + .hlc shapes, a shape directory with SVGs, // or a legacy Xcursor cursors/ directory. The extracted image goes to the // per-shell cache; on failure the tab shows nothing rather than a broken // image. function previewCursor(name) { const dir = Quickshell.cachePath("cursors"); const out = `${dir}/${name}.png`; curPreviewProc.themeName = name; curPreviewProc.command = ["sh", "-c", `set -e; ` + `d=""; for r in ${root.home}/.icons ${root.home}/.local/share/icons /usr/share/icons; do ` + `[ -d "$r/${name}" ] && d="$r/${name}" && break; done; [ -n "$d" ] || exit 1; ` + `mkdir -p ${dir}; ` + `c=""; for n in left_ptr default pointer hand2; do ` + `if [ -f "$d/$n.hlc" ]; then c="$d/$n.hlc"; break; fi; ` + `if [ -f "$d/$n/$n.svg" ]; then c="$d/$n/$n.svg"; break; fi; ` + `if [ -f "$d/cursors/$n" ]; then c="$d/cursors/$n"; break; fi; done; ` + `[ -n "$c" ] || exit 1; ` + `case "$c" in ` + `*.hlc) unzip -p "$c" '*.svg' > ${out} 2>/dev/null; ` + `[ -s ${out} ] || unzip -p "$c" '*.png' > ${out} 2>/dev/null; ` + `[ -s ${out} ] || exit 1;; ` + `*.svg) cp "$c" ${out};; ` + `*) rm -rf ${dir}/raw-${name}; mkdir -p ${dir}/raw-${name}; ` + `xcur2png -d ${dir}/raw-${name} "$c" >/dev/null 2>&1; ` + `cp "$(ls ${dir}/raw-${name}/$(basename "$c")_*.png | tail -1)" ${out};; esac`] curPreviewProc.running = false; curPreviewProc.running = true; } Process { id: curPreviewProc property string themeName: "" onExited: code => { if (code === 0) { const next = Object.assign({}, root.cursorPreview); next[themeName] = `${Quickshell.cachePath("cursors")}/${themeName}.png`; 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 } FileView { id: envFile; path: `${root.home}/.config/hypr/sections/environment.lua`; blockLoading: true } function gsettingsSet(key, value) { gsetProc.command = ["gsettings", "set", "org.gnome.desktop.interface", key, value]; gsetProc.running = false; gsetProc.running = true; } Process { id: gsetProc } function applyIcon(name) { root.gsettingsSet("icon-theme", 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.currentIcon = name; root.notice = `${name} set. Restart apps to see it.`; } function applyCursor(name) { // Live switch first, then persistence. Quickshell.execDetached(["hyprctl", "setcursor", name, "24"]); root.gsettingsSet("cursor-theme", name); const text = envFile.text(); // Matches both XCURSOR_THEME and HYPRCURSOR_THEME: both end in // CURSOR_THEME", ". if (text !== "") envFile.setText(text.replace(/(CURSOR_THEME", ")[^"]*/g, (m, p1) => p1 + name)); root.currentCursor = name; root.notice = `${name} set live. environment.lua updated for next login.`; } } ``` - [ ] **Step 2: Extend the selftest IPC and run it to verify it fails** In `appearance/shell.qml`: ```qml function selftest(): string { return Hyprsunset.selftest() + "\n" + Hypridle.selftest() + "\n" + Icons.selftest(); } ``` Run: `qs -p appearance ipc call appearance selftest` Expected: three lines; the `Icons` line reports `SELFTEST Icons FAIL: icons []`. - [ ] **Step 3: Implement classify and verify the selftest passes** Replace the stub body with: ```qml const icons = [], cursors = []; for (const e of entries) { if (e.cursors || e.manifest) cursors.push(e.name); else 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) }; ``` Run: `qs -p appearance ipc call appearance selftest` Expected: `SELFTEST Icons PASS` - [ ] **Step 4: Verify the icon lookup standalone** Run: `python3 -c 'import gi; gi.require_version("Gtk","3.0"); from gi.repository import Gtk; t=Gtk.IconTheme.new(); t.set_custom_theme("Material-Black-Plum-Suru"); print(t.lookup_icon("folder",32,0).get_filename())'` Expected: an absolute path ending `folder.svg`. If `gi` is not importable, the icon section will be empty; report that and stop. - [ ] **Step 5: Commit** ```bash git add appearance/Icons.qml appearance/shell.qml git commit -m "feat(appearance): add the icon and cursor theme model Lists themes from ~/.icons, ~/.local/share/icons and /usr/share/icons, distinguishing cursor themes by cursors/ or manifest.hl. Icon previews come from one GTK lookup process for every theme; cursor previews extract left_ptr from a hyprcursor .hlc via unzip or from an Xcursor theme via xcur2png. Application sets gsettings and the Qt configs, and rewrites the cursor env in environment.lua." ``` --- ### Task 4: Tab bar, Loader, and the Sunset tab **Files:** - Create: `appearance/Field.qml`, `appearance/Toggle.qml`, `appearance/SunsetTab.qml` - Modify: `appearance/AppearancePanel.qml`, `appearance/shell.qml` **Interfaces:** - Consumes: `Hyprsunset` (Task 1). - Produces: `Field` (`value`, `placeholder`, `onEdited`), `Toggle` (`checked`, `label`), `SunsetTab` (a `Flickable`). - [ ] **Step 1: Create the two shared controls** `appearance/Field.qml`: ```qml // Copyright (C) 2026 Danilo M. // GPLv2-only. See LICENSE. 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() } } ``` `appearance/Toggle.qml`: ```qml // Copyright (C) 2026 Danilo M. // GPLv2-only. See LICENSE. 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(); } } } ``` - [ ] **Step 2: Generalize the tab bar and key cycle** In `appearance/AppearancePanel.qml`, add to the `Scope` (near `property string tab`): ```qml readonly property var tabs: ["wallpaper", "theme", "sunset", "idle", "icons"] readonly property var tabLabels: ({ wallpaper: "Wallpaper", theme: "Theme", sunset: "Sunset", idle: "Idle", icons: "Icons", }) ``` Replace the `Keys.onPressed` body (the `Qt.Key_Tab` branch): ```qml 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; } ``` Replace the two hardcoded `Tab` buttons in the header `Row` with: ```qml Repeater { model: root.tabs Tab { required property var modelData text: root.tabLabels[modelData] selected: root.tab === modelData onClicked: root.tab = modelData } } ``` Replace the tab `Loader` with: ```qml 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") : "" } ``` In `show()`, add refreshes: ```qml Hyprsunset.refresh(); ``` - [ ] **Step 3: Add the sunset IPC verbs** In `appearance/shell.qml` `IpcHandler`: ```qml function sunset() { panel.toggle("sunset"); } function idle() { panel.toggle("idle"); } function icons() { panel.toggle("icons"); } ``` - [ ] **Step 4: Create the Sunset tab** `appearance/SunsetTab.qml`: ```qml // Copyright (C) 2026 Danilo M. // GPLv2-only. See LICENSE. 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) } Item { width: 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 } } } } ``` - [ ] **Step 5: Verify the tab opens and the selftest still passes** Run: `qs -p appearance ipc call appearance sunset` Expected: the drawer opens on the Sunset tab, showing two profile rows from the real file. Confirm the profiles match `~/.config/hypr/hyprsunset.conf`. Run: `qs -p appearance ipc call appearance selftest` Expected: `SELFTEST Hyprsunset PASS` (and Hypridle PASS). - [ ] **Step 6: Commit** ```bash git add appearance/Field.qml appearance/Toggle.qml appearance/SunsetTab.qml \ appearance/AppearancePanel.qml appearance/shell.qml git commit -m "feat(appearance): add the Sunset tab Tab bar and Tab-key cycle become five entries, the Loader picks inline or file tabs, and show() refreshes the model. The tab edits the profiles in place, fetches sun times and previews through the daemon." ``` --- ### Task 5: Idle tab **Files:** - Create: `appearance/IdleTab.qml` - Modify: `appearance/AppearancePanel.qml` (`show()` refresh) **Interfaces:** - Consumes: `Hypridle` (Task 2), `Toggle`, `Field`, `Tab`. - Produces: `IdleTab` (a `Flickable`). - [ ] **Step 1: Create the tab** `appearance/IdleTab.qml`: ```qml // Copyright (C) 2026 Danilo M. // GPLv2-only. See LICENSE. 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)}m ${modelData.timeout % 60}s` 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() } } } ``` - [ ] **Step 2: Refresh the model when the panel opens** In `appearance/AppearancePanel.qml` `show()`, beside `Hyprsunset.refresh();` add: ```qml Hypridle.refresh(); ``` - [ ] **Step 3: Verify** Run: `qs -p appearance ipc call appearance idle` Expected: the drawer opens on Idle with four rows (notify before lock, lock session, monitors off, suspend) and their timeouts 570/600/630/660. Toggle one off, run: `qs -p appearance ipc call appearance selftest` Expected: `SELFTEST Hypridle PASS` still. - [ ] **Step 4: Commit** ```bash git add appearance/IdleTab.qml appearance/AppearancePanel.qml git commit -m "feat(appearance): add the Idle tab Rows for each listener with an enable toggle and a timeout field, the command shown read-only. Save rewrites the file and restarts hypridle." ``` --- ### Task 6: Icons tab **Files:** - Create: `appearance/IconsTab.qml` - Modify: `appearance/AppearancePanel.qml` (`show()` refresh) **Interfaces:** - Consumes: `Icons` (Task 3), `Toggle`, `Tab`, `Quickshell.Widgets.IconImage`. - Produces: `IconsTab` (a `Flickable`). - [ ] **Step 1: Create the tab** `appearance/IconsTab.qml`: ```qml // Copyright (C) 2026 Danilo M. // GPLv2-only. See LICENSE. import Quickshell import Quickshell.Widgets import QtQuick Column { 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 } } Flickable { id: iconList width: parent.width height: 150 contentWidth: iconRow.implicitWidth contentHeight: height clip: true flickableDirection: Flickable.HorizontalFlick Row { id: iconRow spacing: 10 Repeater { model: Icons.iconThemes Rectangle { id: iconCard required property string modelData readonly property bool current: Icons.currentIcon === modelData width: 150; height: 120; 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 { anchors.centerIn: parent spacing: 6 Row { spacing: 6 Repeater { model: ["folder", "text-x-generic", "image-x-generic", "network-wireless"] IconImage { required property string modelData implicitSize: 26 source: (Icons.iconPreview[iconCard.modelData] ?? {})[modelData] ?? "" } } } Text { anchors.horizontalCenter: parent.horizontalCenter text: iconCard.current ? modelData + " current" : modelData color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } } } } } } } Text { text: "Cursor theme" color: Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } } Flickable { width: parent.width height: 150 contentWidth: cursorRow.implicitWidth contentHeight: height clip: true flickableDirection: Flickable.HorizontalFlick Row { id: cursorRow spacing: 10 Repeater { model: Icons.cursorThemes Rectangle { required property string modelData readonly property bool current: Icons.currentCursor === modelData width: 120; height: 120; 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" HoverHandler { onHoveredChanged: { if (hovered) { Icons.previewCursor(modelData); // Live preview: the real cursor, reverted on leave. liveCursorCursor = modelData; Quickshell.execDetached(["hyprctl", "setcursor", modelData, "24"]); } else if (liveCursorCursor === modelData) { liveCursorCursor = ""; Quickshell.execDetached(["hyprctl", "setcursor", Icons.currentCursor, "24"]); } } } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: Icons.applyCursor(modelData) } Column { anchors.centerIn: parent spacing: 6 Image { anchors.horizontalCenter: parent.horizontalCenter width: 40; height: 40 asynchronous: true source: Icons.cursorPreview[modelData] ? "file://" + Icons.cursorPreview[modelData] : "" fillMode: Image.PreserveAspectFit } Text { anchors.horizontalCenter: parent.horizontalCenter text: parent.parent.current ? modelData + " current" : modelData color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } width: 110 elide: Text.ElideMiddle horizontalAlignment: Text.AlignHCenter } } } } } } Text { width: parent.width wrapMode: Text.Wrap visible: Icons.notice !== "" text: Icons.notice color: Theme.green font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } } property string liveCursorCursor: "" onVisibleChanged: if (!visible && liveCursorCursor !== "") { Quickshell.execDetached(["hyprctl", "setcursor", Icons.currentCursor, "24"]); liveCursorCursor = ""; } } ``` - [ ] **Step 2: Refresh the model when the panel opens** In `appearance/AppearancePanel.qml` `show()`, add: ```qml Icons.refresh(); ``` - [ ] **Step 3: Verify** Run: `qs -p appearance ipc call appearance icons` Expected: two scrollable rows. Icon themes show four glyphs each and the current theme is marked. Cursor themes show an extracted arrow and hovering changes the real cursor, reverting on leave. Run: `qs -p appearance ipc call appearance selftest` Expected: all three `PASS`. - [ ] **Step 4: Commit** ```bash git add appearance/IconsTab.qml appearance/AppearancePanel.qml git commit -m "feat(appearance): add the Icons tab Icon themes preview four glyphs each from the GTK lookup; cursor themes preview an extracted left_ptr and change the real cursor on hover, reverted on leave and on close. Selection applies both." ``` --- ### Task 7: Docs, follow-up TODOs, final verification **Files:** - Modify: `appearance/README.md` - Create: `~/Programming/GIT/unified-desktop-theme/TODO-icons.md` (or append to an existing TODO if one exists) - Create: `~/Programming/GIT/waybar-theme-udt/TODO-icons.md` (same) - Modify: no code. **Interfaces:** - Consumes: the finished tabs. - Produces: documentation and tracked follow-ups. - [ ] **Step 1: Update the README** In `appearance/README.md`, extend the tab diagram line and add a section: ````markdown ┌─[ Wallpaper ]─[ Theme ]─[ Sunset ]─[ Idle ]─[ Icons ]──┐ ```` Add under `## Running it`: ```markdown IPC verbs: `wallpaper`, `theme`, `sunset`, `idle`, `icons`. ## 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. Icon previews come from a GTK lookup per theme; cursor previews extract `left_ptr` from a hyprcursor `.hlc` with `unzip` or from an Xcursor theme with `xcur2png`, and hovering changes the real cursor. Applying writes `gsettings`, the Qt configs, and for cursors `environment.lua`; apps and a relogin are needed to see the rest. The theme name is still hardcoded in `unified-desktop-theme`, `waybar-theme-udt` and rofi, which is tracked as a follow-up in those repos. ``` - [ ] **Step 2: Write the follow-up TODOs** Check each repo for an existing TODO file (`ls ~/Programming/GIT/unified-desktop-theme ~/Programming/GIT/waybar-theme-udt`). If one exists, append; otherwise create `TODO-icons.md` in each with: ```markdown # Unhardcode the icon theme The active icon theme name `Material-Black-Plum-Suru` is hardcoded here. The appearance drawer's Icons tab now switches it through gsettings and the Qt configs, but these files do not follow, so a switch is silently reverted. Replace the hardcoded name with a read of `gsettings get org.gnome.desktop.interface icon-theme`. Known sites: - unified-desktop-theme: `templates/qt-gtk/qt5ct.conf`, `qt6ct.conf`, `gtk3-settings.ini` - waybar-theme-udt: `bin/wb-icon` (the `THEME` constant), `modules/extras/taskbar.jsonc`, `install.sh` - rofi: `~/.config/rofi/config.rasi` - `~/.config/hypr/hyprqt6engine.conf` - `~/.local/share/applications/firefox-clean.desktop` ``` - [ ] **Step 3: Final verification, real saves** ```bash cp ~/.config/hypr/hyprsunset.conf /tmp/hs.before cp ~/.config/hypr/hypridle.conf /tmp/hi.before cp ~/.config/hypr/sections/environment.lua /tmp/env.before ``` Open the Sunset tab, press Save + Restart with no edits, then: Run: `diff /tmp/hs.before ~/.config/hypr/hyprsunset.conf` Expected: no output (byte-identical). Repeat for Idle. For the cursor, applying any theme is expected to change `environment.lua`; confirm with `diff /tmp/env.before ~/.config/hypr/sections/environment.lua` that only the two cursor lines changed, then set it back. - [ ] **Step 4: Confirm the daemons still run** Run: `pgrep -x hyprsunset; pgrep -x hypridle` Expected: one PID each. - [ ] **Step 5: Commit** ```bash git add appearance/README.md git commit -m "docs(appearance): document the Sunset, Idle and Icons tabs Records the IPC verbs, the shared hyprsunset.conf format, the preserved hypridle comments, and the preview mechanisms. The hardcoded icon theme in udt, waybar and rofi is tracked as a TODO in those repos." ``` Note: the `TODO-icons.md` files live in other repos and are committed there separately, not in this plan's commits. --- ## Self-review - **Spec coverage:** tab bar/IPC (Task 4), Sunset full mirror (Tasks 1, 4), Idle timeouts+toggle+comment preservation (Tasks 2, 5), Icons lists/preview/apply (Tasks 3, 6), README + cross-repo TODOs (Task 7), selftests (Tasks 1-3), verified traps (Global Constraints, Task 2). All spec sections map to a task. - **Placeholder scan:** no TBD/TODO steps; every code step has full code. - **Type consistency:** `parseProfiles`/`serializeProfiles` names are used consistently; `Hypridle.parse`/`serialize` signatures match between the selftest and Task 5's use; `Icons.classify` field names (`index`, `cursors`, `manifest`) match the scan and the selftest. `Field.value`/`Toggle.checked` match the tab code. - **Fixed during self-review:** the sunset fixture gained the two blank lines after the header that sunset-qt's serializer actually emits; the idle parser's first-listener leading trivia no longer duplicates the prefix, and its closing-brace pattern tolerates a commented brace; `Icons` writes configs through `FileView` instead of shell `sed`, so no quoting can corrupt them; `previewCursor` assigns `themeName` before running and no longer concatenates raw zip bytes on an `.hlc` without an SVG; `IconsTab` references the delegate by `id`. - **Open risk:** the mixed `Loader` (inline `sourceComponent` for theme/wallpaper, `source` for the new tabs) relies on `sourceComponent` taking precedence when non-null. If a tab switch between an inline and a file tab misbehaves during Task 4, fall back to `source`-only by moving `themeTab` and `wallpaperTab` into `ThemeTab.qml` and `WallpaperTab.qml`; the change is mechanical.