From d320a7ad71cf2426f54fb6b9cbc33bf86149ff56 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 15 Sep 2026 19:42:05 +0200 Subject: docs: add the appearance sunset/idle/icons implementation plan Seven tasks: the hyprsunset model, the hypridle model, the icon/cursor model, then the tab bar and one task per tab, finishing with docs and the cross-repo unhardcode TODOs. Models are built and self-tested first so every commit loads. Records the brace-in-command parsing trap and the sunset-qt byte-for-byte serializer shape. --- .../2026-09-15-appearance-sunset-idle-icons.md | 1750 ++++++++++++++++++++ 1 file changed, 1750 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-15-appearance-sunset-idle-icons.md diff --git a/docs/superpowers/plans/2026-09-15-appearance-sunset-idle-icons.md b/docs/superpowers/plans/2026-09-15-appearance-sunset-idle-icons.md new file mode 100644 index 0000000..4e77136 --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-appearance-sunset-idle-icons.md @@ -0,0 +1,1750 @@ +# 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. -- cgit v1.2.3