# hyprsunset-qt — Design Spec Date: 2026-07-17 A small Qt6 GUI to edit `~/.config/hypr/hyprsunset.conf` and control the `hyprsunset` daemon, with internet-fetched sunrise/sunset times for accurate per-location scheduling. ## Goals - Manage hyprsunset profiles (add/remove/edit) through a GUI. - Fetch accurate sunrise/sunset times for the user's location and drop them into the day/night profiles. - Apply changes by restarting the daemon; offer live preview via IPC before saving. - Minimal dependencies: PyQt6 plus the Python standard library only. ## Non-Goals - No config-format preservation of hand edits (comments/whitespace are regenerated, see Config Model). - No automatic/scheduled sunset refresh. Refetch is manual only. - No packaging/distribution work in this spec (SlackBuild etc. is separate). ## Stack - Language: Python 3, single file where practical. - GUI: PyQt6 (Riverbank, GPL) — aligns with the project's GPLv2 license. - Everything else from stdlib: `urllib.request`, `json`, `subprocess`, `configparser`, `re`, `datetime`, `zoneinfo`, `pathlib`. ## Architecture One Python file, internally split by concern: 1. **Config model** — parse/serialize `hyprsunset.conf`. 2. **App settings** — load/write `~/.config/hyprsunset-qt/config`. 3. **Daemon control** — restart, live preview, status. 4. **Sun fetch** — geolocation + sunrise-sunset lookup, cached. 5. **UI** — PyQt6 main window wiring the above. Pure-logic units (1–4, minus the network/subprocess edges) are testable without Qt or network. ## Config Model Target file: `~/.config/hypr/hyprsunset.conf` (hyprlang `profile { ... }` blocks). `Profile` (dataclass): - `time: str` — `HH:MM` - `identity: bool` - `temperature: int | None` - `gamma: float | None` Functions: - `parse_config(text) -> list[Profile]` — regex over `profile { ... }` blocks, read `time`, `identity`, `temperature`, `gamma` keys. - `serialize(profiles) -> str` — **regenerate clean**. Emit a header comment and a per-block comment tagging day/night for a human reader. Hand-edited comments/whitespace are not preserved. **Day/night tagging** is inferred, not stored: the profile with `identity = true` is the *day* profile (sunrise target); the profile with `temperature` set is the *night* profile (sunset target). No extra state file. Example serialized output: ``` # Managed by hyprsunset-qt. Edits here are overwritten on save. # day profile — sunrise profile { time = 5:00 identity = true } # night profile — sunset profile { time = 19:40 temperature = 5500 gamma = 0.8 } ``` ## App Settings Path: `~/.config/hyprsunset-qt/config` (INI via `configparser`). Created with defaults on first run. Lives in `.config` (not `.cache`) because the user's `~/.cache` is wiped at reboot and would never persist. ```ini [location] lat = 45.07 lon = 7.68 auto_detect = true ; if false, skip IP geolocation and use lat/lon above [cache] path = ~/.config/hyprsunset-qt/sun.json ; sunset JSON cache location [daemon] command = hyprsunset ; launch command, override if needed ``` - `~` is expanded on read. - lat/lon double as manual override and last-known values. - With `auto_detect = false`, no IP geolocation call is made (privacy). - `cache.path` and `daemon.command` are advanced keys, edited by hand in the file, not surfaced in the UI. lat/lon and auto_detect are edited via UI fields and written back. `ponytail:` flat INI, no schema validation lib — add validation only if values start breaking things. ## Daemon Control hyprsunset v0.3.3: has an IPC socket (`$XDG_RUNTIME_DIR/hypr//.hyprsunset.sock`) driven by `hyprctl hyprsunset temperature|gamma|identity`, but **no config reload**. Config is read only at daemon startup, so applying config changes requires a restart. - `restart()` — `pkill hyprsunset`, then launch `[daemon].command` detached (`subprocess.Popen`). Matches the bare `hl.exec_cmd("hyprsunset")` in the user's Hyprland autostart. - `live_preview(temperature, gamma, identity)` — push values via `hyprctl hyprsunset temperature ` / `gamma ` / `identity`. Instant, no restart, no file write. For testing a look before saving. - `is_running()` — `pgrep hyprsunset` to show status and gate the Restart button. Note: `hyprctl hyprsunset gamma`/`temperature` take/return integer percent and Kelvin respectively; live preview maps the profile's gamma (0–2 float) to the IPC's expected units. ## Sun Fetch - `geolocate() -> (lat, lon)` — GET `http://ip-api.com/json` (free, no key), read `lat`/`lon`. On failure, leave fields for manual entry. - `fetch_sun(lat, lon) -> dict` — GET `https://api.sunrise-sunset.org/json?lat=&lng=&formatted=0`. Returns UTC ISO timestamps. Convert `sunrise`/`sunset` to local `HH:MM` using `datetime` + `zoneinfo` (system local zone). - **Cache** — write the raw response JSON plus the queried lat/lon to `[cache].path`. Reused on app open. **Manual refresh only**: refetch happens on [Fetch sun times] click or when lat/lon changes, never automatically. - **Apply**: sunrise -> day profile `time`, sunset -> night profile `time`. **Attribution**: credit sunrise-sunset.org in the README (their usage terms request it). Error handling: any network failure shows an error in a status label, keeps the last cache, and never crashes. With no internet, all profile times remain fully hand-editable. ## UI Layout Single main window, top to bottom: ``` Daemon: ● running [Restart] ------------------------------------------------ Location Lat [45.07] Lon [7.68] [ ] auto [Detect] [Fetch sun times] sunrise 05:12 sunset 19:40 ------------------------------------------------ Profiles [+ Add] time [05:00] [x] identity [ ] temp [ ] gamma [X] time [19:40] [ ] identity [x] temp [5500] [x] gamma [0.8] [X] ------------------------------------------------ [Live preview >] [Save + Restart] ``` - Each profile is a row widget: a time field, checkboxes toggling identity/temperature/gamma (each enabling its spinbox), and a remove `[X]`. `[+ Add]` appends a blank profile. - **Live preview** pushes the selected profile's temp/gamma/identity via `hyprctl`, without writing or restarting. - **Save + Restart** validates all -> serializes -> writes conf -> restarts daemon -> updates status. ### Validation - `time` matches `HH:MM` (00:00–23:59). - `temperature` integer, 1000–20000 K. - `gamma` float, 0.0–2.0. - Invalid field is marked (red) and blocks save. ## Testing TDD for pure logic (no Qt, no network): - `parse_config` / `serialize` round-trip. - sunset/sunrise UTC -> local `HH:MM` conversion. - settings load, defaults on first run, `~` expansion. - cache read/write. Network, daemon, and Qt layers are thin wrappers — mocked or manually verified, not unit-tested. ## Open Questions None blocking. Advanced-key UI exposure and validation strictness can evolve after first use.