The design of the full desktop app was still bad, and the reason it stayed bad is that it had no business existing. Five pages — Timer, Tasks, Blocker, Report, Settings — each a second view of the *server's* data: a second place to be wrong, a second place to style, and a second place to keep in step with a web app that already does lists, rules and reports properly in a browser, on any device. So it is gone, along with 2,100 lines of QML and the controller surface that fed it. What is left is what only a program on your machine can do, plus the two surfaces worth having in front of you: * **the tray menu — unchanged**, as asked. All fourteen entries, plus "Settings…". "Open Kadence" now brings the countdown up, because that is what it meant all along. * **the mini timer**, which is now the whole of this app's own interface and so finally carries the house style on its own: warm surface, hairline instead of shadow, a letterspaced eyebrow, the progress lane as a rule, and exactly one filled button — whichever action is the obvious next one. The countdown is set in the display serif on a **fixed advance**, because Fraunces has no tabular figures and ships no `tnum`: each glyph now sits in a box as wide as the widest digit, instead of the clock shifting sideways on every tick. The phone client documents the same trap; the desktop had it too and nobody had looked. It also says OFFLINE when the backend is away — the count and the clock are both the server's, and this is the only window left to say it in. * **a settings dialog**, because a fresh install has to be pointed at a backend somewhere. One column, hairline sections: the backend and its token, where the mini timer sits, what this machine enforces, and what it does to the session. It edits `desktop.json` and nothing else — with one honest exception, view-only mode, which is a server-side flag the other clients have to honour too. Structurally, `app.py` no longer loads a QML root at all: a plain `QQmlEngine` with the mini timer as a component, and the dialog built on demand. Everything that used to ask "is the main window visible?" is simpler for not having one — the mini timer's auto-show rule is now just "an interval is running", which is what the setting always claimed. The window-only controller surface goes with the pages: tasks and task writes, stats, the daily report, the settings document, the presets, the cycle and interruption counters, and their worker jobs and API calls. Two things were kept deliberately: the task list is still read once a minute for one fact — which task is pinned, so "Start focus" from the tray attaches what you pinned this morning — and `timerEnabled` now comes from the timer state, which already carries `timer_enabled`, rather than from a second fetch of a document this app can no longer edit. Also: `--settings` and a `Settings` D-Bus verb; toasts go to the tray's balloon now that the snackbar is gone; the launcher entry starts the agent and grew a Settings action; a systemd user unit is shipped rather than only written at runtime; and both READMEs say what this piece is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
135 lines
4.7 KiB
Python
135 lines
4.7 KiB
Python
"""The network thread.
|
|
|
|
Every HTTP call happens here, on a worker thread, so a slow or dead backend can
|
|
never freeze the window — the UI keeps ticking its countdown from the last known
|
|
state and shows a "reconnecting" chip instead.
|
|
|
|
Commands go in through a queued slot and come back out as signals; the GUI side
|
|
never touches `Api` directly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from PyQt6.QtCore import QObject, QTimer, pyqtSignal, pyqtSlot
|
|
|
|
from kadence_desktop.api import Api, ApiError
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class ApiWorker(QObject):
|
|
stateChanged = pyqtSignal(dict)
|
|
policyChanged = pyqtSignal(dict)
|
|
tasksChanged = pyqtSignal(dict)
|
|
connectionChanged = pyqtSignal(bool, str)
|
|
commandFailed = pyqtSignal(str, str)
|
|
commandDone = pyqtSignal(str, dict)
|
|
|
|
def __init__(self, api: Api, poll_seconds: int = 5) -> None:
|
|
super().__init__()
|
|
self._api = api
|
|
self._poll_seconds = poll_seconds
|
|
self._timer: QTimer | None = None
|
|
self._connected: bool | None = None
|
|
self._slow_tick = 0
|
|
|
|
# ---- lifecycle ---------------------------------------------------------
|
|
|
|
@pyqtSlot()
|
|
def start(self) -> None:
|
|
"""Called once the worker is on its own thread."""
|
|
self._timer = QTimer(self)
|
|
self._timer.timeout.connect(self.poll)
|
|
self._timer.start(max(1, self._poll_seconds) * 1000)
|
|
self.poll()
|
|
self.refresh_tasks()
|
|
|
|
@pyqtSlot(int)
|
|
def set_interval(self, seconds: int) -> None:
|
|
if self._timer:
|
|
self._timer.start(max(1, seconds) * 1000)
|
|
|
|
@pyqtSlot(str, str)
|
|
def configure(self, base_url: str, token: str) -> None:
|
|
self._api.configure(base_url, token)
|
|
self._connected = None
|
|
self.poll()
|
|
|
|
# ---- polling -----------------------------------------------------------
|
|
|
|
@pyqtSlot()
|
|
def poll(self) -> None:
|
|
try:
|
|
state = self._api.timer_state()
|
|
policy = self._api.blocker_policy()
|
|
except ApiError as exc:
|
|
self._set_connected(False, str(exc))
|
|
return
|
|
self._set_connected(True, "")
|
|
self.stateChanged.emit(state)
|
|
self.policyChanged.emit(policy)
|
|
|
|
# The task list changes far more slowly than the clock does, and all the
|
|
# app wants from it now is which one is pinned.
|
|
self._slow_tick += 1
|
|
if self._slow_tick % 12 == 0:
|
|
self.refresh_tasks()
|
|
|
|
def _set_connected(self, connected: bool, detail: str) -> None:
|
|
if connected != self._connected:
|
|
self._connected = connected
|
|
self.connectionChanged.emit(connected, detail)
|
|
|
|
@pyqtSlot()
|
|
def refresh_tasks(self) -> None:
|
|
try:
|
|
self.tasksChanged.emit(self._api.tasks())
|
|
except ApiError as exc:
|
|
log.debug("tasks: %s", exc)
|
|
|
|
# ---- commands ----------------------------------------------------------
|
|
|
|
@pyqtSlot(str, dict)
|
|
def command(self, name: str, args: dict[str, Any]) -> None:
|
|
"""Run one API action by name and re-poll so the UI lands on the truth."""
|
|
handlers = {
|
|
"start": lambda: self._api.start(args.get("kind"), args.get("task_id"), args.get("minutes")),
|
|
"pause": self._api.pause,
|
|
"resume": self._api.resume,
|
|
"complete": lambda: self._api.complete(bool(args.get("auto_next", True))),
|
|
"abandon": lambda: self._api.abandon(args.get("reason")),
|
|
"extend": lambda: self._api.extend(int(args.get("minutes", 5))),
|
|
"interrupt": self._api.interrupt,
|
|
"refresh_sources": self._api.refresh_tasks,
|
|
"send_report": self._api.send_report,
|
|
"patch_settings": lambda: self._api.patch_settings(dict(args.get("patch", {}))),
|
|
"report_block": lambda: self._api.report_block(
|
|
str(args.get("kind", "app")),
|
|
str(args.get("target", "")),
|
|
str(args.get("action", "blocked")),
|
|
args.get("details"),
|
|
),
|
|
}
|
|
handler = handlers.get(name)
|
|
if handler is None:
|
|
self.commandFailed.emit(name, f"unknown command {name!r}")
|
|
return
|
|
try:
|
|
result = handler()
|
|
except ApiError as exc:
|
|
self.commandFailed.emit(name, str(exc))
|
|
return
|
|
except Exception as exc: # pragma: no cover
|
|
log.exception("command %s blew up", name)
|
|
self.commandFailed.emit(name, str(exc))
|
|
return
|
|
|
|
self.commandDone.emit(name, result if isinstance(result, dict) else {})
|
|
if name == "refresh_sources":
|
|
self.refresh_tasks()
|
|
if name != "report_block":
|
|
self.poll()
|