init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user