# 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. """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, ) 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 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")