"""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)