# 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`.