187 lines
7.0 KiB
Python
187 lines
7.0 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()
|
|
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)
|