desktop: an agent and a mini timer, not an application window
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>
This commit is contained in:
26 files changed
+752
-2690
No files matched your search
@@ -25,7 +25,7 @@ from PyQt6.QtGui import QDesktopServices
|
||||
from kadence_desktop.api import Api
|
||||
from kadence_desktop.blocking.manager import BlockerManager
|
||||
from kadence_desktop.config import DesktopConfig
|
||||
from kadence_desktop.integrations import autostart, keep_above
|
||||
from kadence_desktop.integrations import autostart, keep_above, placement
|
||||
from kadence_desktop.integrations.inhibit import DoNotDisturb, ScreensaverInhibitor
|
||||
from kadence_desktop.integrations.launcher import LauncherEntry
|
||||
from kadence_desktop.integrations.mpris import MediaController
|
||||
@@ -46,17 +46,15 @@ def clock(seconds: int) -> str:
|
||||
class Controller(QObject):
|
||||
# ---- notifications to QML ---------------------------------------------
|
||||
stateChanged = pyqtSignal()
|
||||
tasksChanged = pyqtSignal()
|
||||
statsChanged = pyqtSignal()
|
||||
reportChanged = pyqtSignal()
|
||||
settingsChanged = pyqtSignal()
|
||||
blockerChanged = pyqtSignal()
|
||||
tasksChanged = pyqtSignal()
|
||||
connectionChanged = pyqtSignal()
|
||||
configChanged = pyqtSignal()
|
||||
miniChanged = pyqtSignal()
|
||||
miniPlacementChanged = pyqtSignal()
|
||||
toast = pyqtSignal(str)
|
||||
showWindowRequested = pyqtSignal()
|
||||
settingsRequested = pyqtSignal()
|
||||
|
||||
def __init__(self, config: DesktopConfig, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -66,11 +64,14 @@ class Controller(QObject):
|
||||
# ---- state mirrors --------------------------------------------------
|
||||
self._state: dict[str, Any] = {}
|
||||
self._policy: dict[str, Any] = {}
|
||||
self._tasks: list[dict[str, Any]] = []
|
||||
self._task_counts: dict[str, Any] = {}
|
||||
self._stats: dict[str, Any] = {}
|
||||
self._report: dict[str, Any] = {}
|
||||
self._settings: dict[str, Any] = {}
|
||||
self._pinned_task_id: int | None = None
|
||||
"""The task pinned in the web app, so "Start focus" can attach it.
|
||||
|
||||
All that is left of this app's interest in the task list: there is no
|
||||
Tasks page any more, but starting an interval from the tray and having
|
||||
it land on the thing you pinned this morning is worth one request a
|
||||
minute. The list itself is never rendered here.
|
||||
"""
|
||||
self._connected = False
|
||||
self._connection_error = ""
|
||||
self._remaining = 0
|
||||
@@ -116,9 +117,6 @@ class Controller(QObject):
|
||||
self._worker.stateChanged.connect(self._on_state)
|
||||
self._worker.policyChanged.connect(self._on_policy)
|
||||
self._worker.tasksChanged.connect(self._on_tasks)
|
||||
self._worker.statsChanged.connect(self._on_stats)
|
||||
self._worker.reportChanged.connect(self._on_report)
|
||||
self._worker.settingsChanged.connect(self._on_settings)
|
||||
self._worker.connectionChanged.connect(self._on_connection)
|
||||
self._worker.commandFailed.connect(self._on_command_failed)
|
||||
self._worker.commandDone.connect(self._on_command_done)
|
||||
@@ -208,20 +206,6 @@ class Controller(QObject):
|
||||
task = current.get("task") or {}
|
||||
return str(task.get("title", ""))
|
||||
|
||||
@pyqtProperty(int, notify=stateChanged)
|
||||
def taskId(self) -> int:
|
||||
for task in self._tasks:
|
||||
current = self._state.get("current") or {}
|
||||
attached = current.get("task") or {}
|
||||
if attached and task.get("external_id") == attached.get("external_id"):
|
||||
return int(task.get("id", -1))
|
||||
return -1
|
||||
|
||||
@pyqtProperty(int, notify=stateChanged)
|
||||
def interruptions(self) -> int:
|
||||
current = self._state.get("current") or {}
|
||||
return int(current.get("interruptions", 0))
|
||||
|
||||
@pyqtProperty(int, notify=stateChanged)
|
||||
def completedToday(self) -> int:
|
||||
return int(self._state.get("completed_today", 0))
|
||||
@@ -230,63 +214,18 @@ class Controller(QObject):
|
||||
def goal(self) -> int:
|
||||
return int(self._state.get("goal", 8))
|
||||
|
||||
@pyqtProperty(int, notify=stateChanged)
|
||||
def cyclePosition(self) -> int:
|
||||
return int(self._state.get("cycle_position", 1))
|
||||
|
||||
@pyqtProperty(int, notify=stateChanged)
|
||||
def cycleLength(self) -> int:
|
||||
return int(self._state.get("cycles_before_long_break", 4))
|
||||
|
||||
@pyqtProperty(str, notify=stateChanged)
|
||||
def nextKindLabel(self) -> str:
|
||||
return KIND_LABELS.get(str(self._state.get("next_kind", "work")), "Focus")
|
||||
|
||||
@pyqtProperty(bool, notify=settingsChanged)
|
||||
@pyqtProperty(bool, notify=stateChanged)
|
||||
def timerEnabled(self) -> bool:
|
||||
"""When False the app is in view-only mode — no timer controls."""
|
||||
"""When False the app is in view-only mode — no timer controls.
|
||||
|
||||
Read from the timer *state*, which carries `timer_enabled`, rather than
|
||||
from a second fetch of the settings document: this app no longer has a
|
||||
page to edit that document in, and one less endpoint to poll is one less
|
||||
thing to be out of date.
|
||||
"""
|
||||
if self._config.view_only:
|
||||
return False
|
||||
pomodoro = self._settings.get("pomodoro", {})
|
||||
return bool(pomodoro.get("timer_enabled", True))
|
||||
|
||||
@pyqtProperty("QVariantList", notify=tasksChanged)
|
||||
def tasks(self) -> list[dict[str, Any]]:
|
||||
return self._tasks
|
||||
|
||||
@pyqtProperty("QVariantMap", notify=tasksChanged)
|
||||
def taskCounts(self) -> dict[str, Any]:
|
||||
return self._task_counts
|
||||
|
||||
@pyqtProperty("QVariantMap", notify=statsChanged)
|
||||
def stats(self) -> dict[str, Any]:
|
||||
return self._stats
|
||||
|
||||
@pyqtProperty("QVariantMap", notify=reportChanged)
|
||||
def report(self) -> dict[str, Any]:
|
||||
return self._report
|
||||
|
||||
@pyqtProperty("QVariantMap", notify=settingsChanged)
|
||||
def settings(self) -> dict[str, Any]:
|
||||
return self._settings
|
||||
|
||||
@pyqtProperty(int, notify=settingsChanged)
|
||||
def activePresetIndex(self) -> int:
|
||||
"""Which saved preset the live Focus/Short/Long minutes match, or -1.
|
||||
|
||||
One source of truth for SettingsPage (the full switcher, with the
|
||||
numbers editable below it) and TimerPage (a quick switcher next to
|
||||
the dial) so the two never compute "which preset is this" differently.
|
||||
"""
|
||||
pomodoro = self._settings.get("pomodoro", {})
|
||||
for index, preset in enumerate(pomodoro.get("presets", [])):
|
||||
if (
|
||||
preset.get("work_minutes") == pomodoro.get("work_minutes")
|
||||
and preset.get("short_break_minutes") == pomodoro.get("short_break_minutes")
|
||||
and preset.get("long_break_minutes") == pomodoro.get("long_break_minutes")
|
||||
):
|
||||
return index
|
||||
return -1
|
||||
return bool(self._state.get("timer_enabled", True))
|
||||
|
||||
@pyqtProperty(bool, notify=blockerChanged)
|
||||
def blockerActive(self) -> bool:
|
||||
@@ -345,6 +284,30 @@ class Controller(QObject):
|
||||
def blockerError(self) -> str:
|
||||
return self._blocker.error or ""
|
||||
|
||||
@pyqtProperty("QVariantList", notify=configChanged)
|
||||
def miniCornerLabels(self) -> list[str]:
|
||||
"""Where the mini timer may be put, as the settings dialog lists them.
|
||||
|
||||
From `placement.CORNER_LABELS` rather than written out again in QML:
|
||||
the old settings page kept its own copy of this list, which is how a
|
||||
corner gets added in one place and silently missing in the other.
|
||||
"""
|
||||
return list(placement.CORNER_LABELS.values())
|
||||
|
||||
@pyqtProperty(int, notify=configChanged)
|
||||
def miniCornerIndex(self) -> int:
|
||||
keys = list(placement.CORNER_LABELS)
|
||||
try:
|
||||
return keys.index(self._config.mini_timer_corner)
|
||||
except ValueError:
|
||||
return keys.index("bottom-right")
|
||||
|
||||
@pyqtSlot(int)
|
||||
def setMiniCorner(self, index: int) -> None:
|
||||
keys = list(placement.CORNER_LABELS)
|
||||
if 0 <= index < len(keys):
|
||||
self.setConfig("mini_timer_corner", keys[index])
|
||||
|
||||
@pyqtProperty(bool, notify=miniChanged)
|
||||
def miniVisible(self) -> bool:
|
||||
"""Whether the floating mini timer is pinned open by the user."""
|
||||
@@ -389,10 +352,8 @@ class Controller(QObject):
|
||||
if minutes:
|
||||
args["minutes"] = minutes
|
||||
current_task = self._state.get("current") or {}
|
||||
if not current_task.get("task"):
|
||||
pinned = next((task for task in self._tasks if task.get("pinned")), None)
|
||||
if pinned:
|
||||
args["task_id"] = pinned["id"]
|
||||
if not current_task.get("task") and self._pinned_task_id is not None:
|
||||
args["task_id"] = self._pinned_task_id
|
||||
self._send("start", args)
|
||||
|
||||
@pyqtSlot()
|
||||
@@ -419,8 +380,8 @@ class Controller(QObject):
|
||||
"""Toggle view-only mode on or off."""
|
||||
self._config.update(view_only=not enabled)
|
||||
self.configChanged.emit()
|
||||
self.settingsChanged.emit()
|
||||
self.patchSettings({"pomodoro": {"timer_enabled": enabled}})
|
||||
self.stateChanged.emit()
|
||||
self._patch_settings({"pomodoro": {"timer_enabled": enabled}})
|
||||
|
||||
@pyqtSlot()
|
||||
def pause(self) -> None:
|
||||
@@ -448,19 +409,6 @@ class Controller(QObject):
|
||||
def interrupt(self) -> None:
|
||||
self._send("interrupt")
|
||||
|
||||
@pyqtSlot(int)
|
||||
def attachTask(self, task_id: int) -> None:
|
||||
self._send("attach_task", {"task_id": task_id if task_id >= 0 else None})
|
||||
|
||||
@pyqtSlot(int, bool)
|
||||
def setTaskPinned(self, task_id: int, pinned: bool) -> None:
|
||||
"""Pinning is the only task edit this app makes — closing one is not."""
|
||||
self._send("patch_task", {"task_id": task_id, "patch": {"pinned": pinned}})
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def createTask(self, title: str, due_date: str) -> None:
|
||||
self._send("create_task", {"title": title, "due_date": due_date or None})
|
||||
|
||||
@pyqtSlot()
|
||||
def refreshSources(self) -> None:
|
||||
self.toast.emit("Pulling Jira, Todoist, Google Tasks…")
|
||||
@@ -470,43 +418,14 @@ class Controller(QObject):
|
||||
def sendReport(self) -> None:
|
||||
self._send("send_report")
|
||||
|
||||
@pyqtSlot()
|
||||
def loadReport(self) -> None:
|
||||
from PyQt6.QtCore import QMetaObject, Qt
|
||||
def _patch_settings(self, patch: dict[str, Any]) -> None:
|
||||
"""The one write this app still makes to the server's settings document.
|
||||
|
||||
QMetaObject.invokeMethod(self._worker, "refresh_report", Qt.ConnectionType.QueuedConnection)
|
||||
|
||||
@pyqtSlot()
|
||||
def loadSettings(self) -> None:
|
||||
from PyQt6.QtCore import QMetaObject, Qt
|
||||
|
||||
QMetaObject.invokeMethod(self._worker, "refresh_settings", Qt.ConnectionType.QueuedConnection)
|
||||
|
||||
@pyqtSlot("QVariantMap")
|
||||
def patchSettings(self, patch: dict[str, Any]) -> None:
|
||||
self._send("patch_settings", {"patch": patch})
|
||||
|
||||
@pyqtSlot(int)
|
||||
def applyPreset(self, index: int) -> None:
|
||||
"""Copy a saved preset's minutes onto the live timer settings.
|
||||
|
||||
The backend resizes whatever interval is already running or paused
|
||||
to match, so this takes effect at once — not just on the next one.
|
||||
Only [setTimerEnabled] uses it — view-only mode is a server-side flag,
|
||||
because the other clients have to honour it too. Everything else in that
|
||||
document is edited in the web app.
|
||||
"""
|
||||
presets = self._settings.get("pomodoro", {}).get("presets", [])
|
||||
if not (0 <= index < len(presets)):
|
||||
return
|
||||
preset = presets[index]
|
||||
self.patchSettings(
|
||||
{
|
||||
"pomodoro": {
|
||||
"work_minutes": preset["work_minutes"],
|
||||
"short_break_minutes": preset["short_break_minutes"],
|
||||
"long_break_minutes": preset["long_break_minutes"],
|
||||
"cycles_before_long_break": preset["cycles_before_long_break"],
|
||||
}
|
||||
}
|
||||
)
|
||||
self._send("patch_settings", {"patch": patch})
|
||||
|
||||
@pyqtSlot(str, "QVariant")
|
||||
def setConfig(self, key: str, value: Any) -> None:
|
||||
@@ -675,22 +594,13 @@ class Controller(QObject):
|
||||
return max(1, min(30, seconds)) * 1000
|
||||
|
||||
def _on_tasks(self, payload: dict[str, Any]) -> None:
|
||||
self._tasks = list(payload.get("tasks", []))
|
||||
self._task_counts = dict(payload.get("counts", {}))
|
||||
pinned = next(
|
||||
(task for task in payload.get("tasks", []) if task.get("pinned")),
|
||||
None,
|
||||
)
|
||||
self._pinned_task_id = int(pinned["id"]) if pinned else None
|
||||
self.tasksChanged.emit()
|
||||
|
||||
def _on_stats(self, payload: dict[str, Any]) -> None:
|
||||
self._stats = payload
|
||||
self.statsChanged.emit()
|
||||
|
||||
def _on_report(self, payload: dict[str, Any]) -> None:
|
||||
self._report = payload
|
||||
self.reportChanged.emit()
|
||||
|
||||
def _on_settings(self, payload: dict[str, Any]) -> None:
|
||||
self._settings = payload.get("settings", payload)
|
||||
self.settingsChanged.emit()
|
||||
|
||||
def _on_connection(self, connected: bool, detail: str) -> None:
|
||||
self._connected = connected
|
||||
self._connection_error = detail
|
||||
|
||||
Reference in new issue
Block a user