# vi: filetype=python import sys import subprocess from enum import Enum from threading import Thread from PyQt6.QtWidgets import ( QApplication, QSystemTrayIcon, QMenu, QWidget, QVBoxLayout, QMessageBox, ) from PyQt6.QtGui import QIcon, QPixmap, QColor, QAction, QPainter from PyQt6.QtCore import QTimer, Qt, pyqtSignal, QEventLoop class State(Enum): IDLE = 0 PENDING = 1 BREAK = 2 S = 1000 M = 60 * S DURATION = { State.IDLE: 15*M, State.PENDING: 1*M, State.BREAK: 15*S, } TOOLTIP = { State.IDLE: "Waiting for next break", State.PENDING: "Click to take a break", State.BREAK: "Taking a break", } COLOR = { State.IDLE: "#608c58", State.PENDING: "#d32a5d", State.BREAK: "#7e95d3", 0: "rgba(40, 20, 40, 200)", 1: "rgba(40, 20, 40, 255)", } DONE = '/run/current-system/sw/share/sounds/freedesktop/stereo/complete.oga' POP = '/run/current-system/sw/share/sounds/freedesktop/stereo/message.oga' audio = True def m_ceil(n): return (n + M - 1) // M def circle_icon(color, text=None): pixmap = QPixmap(64, 64) pixmap.fill(Qt.GlobalColor.transparent) painter = QPainter(pixmap) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setBrush(QColor(color)) painter.setPen(Qt.PenStyle.NoPen) painter.drawEllipse(2, 2, 60, 60) if text: painter.setPen(QColor("white" if color != "white" else "black")) font = painter.font() font.setPixelSize(34) font.setBold(True) painter.setFont(font) painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, text) painter.end() return QIcon(pixmap) def play_sound(sound=DONE, n=1): global audio audio = True def play(): for _ in range(n): if audio: subprocess.call(['paplay', sound]) Thread(target=play).start() class BreakTimer(QSystemTrayIcon): def __init__(self, parent=None): super().__init__(parent) self.state = State.IDLE self.timer = QTimer(self) self.timer.timeout.connect(self.on_timeout) self.activated.connect(self.on_tray_click) self.icon_color = "" self.icon_text = "" self.color = "" self.text = "" self.icon_timer = QTimer(self) self.icon_timer.timeout.connect(self.update_ui) self.icon_timer.start(S) self.overlay = TransparentOverlay() screen_geometry = app.primaryScreen().virtualGeometry() self.overlay.setGeometry(screen_geometry) menu = QMenu() self.p_act = QAction("Pause Timer", menu) self.p_act.triggered.connect(self.pause_timers) menu.addAction(self.p_act) act = QAction("Reset Timer", menu) act.triggered.connect(self.start_idle) menu.addAction(act) act = QAction("Break Now", menu) act.triggered.connect(self.start_break) menu.addAction(act) act = QAction("Go Inactive", menu) act.triggered.connect(self.inactive_popup) menu.addAction(act) act = QAction("Quit", menu) act.triggered.connect(sys.exit) menu.addAction(act) self.setContextMenu(menu) self.start_idle() def update_icon(self): if self.color != self.icon_color or self.text != self.icon_text: self.icon_color = self.color self.icon_text = self.text self.setIcon(circle_icon(self.color, self.text)) def update_ui(self): if self.state is State.IDLE: self.text = str(m_ceil(self.timer.remainingTime()) or self.text) self.update_icon() elif self.state is State.PENDING: if self.color == "white": self.color = COLOR[State.PENDING] else: self.color = "white" self.update_icon() def set_state(self, state, text): global audio audio = False self.state = state self.color = COLOR[self.state] self.text = str(text) self.update_icon() self.setToolTip(TOOLTIP[self.state]) self.timer.start(DURATION[self.state]) def start_idle(self): self.icon_timer.start() self.p_act.setText("Pause Timer") self.set_state(State.IDLE, DURATION[State.IDLE]//M) def start_break(self): self.set_state(State.BREAK, "...") self.overlay.start() def on_timeout(self): if self.state is State.IDLE: self.set_state(State.PENDING, 0) play_sound() elif self.state is State.PENDING: n = int(self.text or 0) + 1 self.text = str(n) self.update_icon() if n < 5: play_sound(n=n) else: self.inactive_popup() elif self.state is State.BREAK: play_sound(POP) self.overlay.stop() self.start_idle() self.showMessage('Timer', 'Timer is complete') def inactive_popup(self): self.pause_timers() popup = QMessageBox() popup.setWindowTitle("Break Timer") popup.setText("Timer paused due to inactivity") resume = popup.addButton("Resume", QMessageBox.ButtonRole.AcceptRole) resume.clicked.connect(self.start_idle) quit = popup.addButton("Quit", QMessageBox.ButtonRole.RejectRole) quit.clicked.connect(sys.exit) popup.exec() def on_tray_click(self, reason): if reason == QSystemTrayIcon.ActivationReason.Trigger: if self.state is State.PENDING: self.start_break() def pause_timers(self): if self.timer.isActive(): self.remaining_time = self.timer.remainingTime() self.color = "gray" self.update_icon() self.timer.stop() self.icon_timer.stop() self.p_act.setText("Resume Timer") else: self.color = COLOR[self.state] self.update_icon() self.timer.start(self.remaining_time) self.icon_timer.start() self.p_act.setText("Pause Timer") class TransparentOverlay(QWidget): unlock_sig = pyqtSignal() def __init__(self): super().__init__() self.keys = [] self.setWindowFlags(Qt.WindowType.BypassWindowManagerHint) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) self.bg = QWidget() self.bg.setStyleSheet(f"background-color: {COLOR[0]};") layout.addWidget(self.bg) self.setLayout(layout) def start(self): self.keys = [] self.locked = False self.show() self.grabKeyboard() def stop(self): if self.locked: loop = QEventLoop() self.unlock_sig.connect(loop.quit) loop.exec() self.hide() def keyPressEvent(self, event): k = event.key() self.keys.append(k) self.keys = self.keys[-10:] if self.locked: play_sound(POP) if self.keys[-6:] == list(b'UNLOCK'): self.locked = False self.bg.setStyleSheet(f"background-color: {COLOR[0]};") self.unlock_sig.emit() elif k == Qt.Key.Key_Escape: self.hide() elif self.keys[-4:] == list(b'LOCK'): self.locked = True self.bg.setStyleSheet(f"background-color: {COLOR[1]};") if __name__ == "__main__": app = QApplication(sys.argv) app.setQuitOnLastWindowClosed(False) reminder = BreakTimer() reminder.show() sys.exit(app.exec())