# 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/<sig>/.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 <n>` / `gamma <n>` / `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=<lat>&lng=<lon>&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.
