From 2e39e223c4d0d400a7c51867c9b3a01c79395481 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 17 Jul 2026 09:22:46 +0200 Subject: Add implementation plan for hyprsunset-qt Co-Authored-By: Claude Opus 4.8 --- docs/superpowers/plans/2026-07-17-hyprsunset-qt.md | 1206 ++++++++++++++++++++ 1 file changed, 1206 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-hyprsunset-qt.md (limited to 'docs/superpowers') 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. +# 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`. -- cgit v1.2.3