Files
kadence/desktop/kadence_desktop/tray.py
T
timka123andClaude Opus 5 8e659cd315 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>
2026-10-22 18:00:52 +03:00

220 lines
8.6 KiB
Python

"""The tray icon — on KDE this becomes a StatusNotifierItem, so it behaves.
The icon is painted at runtime rather than loaded from a file, because it *is*
the timer: a ring that empties as the interval runs, tinted by what is running
(focus / break / idle). With the window closed you still have a countdown, a
tooltip with the full status, and a menu that can drive everything.
"""
from __future__ import annotations
from PyQt6.QtCore import QObject, QRectF, Qt, pyqtSignal
from PyQt6.QtGui import QAction, QColor, QIcon, QPainter, QPen, QPixmap
from PyQt6.QtWidgets import QMenu, QSystemTrayIcon
# The same validated hues the charts use, so the tray matches the app.
COLOR_WORK = QColor("#2a78d6")
COLOR_SHORT = QColor("#eb6834")
COLOR_LONG = QColor("#1baf7a")
COLOR_IDLE = QColor("#8a8a8a")
COLOR_OFFLINE = QColor("#e34948")
_SIZE = 64
def _state_color(kind: str, running: bool, connected: bool) -> QColor:
if not connected:
return COLOR_OFFLINE
if not running:
return COLOR_IDLE
return {"work": COLOR_WORK, "short_break": COLOR_SHORT, "long_break": COLOR_LONG}.get(kind, COLOR_WORK)
def render_icon(progress: float, kind: str, running: bool, connected: bool, paused: bool) -> QIcon:
"""A ring that drains as the interval runs."""
pixmap = QPixmap(_SIZE, _SIZE)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
color = _state_color(kind, running, connected)
rect = QRectF(7, 7, _SIZE - 14, _SIZE - 14)
track = QColor(color)
track.setAlpha(60)
painter.setPen(QPen(track, 8, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
painter.drawArc(rect, 0, 360 * 16)
if running:
painter.setPen(QPen(color, 8, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
span = int(-360 * 16 * max(0.0, min(1.0, 1 - progress)))
painter.drawArc(rect, 90 * 16, span)
if paused:
# Two bars in the middle: unmistakably "on hold".
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawRect(QRectF(24, 23, 6, 18))
painter.drawRect(QRectF(34, 23, 6, 18))
else:
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawEllipse(QRectF(26, 26, 12, 12))
painter.end()
return QIcon(pixmap)
class Tray(QObject):
"""Tray icon plus its menu. Every entry maps to a controller slot."""
showRequested = pyqtSignal()
settingsRequested = pyqtSignal()
quitRequested = pyqtSignal()
def __init__(self, controller, parent: QObject | None = None) -> None:
super().__init__(parent)
self._controller = controller
self._icon = QSystemTrayIcon(parent)
self._menu = QMenu()
self._build_menu()
self._icon.setContextMenu(self._menu)
self._icon.activated.connect(self._on_activated)
controller.stateChanged.connect(self.refresh)
controller.connectionChanged.connect(self.refresh)
controller.blockerChanged.connect(self.refresh)
controller.miniChanged.connect(self.refresh)
controller.settingsChanged.connect(self.refresh)
controller.configChanged.connect(self.refresh)
self.refresh()
def show(self) -> None:
self._icon.show()
def hide(self) -> None:
self._icon.hide()
@property
def available(self) -> bool:
return QSystemTrayIcon.isSystemTrayAvailable()
def _build_menu(self) -> None:
controller = self._controller
self._action_status = QAction("…", self._menu)
self._action_status.setEnabled(False)
self._menu.addAction(self._action_status)
self._menu.addSeparator()
self._action_start = QAction("Start focus", self._menu)
self._action_start.triggered.connect(lambda: controller.startWork())
self._menu.addAction(self._action_start)
self._action_break = QAction("Start break", self._menu)
self._action_break.triggered.connect(controller.startBreak)
self._menu.addAction(self._action_break)
self._action_pause = QAction("Pause", self._menu)
self._action_pause.triggered.connect(controller.pause)
self._menu.addAction(self._action_pause)
self._action_resume = QAction("Resume", self._menu)
self._action_resume.triggered.connect(controller.resume)
self._menu.addAction(self._action_resume)
self._action_done = QAction("Finish now", self._menu)
self._action_done.triggered.connect(controller.complete)
self._menu.addAction(self._action_done)
self._action_extend = QAction("+5 minutes", self._menu)
self._action_extend.triggered.connect(lambda: controller.extend(5))
self._menu.addAction(self._action_extend)
self._action_abandon = QAction("Abandon", self._menu)
self._action_abandon.triggered.connect(lambda: controller.abandon("abandoned from the tray"))
self._menu.addAction(self._action_abandon)
self._menu.addSeparator()
report = QAction("Send today's report", self._menu)
report.triggered.connect(controller.sendReport)
self._menu.addAction(report)
refresh = QAction("Refresh tasks", self._menu)
refresh.triggered.connect(controller.refreshSources)
self._menu.addAction(refresh)
self._menu.addSeparator()
self._action_mini = QAction("Mini timer", self._menu)
self._action_mini.setCheckable(True)
self._action_mini.triggered.connect(lambda: controller.toggleMini())
self._menu.addAction(self._action_mini)
self._action_view_mode = QAction("View-only mode (timer off)", self._menu)
self._action_view_mode.setCheckable(True)
self._action_view_mode.triggered.connect(
lambda: controller.setTimerEnabled(not controller.timerEnabled)
)
self._menu.addAction(self._action_view_mode)
# The menu is unchanged, and "Open Kadence" still means "show me the
# timer" — there is simply no five-page window behind it any more: it
# raises the mini timer, which is the whole of this app's own interface.
show = QAction("Open Kadence", self._menu)
show.triggered.connect(self.showRequested.emit)
self._menu.addAction(show)
settings = QAction("Settings…", self._menu)
settings.triggered.connect(self.settingsRequested.emit)
self._menu.addAction(settings)
web = QAction("Open the web app", self._menu)
web.triggered.connect(controller.openWebUi)
self._menu.addAction(web)
self._menu.addSeparator()
quit_action = QAction("Quit", self._menu)
quit_action.triggered.connect(self.quitRequested.emit)
self._menu.addAction(quit_action)
def refresh(self) -> None:
controller = self._controller
running = controller.running
paused = controller.status == "paused"
timer_enabled = controller.timerEnabled
self._icon.setIcon(
render_icon(
controller.progress,
controller.kind,
running if timer_enabled else False,
controller.connected,
paused if timer_enabled else False,
)
)
status = controller.statusLine()
blocker = " · 🛡 blocking" if controller.blockerActive else ""
self._icon.setToolTip(f"{status}{blocker}")
self._action_status.setText(status)
self._action_start.setVisible(timer_enabled and not running)
self._action_break.setVisible(timer_enabled and not running)
self._action_pause.setVisible(timer_enabled and running and not paused)
self._action_resume.setVisible(timer_enabled and running and paused)
self._action_done.setVisible(timer_enabled and running)
self._action_extend.setVisible(timer_enabled and running)
self._action_abandon.setVisible(timer_enabled and running)
self._action_mini.setVisible(timer_enabled)
self._action_mini.setChecked(controller.miniVisible)
self._action_view_mode.setChecked(not timer_enabled)
def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
# Left click shows or hides the countdown; middle click toggles the timer,
# which is the fastest "start/pause" on the whole desktop.
if reason == QSystemTrayIcon.ActivationReason.Trigger:
self.showRequested.emit()
elif reason == QSystemTrayIcon.ActivationReason.MiddleClick:
self._controller.toggle()
def message(self, title: str, body: str) -> None:
self._icon.showMessage(title, body, QSystemTrayIcon.MessageIcon.Information, 5000)