"""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() 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) 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() show = QAction("Open Kadence", self._menu) show.triggered.connect(self.showRequested.emit) self._menu.addAction(show) 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" self._icon.setIcon( render_icon(controller.progress, controller.kind, running, controller.connected, paused) ) status = controller.statusLine() blocker = " · 🛡 blocking" if controller.blockerActive else "" self._icon.setToolTip(f"{status}{blocker}") self._action_status.setText(status) self._action_start.setVisible(not running) self._action_break.setVisible(not running) self._action_pause.setVisible(running and not paused) self._action_resume.setVisible(running and paused) self._action_done.setVisible(running) self._action_extend.setVisible(running) self._action_abandon.setVisible(running) def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None: # Left click toggles the window; 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)