2026-03-21 21:06:03 -04:00
|
|
|
# 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
|
2026-03-24 01:18:54 -04:00
|
|
|
from PyQt6.QtCore import QTimer, Qt, pyqtSignal, QEventLoop
|
2026-03-21 21:06:03 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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",
|
2026-03-24 01:18:54 -04:00
|
|
|
0: "rgba(40, 20, 40, 200)",
|
|
|
|
|
1: "rgba(40, 20, 40, 255)",
|
2026-03-21 21:06:03 -04:00
|
|
|
}
|
|
|
|
|
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()
|
2026-03-24 01:18:54 -04:00
|
|
|
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)
|
2026-03-21 21:06:03 -04:00
|
|
|
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):
|
2026-03-24 01:18:54 -04:00
|
|
|
self.icon_timer.start()
|
|
|
|
|
self.p_act.setText("Pause Timer")
|
2026-03-21 21:06:03 -04:00
|
|
|
self.set_state(State.IDLE, DURATION[State.IDLE]//M)
|
|
|
|
|
|
|
|
|
|
def start_break(self):
|
|
|
|
|
self.set_state(State.BREAK, "...")
|
2026-03-24 01:18:54 -04:00
|
|
|
self.overlay.start()
|
2026-03-21 21:06:03 -04:00
|
|
|
|
|
|
|
|
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)
|
2026-03-24 01:18:54 -04:00
|
|
|
self.overlay.stop()
|
|
|
|
|
self.start_idle()
|
2026-03-21 21:06:03 -04:00
|
|
|
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()
|
2026-03-24 01:18:54 -04:00
|
|
|
self.p_act.setText("Resume Timer")
|
2026-03-21 21:06:03 -04:00
|
|
|
else:
|
|
|
|
|
self.color = COLOR[self.state]
|
|
|
|
|
self.update_icon()
|
|
|
|
|
self.timer.start(self.remaining_time)
|
|
|
|
|
self.icon_timer.start()
|
2026-03-24 01:18:54 -04:00
|
|
|
self.p_act.setText("Pause Timer")
|
2026-03-21 21:06:03 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TransparentOverlay(QWidget):
|
2026-03-24 01:18:54 -04:00
|
|
|
unlock_sig = pyqtSignal()
|
|
|
|
|
|
2026-03-21 21:06:03 -04:00
|
|
|
def __init__(self):
|
|
|
|
|
super().__init__()
|
2026-03-24 01:18:54 -04:00
|
|
|
self.keys = []
|
2026-03-21 21:06:03 -04:00
|
|
|
self.setWindowFlags(Qt.WindowType.BypassWindowManagerHint)
|
|
|
|
|
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
|
|
|
|
layout = QVBoxLayout()
|
|
|
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
|
layout.setSpacing(0)
|
2026-03-24 01:18:54 -04:00
|
|
|
self.bg = QWidget()
|
|
|
|
|
self.bg.setStyleSheet(f"background-color: {COLOR[0]};")
|
|
|
|
|
layout.addWidget(self.bg)
|
2026-03-21 21:06:03 -04:00
|
|
|
self.setLayout(layout)
|
|
|
|
|
|
2026-03-24 01:18:54 -04:00
|
|
|
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()
|
|
|
|
|
|
2026-03-21 21:06:03 -04:00
|
|
|
def keyPressEvent(self, event):
|
2026-03-24 01:18:54 -04:00
|
|
|
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:
|
2026-03-21 21:06:03 -04:00
|
|
|
self.hide()
|
2026-03-24 01:18:54 -04:00
|
|
|
elif self.keys[-4:] == list(b'LOCK'):
|
|
|
|
|
self.locked = True
|
|
|
|
|
self.bg.setStyleSheet(f"background-color: {COLOR[1]};")
|
2026-03-21 21:06:03 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
app = QApplication(sys.argv)
|
|
|
|
|
app.setQuitOnLastWindowClosed(False)
|
|
|
|
|
reminder = BreakTimer()
|
|
|
|
|
reminder.show()
|
|
|
|
|
sys.exit(app.exec())
|