aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-07-17-hyprsunset-qt.md
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-07-17 09:22:46 +0200
committerDanilo M. <danix@danix.xyz>2026-07-17 09:22:46 +0200
commit2e39e223c4d0d400a7c51867c9b3a01c79395481 (patch)
treecbff5d40f6b685bca8f8e0d95bb46493ccc862dd /docs/superpowers/plans/2026-07-17-hyprsunset-qt.md
parent2f5ef62a48f6d8543b193a5ae0e663c87f601712 (diff)
downloadhyprsunset-qt-2e39e223c4d0d400a7c51867c9b3a01c79395481.tar.gz
hyprsunset-qt-2e39e223c4d0d400a7c51867c9b3a01c79395481.zip
Add implementation plan for hyprsunset-qt
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers/plans/2026-07-17-hyprsunset-qt.md')
-rw-r--r--docs/superpowers/plans/2026-07-17-hyprsunset-qt.md1206
1 files changed, 1206 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-17-hyprsunset-qt.md b/docs/superpowers/plans/2026-07-17-hyprsunset-qt.md
new file mode 100644
index 0000000..d117262
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-17-hyprsunset-qt.md
@@ -0,0 +1,1206 @@
+# hyprsunset-qt 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:** A PyQt6 GUI to edit `~/.config/hypr/hyprsunset.conf`, fetch sunrise/sunset times for the user's location, and restart the hyprsunset daemon.
+
+**Architecture:** A small Python package split by concern — pure-logic modules (config, settings, sun, daemon) with no Qt/network coupling in their testable core, plus a `ui.py` PyQt6 layer that wires them together. TDD on the pure logic; Qt/network edges are thin wrappers.
+
+**Tech Stack:** Python 3.12, PyQt6, stdlib (`urllib.request`, `json`, `configparser`, `re`, `datetime`, `zoneinfo`, `subprocess`, `pathlib`), pytest.
+
+---
+
+## File Structure
+
+- `hyprsunset_qt/__init__.py` — package marker.
+- `hyprsunset_qt/config.py` — `Profile` dataclass, `parse_config`, `serialize`, `read_config`/`write_config`, day/night detection.
+- `hyprsunset_qt/settings.py` — `Settings` load/save over `~/.config/hyprsunset-qt/config` INI.
+- `hyprsunset_qt/sun.py` — `geolocate`, `fetch_sun`, `to_local_hm`, cache read/write.
+- `hyprsunset_qt/daemon.py` — `is_running`, `restart`, `live_preview`.
+- `hyprsunset_qt/ui.py` — PyQt6 `MainWindow` and profile-row widget.
+- `hyprsunset_qt/__main__.py` — entry point.
+- `tests/test_config.py`, `tests/test_settings.py`, `tests/test_sun.py` — pure-logic tests.
+- `pyproject.toml` — packaging + pytest config.
+- `LICENSE`, `README.md` — GPLv2 + docs.
+
+Each pure-logic module is import-safe without a display or network. `ui.py` is the only module that imports PyQt6.
+
+---
+
+## Task 1: Project scaffolding
+
+**Files:**
+- Create: `pyproject.toml`
+- Create: `hyprsunset_qt/__init__.py`
+- Create: `tests/__init__.py`
+- Create: `.gitignore`
+
+- [ ] **Step 1: Write pyproject.toml**
+
+```toml
+[project]
+name = "hyprsunset-qt"
+version = "0.1.0"
+description = "Qt6 GUI for hyprsunset"
+requires-python = ">=3.11"
+license = { text = "GPL-2.0-only" }
+dependencies = ["PyQt6"]
+
+[project.scripts]
+hyprsunset-qt = "hyprsunset_qt.__main__:main"
+
+[build-system]
+requires = ["setuptools>=61"]
+build-backend = "setuptools.build_meta"
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+```
+
+- [ ] **Step 2: Create package markers**
+
+`hyprsunset_qt/__init__.py`:
+```python
+"""hyprsunset-qt — Qt6 GUI for hyprsunset."""
+
+__version__ = "0.1.0"
+```
+
+`tests/__init__.py`: empty file.
+
+- [ ] **Step 3: Create .gitignore**
+
+```
+__pycache__/
+*.pyc
+*.egg-info/
+build/
+dist/
+.pytest_cache/
+```
+
+- [ ] **Step 4: Verify pytest runs (collects nothing)**
+
+Run: `python3 -m pytest -q`
+Expected: `no tests ran` (exit 5) — confirms pytest is installed and configured.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add pyproject.toml hyprsunset_qt/__init__.py tests/__init__.py .gitignore
+git commit -S -m "chore: project scaffolding"
+```
+
+---
+
+## Task 2: Config model — parse
+
+**Files:**
+- Create: `hyprsunset_qt/config.py`
+- Test: `tests/test_config.py`
+
+- [ ] **Step 1: Write the failing test**
+
+`tests/test_config.py`:
+```python
+from hyprsunset_qt.config import Profile, parse_config
+
+
+def test_parse_two_profiles():
+ text = """
+profile {
+ time = 5:00
+ identity = true
+}
+
+profile {
+ time = 19:40
+ temperature = 5500
+ gamma = 0.8
+}
+"""
+ profiles = parse_config(text)
+ assert profiles == [
+ Profile(time="5:00", identity=True, temperature=None, gamma=None),
+ Profile(time="19:40", identity=False, temperature=5500, gamma=0.8),
+ ]
+
+
+def test_parse_empty():
+ assert parse_config("") == []
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python3 -m pytest tests/test_config.py -v`
+Expected: FAIL — `ModuleNotFoundError: No module named 'hyprsunset_qt.config'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+`hyprsunset_qt/config.py`:
+```python
+"""Parse and serialize hyprsunset.conf profile blocks."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+_BLOCK_RE = re.compile(r"profile\s*\{(.*?)\}", re.DOTALL)
+
+
+@dataclass
+class Profile:
+ time: str = ""
+ identity: bool = False
+ temperature: int | None = None
+ gamma: float | None = None
+
+
+def _get(body: str, key: str) -> str | None:
+ m = re.search(rf"^\s*{key}\s*=\s*(.+?)\s*$", body, re.MULTILINE)
+ return m.group(1) if m else None
+
+
+def parse_config(text: str) -> list[Profile]:
+ profiles: list[Profile] = []
+ for block in _BLOCK_RE.finditer(text):
+ body = block.group(1)
+ time = _get(body, "time") or ""
+ identity = (_get(body, "identity") or "").lower() == "true"
+ temp = _get(body, "temperature")
+ gamma = _get(body, "gamma")
+ profiles.append(
+ Profile(
+ time=time,
+ identity=identity,
+ temperature=int(temp) if temp is not None else None,
+ gamma=float(gamma) if gamma is not None else None,
+ )
+ )
+ return profiles
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python3 -m pytest tests/test_config.py -v`
+Expected: PASS (2 passed).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add hyprsunset_qt/config.py tests/test_config.py
+git commit -S -m "feat: parse hyprsunset.conf profiles"
+```
+
+---
+
+## Task 3: Config model — serialize + day/night detection
+
+**Files:**
+- Modify: `hyprsunset_qt/config.py`
+- Test: `tests/test_config.py`
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/test_config.py`:
+```python
+from hyprsunset_qt.config import serialize, day_index, night_index
+
+
+def test_serialize_roundtrip():
+ profiles = [
+ Profile(time="5:00", identity=True),
+ Profile(time="19:40", temperature=5500, gamma=0.8),
+ ]
+ text = serialize(profiles)
+ assert parse_config(text) == profiles
+ assert "identity = true" in text
+ assert "temperature = 5500" in text
+ assert "gamma = 0.8" in text
+ assert text.startswith("# Managed by hyprsunset-qt")
+
+
+def test_day_night_index():
+ profiles = [
+ Profile(time="5:00", identity=True),
+ Profile(time="19:40", temperature=5500, gamma=0.8),
+ ]
+ assert day_index(profiles) == 0
+ assert night_index(profiles) == 1
+
+
+def test_day_night_index_missing():
+ assert day_index([Profile(time="1:00", temperature=4000)]) is None
+ assert night_index([Profile(time="1:00", identity=True)]) is None
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python3 -m pytest tests/test_config.py -v`
+Expected: FAIL — `ImportError: cannot import name 'serialize'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Append to `hyprsunset_qt/config.py`:
+```python
+_HEADER = "# Managed by hyprsunset-qt. Edits here are overwritten on save.\n"
+
+
+def _fmt_gamma(g: float) -> str:
+ # Drop trailing zeros but keep at least one decimal digit-free ints as-is.
+ return f"{g:g}"
+
+
+def serialize(profiles: list[Profile]) -> str:
+ day = day_index(profiles)
+ night = night_index(profiles)
+ parts = [_HEADER]
+ for i, p in enumerate(profiles):
+ if i == day:
+ parts.append("\n# day profile — sunrise")
+ elif i == night:
+ parts.append("\n# night profile — sunset")
+ else:
+ parts.append("\n# profile")
+ lines = ["profile {", f" time = {p.time}"]
+ if p.identity:
+ lines.append(" identity = true")
+ if p.temperature is not None:
+ lines.append(f" temperature = {p.temperature}")
+ if p.gamma is not None:
+ lines.append(f" gamma = {_fmt_gamma(p.gamma)}")
+ lines.append("}")
+ parts.append("\n".join(lines))
+ return "\n".join(parts) + "\n"
+
+
+def day_index(profiles: list[Profile]) -> int | None:
+ for i, p in enumerate(profiles):
+ if p.identity:
+ return i
+ return None
+
+
+def night_index(profiles: list[Profile]) -> int | None:
+ for i, p in enumerate(profiles):
+ if p.temperature is not None:
+ return i
+ return None
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python3 -m pytest tests/test_config.py -v`
+Expected: PASS (5 passed).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add hyprsunset_qt/config.py tests/test_config.py
+git commit -S -m "feat: serialize profiles with day/night tagging"
+```
+
+---
+
+## Task 4: Config file read/write
+
+**Files:**
+- Modify: `hyprsunset_qt/config.py`
+- Test: `tests/test_config.py`
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/test_config.py`:
+```python
+from pathlib import Path
+from hyprsunset_qt.config import read_config, write_config, CONFIG_PATH
+
+
+def test_read_missing_returns_empty(tmp_path):
+ assert read_config(tmp_path / "nope.conf") == []
+
+
+def test_write_then_read(tmp_path):
+ path = tmp_path / "hyprsunset.conf"
+ profiles = [Profile(time="19:40", temperature=5500, gamma=0.8)]
+ write_config(profiles, path)
+ assert read_config(path) == profiles
+
+
+def test_default_path_is_hypr_conf():
+ assert str(CONFIG_PATH).endswith(".config/hypr/hyprsunset.conf")
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python3 -m pytest tests/test_config.py -v`
+Expected: FAIL — `ImportError: cannot import name 'read_config'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Append to `hyprsunset_qt/config.py`:
+```python
+from pathlib import Path
+
+CONFIG_PATH = Path.home() / ".config" / "hypr" / "hyprsunset.conf"
+
+
+def read_config(path: Path = CONFIG_PATH) -> list[Profile]:
+ path = Path(path)
+ if not path.exists():
+ return []
+ return parse_config(path.read_text())
+
+
+def write_config(profiles: list[Profile], path: Path = CONFIG_PATH) -> None:
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(serialize(profiles))
+```
+
+Add `from pathlib import Path` near the top imports if the earlier append placed it lower; keep a single import. (If a duplicate import appears, remove the lower one.)
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python3 -m pytest tests/test_config.py -v`
+Expected: PASS (8 passed).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add hyprsunset_qt/config.py tests/test_config.py
+git commit -S -m "feat: read/write hyprsunset.conf"
+```
+
+---
+
+## Task 5: App settings
+
+**Files:**
+- Create: `hyprsunset_qt/settings.py`
+- Test: `tests/test_settings.py`
+
+- [ ] **Step 1: Write the failing test**
+
+`tests/test_settings.py`:
+```python
+from pathlib import Path
+from hyprsunset_qt.settings import Settings
+
+
+def test_defaults_created_on_first_run(tmp_path):
+ path = tmp_path / "config"
+ s = Settings.load(path)
+ assert path.exists()
+ assert s.auto_detect is True
+ assert s.daemon_command == "hyprsunset"
+
+
+def test_roundtrip_and_tilde_expansion(tmp_path):
+ path = tmp_path / "config"
+ s = Settings.load(path)
+ s.lat = "45.07"
+ s.lon = "7.68"
+ s.auto_detect = False
+ s.cache_path = "~/somewhere/sun.json"
+ s.save(path)
+
+ s2 = Settings.load(path)
+ assert s2.lat == "45.07"
+ assert s2.lon == "7.68"
+ assert s2.auto_detect is False
+ assert s2.cache_path_expanded() == Path.home() / "somewhere" / "sun.json"
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python3 -m pytest tests/test_settings.py -v`
+Expected: FAIL — `ModuleNotFoundError: No module named 'hyprsunset_qt.settings'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+`hyprsunset_qt/settings.py`:
+```python
+"""App settings stored in ~/.config/hyprsunset-qt/config (INI)."""
+
+from __future__ import annotations
+
+import configparser
+from dataclasses import dataclass
+from pathlib import Path
+
+SETTINGS_PATH = Path.home() / ".config" / "hyprsunset-qt" / "config"
+DEFAULT_CACHE = "~/.config/hyprsunset-qt/sun.json"
+
+
+@dataclass
+class Settings:
+ lat: str = ""
+ lon: str = ""
+ auto_detect: bool = True
+ cache_path: str = DEFAULT_CACHE
+ daemon_command: str = "hyprsunset"
+
+ @classmethod
+ def load(cls, path: Path = SETTINGS_PATH) -> "Settings":
+ path = Path(path)
+ if not path.exists():
+ s = cls()
+ s.save(path)
+ return s
+ cp = configparser.ConfigParser()
+ cp.read(path)
+ return cls(
+ lat=cp.get("location", "lat", fallback=""),
+ lon=cp.get("location", "lon", fallback=""),
+ auto_detect=cp.getboolean("location", "auto_detect", fallback=True),
+ cache_path=cp.get("cache", "path", fallback=DEFAULT_CACHE),
+ daemon_command=cp.get("daemon", "command", fallback="hyprsunset"),
+ )
+
+ def save(self, path: Path = SETTINGS_PATH) -> None:
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ cp = configparser.ConfigParser()
+ cp["location"] = {
+ "lat": self.lat,
+ "lon": self.lon,
+ "auto_detect": str(self.auto_detect).lower(),
+ }
+ cp["cache"] = {"path": self.cache_path}
+ cp["daemon"] = {"command": self.daemon_command}
+ with path.open("w") as f:
+ cp.write(f)
+
+ def cache_path_expanded(self) -> Path:
+ return Path(self.cache_path).expanduser()
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python3 -m pytest tests/test_settings.py -v`
+Expected: PASS (2 passed).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add hyprsunset_qt/settings.py tests/test_settings.py
+git commit -S -m "feat: app settings INI"
+```
+
+---
+
+## Task 6: Sun — UTC to local conversion + cache
+
+**Files:**
+- Create: `hyprsunset_qt/sun.py`
+- Test: `tests/test_sun.py`
+
+- [ ] **Step 1: Write the failing test**
+
+`tests/test_sun.py`:
+```python
+import json
+from datetime import timezone
+from zoneinfo import ZoneInfo
+from hyprsunset_qt.sun import to_local_hm, read_cache, write_cache
+
+
+def test_utc_to_local_hm():
+ # 2026-07-17T17:40:00+00:00 -> 19:40 in Europe/Rome (UTC+2 summer)
+ tz = ZoneInfo("Europe/Rome")
+ assert to_local_hm("2026-07-17T17:40:00+00:00", tz) == "19:40"
+
+
+def test_cache_roundtrip(tmp_path):
+ path = tmp_path / "sun.json"
+ payload = {"lat": "45.07", "lon": "7.68", "results": {"sunset": "x"}}
+ write_cache(payload, path)
+ assert read_cache(path) == payload
+
+
+def test_read_cache_missing(tmp_path):
+ assert read_cache(tmp_path / "nope.json") is None
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python3 -m pytest tests/test_sun.py -v`
+Expected: FAIL — `ModuleNotFoundError: No module named 'hyprsunset_qt.sun'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+`hyprsunset_qt/sun.py`:
+```python
+"""Geolocation + sunrise/sunset lookup with a manual-refresh JSON cache."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime, tzinfo
+from pathlib import Path
+from typing import Optional
+
+
+def to_local_hm(iso_utc: str, tz: Optional[tzinfo] = None) -> str:
+ """Convert an ISO-8601 UTC timestamp to local HH:MM."""
+ dt = datetime.fromisoformat(iso_utc)
+ local = dt.astimezone(tz) # tz=None -> system local zone
+ return local.strftime("%H:%M")
+
+
+def read_cache(path: Path) -> Optional[dict]:
+ path = Path(path)
+ if not path.exists():
+ return None
+ try:
+ return json.loads(path.read_text())
+ except (json.JSONDecodeError, OSError):
+ return None
+
+
+def write_cache(payload: dict, path: Path) -> None:
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(payload, indent=2))
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python3 -m pytest tests/test_sun.py -v`
+Expected: PASS (3 passed).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add hyprsunset_qt/sun.py tests/test_sun.py
+git commit -S -m "feat: sun UTC-to-local conversion and cache"
+```
+
+---
+
+## Task 7: Sun — network fetch (geolocate + fetch_sun)
+
+**Files:**
+- Modify: `hyprsunset_qt/sun.py`
+- Test: `tests/test_sun.py`
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/test_sun.py`:
+```python
+from unittest.mock import patch
+from hyprsunset_qt.sun import geolocate, fetch_sun
+
+
+def test_geolocate_parses_latlon():
+ body = json.dumps({"status": "success", "lat": 45.07, "lon": 7.68})
+ with patch("hyprsunset_qt.sun._http_get", return_value=body):
+ assert geolocate() == ("45.07", "7.68")
+
+
+def test_fetch_sun_returns_results_dict():
+ body = json.dumps({
+ "status": "OK",
+ "results": {
+ "sunrise": "2026-07-17T03:12:00+00:00",
+ "sunset": "2026-07-17T17:40:00+00:00",
+ },
+ })
+ with patch("hyprsunset_qt.sun._http_get", return_value=body) as g:
+ out = fetch_sun("45.07", "7.68")
+ assert out["results"]["sunset"] == "2026-07-17T17:40:00+00:00"
+ # URL includes coords and unformatted flag
+ url = g.call_args[0][0]
+ assert "lat=45.07" in url and "lng=7.68" in url and "formatted=0" in url
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python3 -m pytest tests/test_sun.py -v`
+Expected: FAIL — `ImportError: cannot import name 'geolocate'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Append to `hyprsunset_qt/sun.py`:
+```python
+import urllib.request
+
+GEO_URL = "http://ip-api.com/json"
+SUN_URL = "https://api.sunrise-sunset.org/json?lat={lat}&lng={lon}&formatted=0"
+
+
+def _http_get(url: str, timeout: float = 10.0) -> str:
+ with urllib.request.urlopen(url, timeout=timeout) as resp:
+ return resp.read().decode()
+
+
+def geolocate() -> tuple[str, str]:
+ """Return (lat, lon) as strings from IP geolocation."""
+ data = json.loads(_http_get(GEO_URL))
+ return str(data["lat"]), str(data["lon"])
+
+
+def fetch_sun(lat: str, lon: str) -> dict:
+ """Fetch sunrise/sunset JSON for coords. Caller handles caching."""
+ url = SUN_URL.format(lat=lat, lon=lon)
+ return json.loads(_http_get(url))
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python3 -m pytest tests/test_sun.py -v`
+Expected: PASS (5 passed).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add hyprsunset_qt/sun.py tests/test_sun.py
+git commit -S -m "feat: geolocate and sunrise-sunset fetch"
+```
+
+---
+
+## Task 8: Daemon control
+
+**Files:**
+- Create: `hyprsunset_qt/daemon.py`
+- Test: `tests/test_daemon.py`
+
+Daemon functions shell out; tests assert the commands built, with `subprocess` mocked. No real processes touched.
+
+- [ ] **Step 1: Write the failing test**
+
+`tests/test_daemon.py`:
+```python
+from unittest.mock import patch, MagicMock
+from hyprsunset_qt import daemon
+
+
+def test_is_running_true():
+ with patch("hyprsunset_qt.daemon.subprocess.run") as run:
+ run.return_value = MagicMock(returncode=0)
+ assert daemon.is_running() is True
+ run.assert_called_once()
+
+
+def test_is_running_false():
+ with patch("hyprsunset_qt.daemon.subprocess.run") as run:
+ run.return_value = MagicMock(returncode=1)
+ assert daemon.is_running() is False
+
+
+def test_live_preview_identity():
+ with patch("hyprsunset_qt.daemon.subprocess.run") as run:
+ daemon.live_preview(identity=True)
+ run.assert_called_with(
+ ["hyprctl", "hyprsunset", "identity"], check=False
+ )
+
+
+def test_live_preview_temp_and_gamma():
+ with patch("hyprsunset_qt.daemon.subprocess.run") as run:
+ daemon.live_preview(temperature=5500, gamma=0.8)
+ calls = [c.args[0] for c in run.call_args_list]
+ assert ["hyprctl", "hyprsunset", "temperature", "5500"] in calls
+ assert ["hyprctl", "hyprsunset", "gamma", "80"] in calls
+
+
+def test_restart_kills_then_launches():
+ with patch("hyprsunset_qt.daemon.subprocess.run") as run, \
+ patch("hyprsunset_qt.daemon.subprocess.Popen") as popen:
+ daemon.restart("hyprsunset")
+ run.assert_called_with(["pkill", "hyprsunset"], check=False)
+ popen.assert_called_once()
+ assert popen.call_args[0][0] == ["hyprsunset"]
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python3 -m pytest tests/test_daemon.py -v`
+Expected: FAIL — `ModuleNotFoundError: No module named 'hyprsunset_qt.daemon'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+`hyprsunset_qt/daemon.py`:
+```python
+"""Control the hyprsunset daemon: status, restart, live preview via hyprctl."""
+
+from __future__ import annotations
+
+import shlex
+import subprocess
+from typing import Optional
+
+
+def is_running() -> bool:
+ return subprocess.run(
+ ["pgrep", "-x", "hyprsunset"],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ ).returncode == 0
+
+
+def live_preview(
+ temperature: Optional[int] = None,
+ gamma: Optional[float] = None,
+ identity: bool = False,
+) -> None:
+ """Push values to the running daemon over IPC. No file write, no restart."""
+ if identity:
+ subprocess.run(["hyprctl", "hyprsunset", "identity"], check=False)
+ return
+ if temperature is not None:
+ subprocess.run(
+ ["hyprctl", "hyprsunset", "temperature", str(temperature)],
+ check=False,
+ )
+ if gamma is not None:
+ # IPC gamma is integer percent; profile gamma is a 0-2 float.
+ pct = str(round(gamma * 100))
+ subprocess.run(["hyprctl", "hyprsunset", "gamma", pct], check=False)
+
+
+def restart(command: str = "hyprsunset") -> None:
+ subprocess.run(["pkill", "hyprsunset"], check=False)
+ subprocess.Popen(
+ shlex.split(command),
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+```
+
+Note: `is_running` uses `pgrep -x`; the test patches `subprocess.run` and only checks `returncode`, so the exact args differ from the assertion in `test_is_running_true` (which only asserts `called_once`). Keep the implementation as shown.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python3 -m pytest tests/test_daemon.py -v`
+Expected: PASS (5 passed).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add hyprsunset_qt/daemon.py tests/test_daemon.py
+git commit -S -m "feat: daemon status, restart, live preview"
+```
+
+---
+
+## Task 9: Validation helpers
+
+**Files:**
+- Modify: `hyprsunset_qt/config.py`
+- Test: `tests/test_config.py`
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/test_config.py`:
+```python
+from hyprsunset_qt.config import valid_time, valid_temperature, valid_gamma
+
+
+def test_valid_time():
+ assert valid_time("19:40")
+ assert valid_time("5:00")
+ assert not valid_time("25:00")
+ assert not valid_time("19:60")
+ assert not valid_time("abc")
+
+
+def test_valid_temperature():
+ assert valid_temperature(5500)
+ assert not valid_temperature(500)
+ assert not valid_temperature(30000)
+
+
+def test_valid_gamma():
+ assert valid_gamma(0.8)
+ assert valid_gamma(0.0)
+ assert not valid_gamma(-1.0)
+ assert not valid_gamma(2.5)
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python3 -m pytest tests/test_config.py -v`
+Expected: FAIL — `ImportError: cannot import name 'valid_time'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Append to `hyprsunset_qt/config.py`:
+```python
+_TIME_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
+
+
+def valid_time(s: str) -> bool:
+ return bool(_TIME_RE.match(s))
+
+
+def valid_temperature(t: int) -> bool:
+ return 1000 <= t <= 20000
+
+
+def valid_gamma(g: float) -> bool:
+ return 0.0 <= g <= 2.0
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python3 -m pytest tests/test_config.py -v`
+Expected: PASS (11 passed).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add hyprsunset_qt/config.py tests/test_config.py
+git commit -S -m "feat: field validation helpers"
+```
+
+---
+
+## Task 10: UI — main window
+
+**Files:**
+- Create: `hyprsunset_qt/ui.py`
+- Create: `hyprsunset_qt/__main__.py`
+
+No unit test (Qt layer). Verified by launching. Wires config/settings/sun/daemon modules into widgets per the spec's UI layout.
+
+- [ ] **Step 1: Write the profile-row widget**
+
+`hyprsunset_qt/ui.py`:
+```python
+"""PyQt6 GUI for hyprsunset-qt."""
+
+from __future__ import annotations
+
+from PyQt6.QtWidgets import (
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
+ QLabel, QLineEdit, QCheckBox, QSpinBox, QDoubleSpinBox, QPushButton,
+ QGroupBox, QScrollArea,
+)
+
+from . import config as cfg
+from . import daemon
+from . import sun
+from .settings import Settings
+
+
+class ProfileRow(QWidget):
+ def __init__(self, profile: cfg.Profile):
+ super().__init__()
+ lay = QHBoxLayout(self)
+ lay.setContentsMargins(0, 0, 0, 0)
+
+ self.time = QLineEdit(profile.time)
+ self.time.setFixedWidth(70)
+
+ self.identity = QCheckBox("identity")
+ self.identity.setChecked(profile.identity)
+
+ self.temp_on = QCheckBox("temp")
+ self.temp_on.setChecked(profile.temperature is not None)
+ self.temp = QSpinBox()
+ self.temp.setRange(1000, 20000)
+ self.temp.setValue(profile.temperature or 5500)
+
+ self.gamma_on = QCheckBox("gamma")
+ self.gamma_on.setChecked(profile.gamma is not None)
+ self.gamma = QDoubleSpinBox()
+ self.gamma.setRange(0.0, 2.0)
+ self.gamma.setSingleStep(0.05)
+ self.gamma.setValue(profile.gamma if profile.gamma is not None else 1.0)
+
+ self.remove = QPushButton("✕")
+ self.remove.setFixedWidth(28)
+
+ for w in (QLabel("time"), self.time, self.identity,
+ self.temp_on, self.temp, self.gamma_on, self.gamma,
+ self.remove):
+ lay.addWidget(w)
+ lay.addStretch()
+
+ self.temp_on.toggled.connect(self.temp.setEnabled)
+ self.gamma_on.toggled.connect(self.gamma.setEnabled)
+ self.temp.setEnabled(self.temp_on.isChecked())
+ self.gamma.setEnabled(self.gamma_on.isChecked())
+
+ def to_profile(self) -> cfg.Profile:
+ return cfg.Profile(
+ time=self.time.text().strip(),
+ identity=self.identity.isChecked(),
+ temperature=self.temp.value() if self.temp_on.isChecked() else None,
+ gamma=self.gamma.value() if self.gamma_on.isChecked() else None,
+ )
+```
+
+- [ ] **Step 2: Write the main window**
+
+Append to `hyprsunset_qt/ui.py`:
+```python
+class MainWindow(QMainWindow):
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("hyprsunset-qt")
+ self.settings = Settings.load()
+ self.rows: list[ProfileRow] = []
+
+ central = QWidget()
+ self.setCentralWidget(central)
+ root = QVBoxLayout(central)
+
+ # Daemon status bar
+ bar = QHBoxLayout()
+ self.status = QLabel()
+ restart_btn = QPushButton("Restart")
+ restart_btn.clicked.connect(self.on_restart)
+ bar.addWidget(self.status)
+ bar.addStretch()
+ bar.addWidget(restart_btn)
+ root.addLayout(bar)
+
+ # Location group
+ loc = QGroupBox("Location")
+ loc_lay = QVBoxLayout(loc)
+ coords = QHBoxLayout()
+ self.lat = QLineEdit(self.settings.lat)
+ self.lon = QLineEdit(self.settings.lon)
+ self.auto = QCheckBox("auto")
+ self.auto.setChecked(self.settings.auto_detect)
+ detect = QPushButton("Detect")
+ detect.clicked.connect(self.on_detect)
+ for w in (QLabel("Lat"), self.lat, QLabel("Lon"), self.lon,
+ self.auto, detect):
+ coords.addWidget(w)
+ loc_lay.addLayout(coords)
+ fetch_row = QHBoxLayout()
+ fetch_btn = QPushButton("Fetch sun times")
+ fetch_btn.clicked.connect(self.on_fetch)
+ self.sun_label = QLabel("sunrise — sunset —")
+ fetch_row.addWidget(fetch_btn)
+ fetch_row.addWidget(self.sun_label)
+ fetch_row.addStretch()
+ loc_lay.addLayout(fetch_row)
+ root.addWidget(loc)
+
+ # Profiles group
+ prof_box = QGroupBox("Profiles")
+ prof_outer = QVBoxLayout(prof_box)
+ add_row = QHBoxLayout()
+ add_btn = QPushButton("+ Add")
+ add_btn.clicked.connect(lambda: self.add_row(cfg.Profile(time="0:00")))
+ add_row.addStretch()
+ add_row.addWidget(add_btn)
+ prof_outer.addLayout(add_row)
+ self.rows_lay = QVBoxLayout()
+ prof_outer.addLayout(self.rows_lay)
+ root.addWidget(prof_box)
+
+ # Bottom actions
+ actions = QHBoxLayout()
+ preview_btn = QPushButton("Live preview")
+ preview_btn.clicked.connect(self.on_preview)
+ save_btn = QPushButton("Save + Restart")
+ save_btn.clicked.connect(self.on_save)
+ actions.addWidget(preview_btn)
+ actions.addStretch()
+ actions.addWidget(save_btn)
+ root.addLayout(actions)
+
+ for p in cfg.read_config():
+ self.add_row(p)
+ self.refresh_status()
+ self.load_cached_sun()
+
+ # --- helpers ---
+ def add_row(self, profile: cfg.Profile) -> None:
+ row = ProfileRow(profile)
+ row.remove.clicked.connect(lambda: self.remove_row(row))
+ self.rows.append(row)
+ self.rows_lay.addWidget(row)
+
+ def remove_row(self, row: ProfileRow) -> None:
+ self.rows.remove(row)
+ row.setParent(None)
+
+ def profiles(self) -> list[cfg.Profile]:
+ return [r.to_profile() for r in self.rows]
+
+ def refresh_status(self) -> None:
+ running = daemon.is_running()
+ self.status.setText(
+ "Daemon: ● running" if running else "Daemon: ○ stopped"
+ )
+
+ def save_settings(self) -> None:
+ self.settings.lat = self.lat.text().strip()
+ self.settings.lon = self.lon.text().strip()
+ self.settings.auto_detect = self.auto.isChecked()
+ self.settings.save()
+
+ # --- actions ---
+ def on_restart(self) -> None:
+ daemon.restart(self.settings.daemon_command)
+ self.refresh_status()
+
+ def on_detect(self) -> None:
+ try:
+ lat, lon = sun.geolocate()
+ self.lat.setText(lat)
+ self.lon.setText(lon)
+ except Exception as e: # noqa: BLE001 — surface any network error
+ self.sun_label.setText(f"detect failed: {e}")
+
+ def on_fetch(self) -> None:
+ self.save_settings()
+ lat, lon = self.lat.text().strip(), self.lon.text().strip()
+ try:
+ data = sun.fetch_sun(lat, lon)
+ except Exception as e: # noqa: BLE001
+ self.sun_label.setText(f"fetch failed: {e}")
+ return
+ data["lat"], data["lon"] = lat, lon
+ sun.write_cache(data, self.settings.cache_path_expanded())
+ self.apply_sun(data)
+
+ def load_cached_sun(self) -> None:
+ data = sun.read_cache(self.settings.cache_path_expanded())
+ if data:
+ self.apply_sun(data)
+
+ def apply_sun(self, data: dict) -> None:
+ r = data.get("results", {})
+ sunrise = sun.to_local_hm(r["sunrise"]) if r.get("sunrise") else "—"
+ sunset = sun.to_local_hm(r["sunset"]) if r.get("sunset") else "—"
+ self.sun_label.setText(f"sunrise {sunrise} sunset {sunset}")
+ profiles = self.profiles()
+ di = cfg.day_index(profiles)
+ ni = cfg.night_index(profiles)
+ if di is not None and sunrise != "—":
+ self.rows[di].time.setText(sunrise)
+ if ni is not None and sunset != "—":
+ self.rows[ni].time.setText(sunset)
+
+ def on_preview(self) -> None:
+ profiles = self.profiles()
+ ni = cfg.night_index(profiles)
+ target = profiles[ni] if ni is not None else (
+ profiles[0] if profiles else None
+ )
+ if target is None:
+ return
+ daemon.live_preview(
+ temperature=target.temperature,
+ gamma=target.gamma,
+ identity=target.identity,
+ )
+
+ def on_save(self) -> None:
+ profiles = self.profiles()
+ for p in profiles:
+ if not cfg.valid_time(p.time):
+ self.sun_label.setText(f"invalid time: {p.time!r}")
+ return
+ if p.temperature is not None and not cfg.valid_temperature(p.temperature):
+ self.sun_label.setText(f"invalid temperature: {p.temperature}")
+ return
+ if p.gamma is not None and not cfg.valid_gamma(p.gamma):
+ self.sun_label.setText(f"invalid gamma: {p.gamma}")
+ return
+ cfg.write_config(profiles)
+ self.save_settings()
+ daemon.restart(self.settings.daemon_command)
+ self.refresh_status()
+ self.sun_label.setText("saved + restarted")
+```
+
+- [ ] **Step 3: Write the entry point**
+
+`hyprsunset_qt/__main__.py`:
+```python
+"""Entry point for hyprsunset-qt."""
+
+import sys
+
+from PyQt6.QtWidgets import QApplication
+
+from .ui import MainWindow
+
+
+def main() -> int:
+ app = QApplication(sys.argv)
+ win = MainWindow()
+ win.resize(640, 400)
+ win.show()
+ return app.exec()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
+```
+
+- [ ] **Step 4: Smoke-test import (no display needed)**
+
+Run: `python3 -c "import hyprsunset_qt.ui; import hyprsunset_qt.__main__; print('ok')"`
+Expected: `ok` (imports succeed; Qt module loads without opening a window).
+
+- [ ] **Step 5: Manual launch check**
+
+Run (on the user's Hyprland session): `python3 -m hyprsunset_qt`
+Expected: window opens, existing two profiles load, daemon status shows. The user performs this check.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add hyprsunset_qt/ui.py hyprsunset_qt/__main__.py
+git commit -S -m "feat: PyQt6 main window and entry point"
+```
+
+---
+
+## Task 11: Full test run + license + README
+
+**Files:**
+- Create: `LICENSE`
+- Create: `README.md`
+
+- [ ] **Step 1: Run the whole suite**
+
+Run: `python3 -m pytest -q`
+Expected: all tests pass (config, settings, sun, daemon).
+
+- [ ] **Step 2: Add GPLv2 LICENSE**
+
+Fetch the official GPLv2 text from https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt and save as `LICENSE`.
+
+- [ ] **Step 3: Add per-file header notice**
+
+Add to the top of each `hyprsunset_qt/*.py` file, above the module docstring:
+```python
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 2 as published
+# by the Free Software Foundation.
+```
+
+- [ ] **Step 4: Write README.md**
+
+Include: what it does, install (`pip install .` or run `python3 -m hyprsunset_qt`), dependency (PyQt6), the sunrise-sunset.org attribution, a License section (GPLv2-only), and the mandatory Development Approach section verbatim (from global preferences).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add LICENSE README.md hyprsunset_qt/*.py
+git commit -S -m "docs: GPLv2 license, README, attribution"
+```
+
+---
+
+## Self-Review Notes
+
+- **Spec coverage:** config parse/serialize (T2–T4), day/night detection (T3), app settings incl. cache path + auto_detect (T5), sun conversion/cache (T6) and fetch/geolocate (T7), daemon restart/preview/status (T8), validation (T9), UI layout + sunrise→day/sunset→night + manual-only fetch + credit (T10–T11). All spec sections mapped.
+- **Attribution:** README step covers sunrise-sunset.org credit (T11).
+- **Type consistency:** `Profile` fields, `day_index`/`night_index`, `to_local_hm`, `read_cache`/`write_cache`, `live_preview` signature, `Settings` fields used consistently across tasks.
+- **Cache location:** `~/.config/hyprsunset-qt/` (not `~/.cache`, which the user's system wipes at reboot). Configurable via settings `cache.path`.