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