This commit is contained in:
timka123 committed 2026-10-18 18:02:05 +03:00
commit 17a0a3c4c9
121 files changed
+17641

No files matched your search

+186
View File
@@ -0,0 +1,186 @@
"""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)