swissclock 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- swissclock/__init__.py +2 -0
- swissclock/base_dial.py +94 -0
- swissclock/clock.py +108 -0
- swissclock/main.py +138 -0
- swissclock/morph_digit.py +87 -0
- swissclock/stop.py +273 -0
- swissclock-0.1.0.dist-info/METADATA +719 -0
- swissclock-0.1.0.dist-info/RECORD +10 -0
- swissclock-0.1.0.dist-info/WHEEL +4 -0
- swissclock-0.1.0.dist-info/entry_points.txt +3 -0
swissclock/__init__.py
ADDED
swissclock/base_dial.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
from PySide6.QtCore import QRectF, Qt, QTimer
|
|
3
|
+
from PySide6.QtGui import QColor, QPainter
|
|
4
|
+
from PySide6.QtWidgets import QMenu, QMessageBox, QWidget
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BaseDial(QWidget):
|
|
8
|
+
def __init__(self, parent=None):
|
|
9
|
+
super().__init__(parent)
|
|
10
|
+
|
|
11
|
+
self.timer = QTimer(self)
|
|
12
|
+
self.timer.timeout.connect(self.update_dial)
|
|
13
|
+
self.timer.start(30) # Smooth animation
|
|
14
|
+
|
|
15
|
+
self.black_color = QColor(0, 0, 0)
|
|
16
|
+
self.red_color = QColor(220, 0, 0) # Swiss red
|
|
17
|
+
self.green_color = QColor(0, 180, 0) # Normal tracking green
|
|
18
|
+
self.orange_color = QColor(255, 165, 0) # Warning orange
|
|
19
|
+
|
|
20
|
+
def update_dial(self):
|
|
21
|
+
self.update()
|
|
22
|
+
|
|
23
|
+
def paintEvent(self, event):
|
|
24
|
+
side = min(self.width(), self.height())
|
|
25
|
+
painter = QPainter(self)
|
|
26
|
+
painter.setRenderHint(QPainter.Antialiasing)
|
|
27
|
+
painter.translate(self.width() / 2, self.height() / 2)
|
|
28
|
+
painter.scale(side / 240.0, side / 240.0)
|
|
29
|
+
|
|
30
|
+
# White background face
|
|
31
|
+
painter.setPen(Qt.NoPen)
|
|
32
|
+
painter.setBrush(Qt.white)
|
|
33
|
+
painter.drawEllipse(-98, -98, 196, 196)
|
|
34
|
+
|
|
35
|
+
self.draw_face(painter)
|
|
36
|
+
self.draw_hands(painter)
|
|
37
|
+
|
|
38
|
+
def draw_face(self, painter):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
def draw_hands(self, painter):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
def draw_dial(self, painter):
|
|
45
|
+
# To be implemented by subclasses
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
def contextMenuEvent(self, event):
|
|
49
|
+
menu = QMenu(self)
|
|
50
|
+
about_action = menu.addAction("About")
|
|
51
|
+
help_action = menu.addAction("Help (Shortcuts)")
|
|
52
|
+
action = menu.exec(event.globalPos())
|
|
53
|
+
if action == about_action:
|
|
54
|
+
self.show_about()
|
|
55
|
+
elif action == help_action:
|
|
56
|
+
self.show_help()
|
|
57
|
+
|
|
58
|
+
def show_help(self):
|
|
59
|
+
QMessageBox.information(self, "SwissClock Help",
|
|
60
|
+
"<h2>Keyboard Shortcuts</h2>"
|
|
61
|
+
"<ul>"
|
|
62
|
+
"<li><b>T</b>: Switch between Clock and Stopwatch</li>"
|
|
63
|
+
"<li><b>Space</b>: Start / Stop / Resume Stopwatch</li>"
|
|
64
|
+
"<li><b>R</b>: Reset Stopwatch</li>"
|
|
65
|
+
"<li><b>D</b>: Toggle Digital / Analog mode (in Stopwatch)</li>"
|
|
66
|
+
"<li><b>F</b>: Toggle Fullscreen mode</li>"
|
|
67
|
+
"</ul>")
|
|
68
|
+
|
|
69
|
+
def show_about(self):
|
|
70
|
+
QMessageBox.about(self, "About SwissClock",
|
|
71
|
+
"<h2>Swiss Railway Time</h2>"
|
|
72
|
+
"<p>A Swiss Railways inspired unified analog clock and morphing digital stopwatch.</p>"
|
|
73
|
+
"<p><b>Author:</b> Alin Elena <alin@elena.re></p>"
|
|
74
|
+
"<p><b>License:</b> GPL-3.0-or-later</p>"
|
|
75
|
+
"<p><b>Dependencies:</b> PySide6</p>")
|
|
76
|
+
|
|
77
|
+
def draw_capsule_hand(self, painter, angle, color, rect_args):
|
|
78
|
+
painter.save()
|
|
79
|
+
painter.rotate(angle)
|
|
80
|
+
painter.setBrush(color)
|
|
81
|
+
painter.setPen(Qt.NoPen)
|
|
82
|
+
# rect_args: (x, y, w, h, xRadius, yRadius)
|
|
83
|
+
painter.drawRoundedRect(*rect_args)
|
|
84
|
+
painter.restore()
|
|
85
|
+
|
|
86
|
+
def draw_swiss_second_hand(self, painter, angle):
|
|
87
|
+
painter.save()
|
|
88
|
+
painter.rotate(angle)
|
|
89
|
+
painter.setBrush(self.red_color)
|
|
90
|
+
painter.setPen(Qt.NoPen)
|
|
91
|
+
painter.drawRect(-1, -76, 2, 101)
|
|
92
|
+
painter.drawEllipse(QRectF(-6, -82, 12, 12))
|
|
93
|
+
painter.drawEllipse(QRectF(-3, -3, 6, 6))
|
|
94
|
+
painter.restore()
|
swissclock/clock.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
import math
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from PySide6.QtCore import QRectF, Qt, QTime
|
|
6
|
+
from PySide6.QtWidgets import QApplication
|
|
7
|
+
|
|
8
|
+
from .base_dial import BaseDial
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AnalogClock(BaseDial):
|
|
12
|
+
def __init__(self, parent=None):
|
|
13
|
+
super().__init__(parent)
|
|
14
|
+
self.setWindowTitle("Analog Clock")
|
|
15
|
+
self.resize(300, 300)
|
|
16
|
+
|
|
17
|
+
def draw_face(self, painter):
|
|
18
|
+
# Draw 24 hour markers (thick lines for even, red dots for odd)
|
|
19
|
+
painter.save()
|
|
20
|
+
for i in range(24):
|
|
21
|
+
if i % 2 == 0:
|
|
22
|
+
painter.setBrush(self.black_color)
|
|
23
|
+
painter.setPen(Qt.NoPen)
|
|
24
|
+
painter.drawEllipse(QRectF(83, -4, 8, 8))
|
|
25
|
+
else:
|
|
26
|
+
painter.setBrush(self.red_color)
|
|
27
|
+
painter.setPen(Qt.NoPen)
|
|
28
|
+
painter.drawEllipse(QRectF(85, -2, 4, 4))
|
|
29
|
+
painter.rotate(15.0)
|
|
30
|
+
painter.restore()
|
|
31
|
+
|
|
32
|
+
# Draw 60 minute markers (small black circles)
|
|
33
|
+
painter.save()
|
|
34
|
+
painter.setBrush(self.black_color)
|
|
35
|
+
painter.setPen(Qt.NoPen)
|
|
36
|
+
for i in range(60):
|
|
37
|
+
painter.drawEllipse(QRectF(85, -2, 4, 4))
|
|
38
|
+
painter.rotate(6.0)
|
|
39
|
+
painter.restore()
|
|
40
|
+
|
|
41
|
+
# Draw numbers (even inside, odd outside)
|
|
42
|
+
painter.save()
|
|
43
|
+
font = painter.font()
|
|
44
|
+
font.setFamily("Verdana")
|
|
45
|
+
font.setPointSize(6)
|
|
46
|
+
font.setBold(True)
|
|
47
|
+
painter.setFont(font)
|
|
48
|
+
|
|
49
|
+
window_text_color = self.palette().windowText().color()
|
|
50
|
+
|
|
51
|
+
for h in range(1, 25):
|
|
52
|
+
angle_deg = -90 + h * 15
|
|
53
|
+
angle_rad = math.radians(angle_deg)
|
|
54
|
+
if h % 2 == 0:
|
|
55
|
+
x = 65 * math.cos(angle_rad)
|
|
56
|
+
y = 65 * math.sin(angle_rad)
|
|
57
|
+
painter.setPen(self.black_color)
|
|
58
|
+
else:
|
|
59
|
+
x = 108 * math.cos(angle_rad)
|
|
60
|
+
y = 108 * math.sin(angle_rad)
|
|
61
|
+
painter.setPen(window_text_color)
|
|
62
|
+
|
|
63
|
+
rect = QRectF(x - 10, y - 10, 20, 20)
|
|
64
|
+
painter.drawText(rect, Qt.AlignCenter, str(h))
|
|
65
|
+
painter.restore()
|
|
66
|
+
|
|
67
|
+
# Draw minute labels outside the clock
|
|
68
|
+
painter.save()
|
|
69
|
+
font = painter.font()
|
|
70
|
+
font.setFamily("Courier New")
|
|
71
|
+
font.setPointSize(4)
|
|
72
|
+
font.setBold(True)
|
|
73
|
+
painter.setFont(font)
|
|
74
|
+
painter.setPen(self.palette().windowText().color())
|
|
75
|
+
for m in range(0, 60, 5):
|
|
76
|
+
angle_deg = -90 + m * 6
|
|
77
|
+
angle_rad = math.radians(angle_deg)
|
|
78
|
+
x = 108 * math.cos(angle_rad)
|
|
79
|
+
y = 108 * math.sin(angle_rad)
|
|
80
|
+
rect = QRectF(x - 10, y - 10, 20, 20)
|
|
81
|
+
painter.drawText(rect, Qt.AlignCenter, f"{m:02d}")
|
|
82
|
+
painter.restore()
|
|
83
|
+
|
|
84
|
+
def draw_hands(self, painter):
|
|
85
|
+
time = QTime.currentTime()
|
|
86
|
+
|
|
87
|
+
# Hour hand
|
|
88
|
+
hour_angle = 15.0 * (time.hour() + time.minute() / 60.0 + time.second() / 3600.0)
|
|
89
|
+
self.draw_capsule_hand(painter, hour_angle, self.black_color, (-4, -55, 8, 70, 4.0, 4.0))
|
|
90
|
+
|
|
91
|
+
# Minute hand (Swiss clocks jump the minute hand at the exact minute)
|
|
92
|
+
minute_angle = 6.0 * time.minute()
|
|
93
|
+
self.draw_capsule_hand(painter, minute_angle, self.black_color, (-3, -75, 6, 95, 3.0, 3.0))
|
|
94
|
+
|
|
95
|
+
# Second hand (waits for 1.5s at 60, then jumps directly to 1.5 seconds)
|
|
96
|
+
ms = time.second() * 1000 + time.msec()
|
|
97
|
+
if ms < 1500:
|
|
98
|
+
second_angle = 0.0
|
|
99
|
+
else:
|
|
100
|
+
second_angle = ms * (360.0 / 60000.0)
|
|
101
|
+
|
|
102
|
+
self.draw_swiss_second_hand(painter, second_angle)
|
|
103
|
+
|
|
104
|
+
if __name__ == '__main__':
|
|
105
|
+
app = QApplication(sys.argv)
|
|
106
|
+
clock = AnalogClock()
|
|
107
|
+
clock.show()
|
|
108
|
+
sys.exit(app.exec())
|
swissclock/main.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from PySide6.QtGui import QKeySequence, QShortcut
|
|
5
|
+
from PySide6.QtWidgets import (
|
|
6
|
+
QApplication,
|
|
7
|
+
QHBoxLayout,
|
|
8
|
+
QPushButton,
|
|
9
|
+
QStackedWidget,
|
|
10
|
+
QVBoxLayout,
|
|
11
|
+
QWidget,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from .clock import AnalogClock
|
|
15
|
+
from .stop import StopWatch
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UnifiedApp(QWidget):
|
|
19
|
+
def __init__(self):
|
|
20
|
+
super().__init__()
|
|
21
|
+
self.setWindowTitle("Swiss Railway Time")
|
|
22
|
+
self.resize(320, 420)
|
|
23
|
+
|
|
24
|
+
layout = QVBoxLayout(self)
|
|
25
|
+
|
|
26
|
+
self.stacked = QStackedWidget()
|
|
27
|
+
|
|
28
|
+
self.clock_widget = AnalogClock()
|
|
29
|
+
self.stopwatch_widget = StopWatch(self)
|
|
30
|
+
|
|
31
|
+
self.stacked.addWidget(self.clock_widget)
|
|
32
|
+
self.stacked.addWidget(self.stopwatch_widget)
|
|
33
|
+
|
|
34
|
+
layout.addWidget(self.stacked, 1) # Expanding
|
|
35
|
+
|
|
36
|
+
# Action Buttons Layout (Start / Reset)
|
|
37
|
+
self.action_layout = QHBoxLayout()
|
|
38
|
+
self.btn_start = QPushButton("Start (Space)")
|
|
39
|
+
self.btn_start.clicked.connect(self.toggle_stopwatch)
|
|
40
|
+
|
|
41
|
+
self.btn_reset = QPushButton("Reset (R)")
|
|
42
|
+
self.btn_reset.clicked.connect(self.reset_stopwatch)
|
|
43
|
+
|
|
44
|
+
self.action_layout.addWidget(self.btn_start)
|
|
45
|
+
self.action_layout.addWidget(self.btn_reset)
|
|
46
|
+
|
|
47
|
+
# Only show action buttons when in stopwatch mode
|
|
48
|
+
self.action_widget = QWidget()
|
|
49
|
+
self.action_widget.setLayout(self.action_layout)
|
|
50
|
+
sp = self.action_widget.sizePolicy()
|
|
51
|
+
sp.setRetainSizeWhenHidden(True)
|
|
52
|
+
self.action_widget.setSizePolicy(sp)
|
|
53
|
+
self.action_widget.setVisible(False)
|
|
54
|
+
layout.addWidget(self.action_widget)
|
|
55
|
+
|
|
56
|
+
# Main Toggle Button
|
|
57
|
+
self.btn_toggle = QPushButton("Switch to Stopwatch (T)")
|
|
58
|
+
self.btn_toggle.clicked.connect(self.toggle_view)
|
|
59
|
+
layout.addWidget(self.btn_toggle)
|
|
60
|
+
|
|
61
|
+
# Bulletproof window-level shortcuts
|
|
62
|
+
self.shortcut_toggle = QShortcut(QKeySequence("T"), self)
|
|
63
|
+
self.shortcut_toggle.activated.connect(self.toggle_view)
|
|
64
|
+
|
|
65
|
+
self.shortcut_space = QShortcut(QKeySequence("Space"), self)
|
|
66
|
+
self.shortcut_space.activated.connect(self.handle_space)
|
|
67
|
+
|
|
68
|
+
self.shortcut_reset = QShortcut(QKeySequence("R"), self)
|
|
69
|
+
self.shortcut_reset.activated.connect(self.handle_reset)
|
|
70
|
+
|
|
71
|
+
self.shortcut_digital = QShortcut(QKeySequence("D"), self)
|
|
72
|
+
self.shortcut_digital.activated.connect(self.handle_digital)
|
|
73
|
+
|
|
74
|
+
self.shortcut_fullscreen = QShortcut(QKeySequence("F"), self)
|
|
75
|
+
self.shortcut_fullscreen.activated.connect(self.toggle_fullscreen)
|
|
76
|
+
|
|
77
|
+
def toggle_fullscreen(self):
|
|
78
|
+
if self.isFullScreen():
|
|
79
|
+
self.showNormal()
|
|
80
|
+
else:
|
|
81
|
+
self.showFullScreen()
|
|
82
|
+
|
|
83
|
+
def handle_space(self):
|
|
84
|
+
if self.stacked.currentIndex() == 1:
|
|
85
|
+
self.toggle_stopwatch()
|
|
86
|
+
|
|
87
|
+
def handle_reset(self):
|
|
88
|
+
if self.stacked.currentIndex() == 1:
|
|
89
|
+
self.reset_stopwatch()
|
|
90
|
+
|
|
91
|
+
def handle_digital(self):
|
|
92
|
+
if self.stacked.currentIndex() == 1:
|
|
93
|
+
self.stopwatch_widget.toggle_digital_mode()
|
|
94
|
+
|
|
95
|
+
def toggle_stopwatch(self):
|
|
96
|
+
self.stopwatch_widget.toggle_timer()
|
|
97
|
+
self.update_buttons()
|
|
98
|
+
|
|
99
|
+
def reset_stopwatch(self):
|
|
100
|
+
self.stopwatch_widget.reset_timer()
|
|
101
|
+
self.update_buttons()
|
|
102
|
+
|
|
103
|
+
def update_buttons(self):
|
|
104
|
+
if self.stopwatch_widget.running:
|
|
105
|
+
self.btn_start.setText("Stop (Space)")
|
|
106
|
+
else:
|
|
107
|
+
if self.stopwatch_widget.total_elapsed > 0:
|
|
108
|
+
self.btn_start.setText("Resume (Space)")
|
|
109
|
+
else:
|
|
110
|
+
self.btn_start.setText("Start (Space)")
|
|
111
|
+
|
|
112
|
+
def toggle_view(self):
|
|
113
|
+
if self.stacked.currentIndex() == 0:
|
|
114
|
+
self.stacked.setCurrentIndex(1)
|
|
115
|
+
self.btn_toggle.setText("Switch to Clock (T)")
|
|
116
|
+
self.action_widget.setVisible(True)
|
|
117
|
+
self.update_buttons()
|
|
118
|
+
else:
|
|
119
|
+
self.stacked.setCurrentIndex(0)
|
|
120
|
+
self.btn_toggle.setText("Switch to Stopwatch (T)")
|
|
121
|
+
self.action_widget.setVisible(False)
|
|
122
|
+
|
|
123
|
+
def main():
|
|
124
|
+
app = QApplication.instance() or QApplication(sys.argv)
|
|
125
|
+
|
|
126
|
+
# Optional: Set an application-wide stylesheet for a modern dark look if desired
|
|
127
|
+
app.setStyleSheet("""
|
|
128
|
+
QWidget {
|
|
129
|
+
background-color: #121212;
|
|
130
|
+
}
|
|
131
|
+
""")
|
|
132
|
+
|
|
133
|
+
clock = UnifiedApp()
|
|
134
|
+
clock.show()
|
|
135
|
+
sys.exit(app.exec())
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
main()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
from PySide6.QtCore import QPointF, Qt
|
|
3
|
+
from PySide6.QtGui import QColor, QPen
|
|
4
|
+
|
|
5
|
+
# Define the 7 standard segments on a 1x2 grid
|
|
6
|
+
SEGS = [
|
|
7
|
+
(0,0, 1,0), # 0: T
|
|
8
|
+
(1,0, 1,1), # 1: TR
|
|
9
|
+
(1,1, 1,2), # 2: BR
|
|
10
|
+
(1,2, 0,2), # 3: B
|
|
11
|
+
(0,2, 0,1), # 4: BL
|
|
12
|
+
(0,1, 0,0), # 5: TL
|
|
13
|
+
(0,1, 1,1) # 6: M
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
# Standard 7-segment bitmasks for digits 0-9
|
|
17
|
+
# Order: T, TR, BR, B, BL, TL, M
|
|
18
|
+
STATES = [
|
|
19
|
+
[1, 1, 1, 1, 1, 1, 0], # 0
|
|
20
|
+
[0, 1, 1, 0, 0, 0, 0], # 1
|
|
21
|
+
[1, 1, 0, 1, 1, 0, 1], # 2
|
|
22
|
+
[1, 1, 1, 1, 0, 0, 1], # 3
|
|
23
|
+
[0, 1, 1, 0, 0, 1, 1], # 4
|
|
24
|
+
[1, 0, 1, 1, 0, 1, 1], # 5
|
|
25
|
+
[1, 0, 1, 1, 1, 1, 1], # 6
|
|
26
|
+
[1, 1, 1, 0, 0, 0, 0], # 7
|
|
27
|
+
[1, 1, 1, 1, 1, 1, 1], # 8
|
|
28
|
+
[1, 1, 1, 1, 0, 1, 1], # 9
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
class MorphDigit:
|
|
32
|
+
def __init__(self):
|
|
33
|
+
self.target = 0
|
|
34
|
+
self.opacities = [float(x) for x in STATES[0]]
|
|
35
|
+
|
|
36
|
+
def set_target(self, digit):
|
|
37
|
+
if self.target != digit:
|
|
38
|
+
self.target = digit
|
|
39
|
+
|
|
40
|
+
def update(self):
|
|
41
|
+
# Lerp each segment's opacity towards its target state (smooth fade)
|
|
42
|
+
target_state = STATES[self.target]
|
|
43
|
+
for i in range(7):
|
|
44
|
+
current = self.opacities[i]
|
|
45
|
+
target = float(target_state[i])
|
|
46
|
+
|
|
47
|
+
# Smooth 30fps fade factor (lower is slower)
|
|
48
|
+
factor = 0.1
|
|
49
|
+
self.opacities[i] = current + (target - current) * factor
|
|
50
|
+
|
|
51
|
+
def draw(self, painter, x_offset, y_offset, scale_x, scale_y, color):
|
|
52
|
+
pen_width = max(2.0, scale_x * 0.15)
|
|
53
|
+
|
|
54
|
+
# 10% gap to ensure the segments are completely distinct from each other
|
|
55
|
+
gap = 0.1
|
|
56
|
+
|
|
57
|
+
for i in range(7):
|
|
58
|
+
opacity = self.opacities[i]
|
|
59
|
+
if opacity < 0.01:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
x1, y1, x2, y2 = SEGS[i]
|
|
63
|
+
|
|
64
|
+
dx = x2 - x1
|
|
65
|
+
dy = y2 - y1
|
|
66
|
+
length = (dx**2 + dy**2)**0.5
|
|
67
|
+
|
|
68
|
+
# Apply gap to shrink the line slightly and make it distinct
|
|
69
|
+
nx = dx / length
|
|
70
|
+
ny = dy / length
|
|
71
|
+
|
|
72
|
+
start_x = x1 + nx * gap
|
|
73
|
+
start_y = y1 + ny * gap
|
|
74
|
+
end_x = x2 - nx * gap
|
|
75
|
+
end_y = y2 - ny * gap
|
|
76
|
+
|
|
77
|
+
# Create a color with the current opacity for fading
|
|
78
|
+
c = QColor(color)
|
|
79
|
+
c.setAlphaF(opacity)
|
|
80
|
+
|
|
81
|
+
pen = QPen(c)
|
|
82
|
+
pen.setWidthF(pen_width)
|
|
83
|
+
pen.setCapStyle(Qt.FlatCap) # Flat caps for that distinct LCD look
|
|
84
|
+
painter.setPen(pen)
|
|
85
|
+
|
|
86
|
+
painter.drawLine(QPointF(x_offset + start_x * scale_x, y_offset + start_y * scale_y),
|
|
87
|
+
QPointF(x_offset + end_x * scale_x, y_offset + end_y * scale_y))
|
swissclock/stop.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
import math
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from PySide6.QtCore import QElapsedTimer, QRectF, Qt
|
|
6
|
+
from PySide6.QtGui import QPainter, QPen
|
|
7
|
+
from PySide6.QtWidgets import QApplication, QInputDialog, QMenu
|
|
8
|
+
|
|
9
|
+
from .base_dial import BaseDial
|
|
10
|
+
from .morph_digit import MorphDigit
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StopWatch(BaseDial):
|
|
14
|
+
def __init__(self, parent=None):
|
|
15
|
+
super().__init__(parent)
|
|
16
|
+
|
|
17
|
+
self.running = False
|
|
18
|
+
self.elapsed_timer = QElapsedTimer()
|
|
19
|
+
self.total_elapsed = 0
|
|
20
|
+
self.countdown_target_ms = 0
|
|
21
|
+
self.is_digital = False
|
|
22
|
+
|
|
23
|
+
# 5 digits for H:MM:SS
|
|
24
|
+
self.digits = [MorphDigit() for _ in range(5)]
|
|
25
|
+
|
|
26
|
+
self.setWindowTitle("Stopwatch")
|
|
27
|
+
|
|
28
|
+
self.setContextMenuPolicy(Qt.CustomContextMenu)
|
|
29
|
+
self.customContextMenuRequested.connect(self.show_context_menu)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def show_context_menu(self, pos):
|
|
33
|
+
menu = QMenu(self)
|
|
34
|
+
|
|
35
|
+
action_stopwatch = menu.addAction("Stopwatch Mode")
|
|
36
|
+
action_1min = menu.addAction("1 Minute")
|
|
37
|
+
action_5min = menu.addAction("5 Minutes")
|
|
38
|
+
action_10min = menu.addAction("10 Minutes")
|
|
39
|
+
action_custom = menu.addAction("Custom...")
|
|
40
|
+
menu.addSeparator()
|
|
41
|
+
action_help = menu.addAction("Help (Shortcuts)")
|
|
42
|
+
action_about = menu.addAction("About")
|
|
43
|
+
|
|
44
|
+
action = menu.exec(self.mapToGlobal(pos))
|
|
45
|
+
|
|
46
|
+
if action == action_stopwatch:
|
|
47
|
+
self.set_countdown(0)
|
|
48
|
+
elif action == action_1min:
|
|
49
|
+
self.set_countdown(60 * 1000)
|
|
50
|
+
elif action == action_5min:
|
|
51
|
+
self.set_countdown(5 * 60 * 1000)
|
|
52
|
+
elif action == action_10min:
|
|
53
|
+
self.set_countdown(10 * 60 * 1000)
|
|
54
|
+
elif action == action_custom:
|
|
55
|
+
mins, ok = QInputDialog.getInt(self, "Custom Countdown", "Enter minutes:", 1, 1, 999)
|
|
56
|
+
if ok:
|
|
57
|
+
self.set_countdown(mins * 60 * 1000)
|
|
58
|
+
elif action == action_help:
|
|
59
|
+
self.show_help()
|
|
60
|
+
elif action == action_about:
|
|
61
|
+
self.show_about()
|
|
62
|
+
|
|
63
|
+
def toggle_digital_mode(self):
|
|
64
|
+
self.is_digital = not self.is_digital
|
|
65
|
+
self.update()
|
|
66
|
+
|
|
67
|
+
def update_dial(self):
|
|
68
|
+
if self.is_digital:
|
|
69
|
+
ms = self.get_elapsed_ms()
|
|
70
|
+
abs_ms = abs(ms)
|
|
71
|
+
s = (abs_ms % 60000) // 1000
|
|
72
|
+
m = (abs_ms % 3600000) // 60000
|
|
73
|
+
h = abs_ms // 3600000
|
|
74
|
+
|
|
75
|
+
self.digits[0].set_target(h % 10)
|
|
76
|
+
self.digits[1].set_target(m // 10)
|
|
77
|
+
self.digits[2].set_target(m % 10)
|
|
78
|
+
self.digits[3].set_target(s // 10)
|
|
79
|
+
self.digits[4].set_target(s % 10)
|
|
80
|
+
|
|
81
|
+
for digit in self.digits:
|
|
82
|
+
digit.update()
|
|
83
|
+
|
|
84
|
+
super().update_dial()
|
|
85
|
+
|
|
86
|
+
def paintEvent(self, event):
|
|
87
|
+
if not self.is_digital:
|
|
88
|
+
super().paintEvent(event)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
ms = self.get_elapsed_ms()
|
|
92
|
+
|
|
93
|
+
if self.total_elapsed == 0 and not self.running:
|
|
94
|
+
color = Qt.white
|
|
95
|
+
elif self.countdown_target_ms > 0:
|
|
96
|
+
if ms < 0:
|
|
97
|
+
color = self.red_color
|
|
98
|
+
elif ms <= 0.10 * self.countdown_target_ms:
|
|
99
|
+
color = self.orange_color
|
|
100
|
+
else:
|
|
101
|
+
color = self.green_color
|
|
102
|
+
else:
|
|
103
|
+
color = self.green_color
|
|
104
|
+
|
|
105
|
+
painter = QPainter(self)
|
|
106
|
+
painter.setRenderHint(QPainter.Antialiasing)
|
|
107
|
+
painter.fillRect(self.rect(), Qt.black)
|
|
108
|
+
|
|
109
|
+
# H:MM:SS layout scaling
|
|
110
|
+
digit_w = self.width() * 0.1
|
|
111
|
+
digit_h = digit_w * 2.0
|
|
112
|
+
gap = digit_w * 0.4
|
|
113
|
+
colon_gap = digit_w * 0.8
|
|
114
|
+
|
|
115
|
+
total_w = digit_w * 5 + gap * 2 + colon_gap * 2
|
|
116
|
+
|
|
117
|
+
start_x = (self.width() - total_w) / 2
|
|
118
|
+
start_y = (self.height() - digit_h) / 2
|
|
119
|
+
|
|
120
|
+
curr_x = start_x
|
|
121
|
+
|
|
122
|
+
# Draw Negative sign if in overtime
|
|
123
|
+
if ms < 0:
|
|
124
|
+
pen = QPen(color)
|
|
125
|
+
pen.setWidthF(max(2.0, digit_w * 0.15))
|
|
126
|
+
pen.setCapStyle(Qt.RoundCap)
|
|
127
|
+
painter.setPen(pen)
|
|
128
|
+
painter.drawLine(start_x - digit_w * 0.8, start_y + digit_h / 2, start_x - digit_w * 0.2, start_y + digit_h / 2)
|
|
129
|
+
|
|
130
|
+
# Draw H
|
|
131
|
+
self.digits[0].draw(painter, curr_x, start_y, digit_w, digit_h / 2.0, color)
|
|
132
|
+
curr_x += digit_w + colon_gap / 2
|
|
133
|
+
|
|
134
|
+
# Draw Colon 1
|
|
135
|
+
painter.setPen(Qt.NoPen)
|
|
136
|
+
painter.setBrush(color)
|
|
137
|
+
radius = digit_w * 0.15
|
|
138
|
+
painter.drawEllipse(curr_x - radius, start_y + digit_h * 0.35 - radius, radius*2, radius*2)
|
|
139
|
+
painter.drawEllipse(curr_x - radius, start_y + digit_h * 0.65 - radius, radius*2, radius*2)
|
|
140
|
+
curr_x += colon_gap / 2
|
|
141
|
+
|
|
142
|
+
# Draw M1, M2
|
|
143
|
+
self.digits[1].draw(painter, curr_x, start_y, digit_w, digit_h / 2.0, color)
|
|
144
|
+
curr_x += digit_w + gap
|
|
145
|
+
self.digits[2].draw(painter, curr_x, start_y, digit_w, digit_h / 2.0, color)
|
|
146
|
+
curr_x += digit_w + colon_gap / 2
|
|
147
|
+
|
|
148
|
+
# Draw Colon 2
|
|
149
|
+
painter.drawEllipse(curr_x - radius, start_y + digit_h * 0.35 - radius, radius*2, radius*2)
|
|
150
|
+
painter.drawEllipse(curr_x - radius, start_y + digit_h * 0.65 - radius, radius*2, radius*2)
|
|
151
|
+
curr_x += colon_gap / 2
|
|
152
|
+
|
|
153
|
+
# Draw S1, S2
|
|
154
|
+
self.digits[3].draw(painter, curr_x, start_y, digit_w, digit_h / 2.0, color)
|
|
155
|
+
curr_x += digit_w + gap
|
|
156
|
+
self.digits[4].draw(painter, curr_x, start_y, digit_w, digit_h / 2.0, color)
|
|
157
|
+
|
|
158
|
+
def draw_face(self, painter):
|
|
159
|
+
# Draw 60 second markers (big for multiples of 5, small for others)
|
|
160
|
+
painter.save()
|
|
161
|
+
for i in range(60):
|
|
162
|
+
if i % 5 == 0:
|
|
163
|
+
painter.setBrush(self.black_color)
|
|
164
|
+
painter.setPen(Qt.NoPen)
|
|
165
|
+
painter.drawEllipse(QRectF(83, -4, 8, 8))
|
|
166
|
+
else:
|
|
167
|
+
painter.setBrush(self.black_color)
|
|
168
|
+
painter.setPen(Qt.NoPen)
|
|
169
|
+
painter.drawEllipse(QRectF(85, -2, 4, 4))
|
|
170
|
+
painter.rotate(6.0)
|
|
171
|
+
painter.restore()
|
|
172
|
+
|
|
173
|
+
# Draw numbers every 1 second outside
|
|
174
|
+
painter.save()
|
|
175
|
+
font = painter.font()
|
|
176
|
+
font.setFamily("Courier New")
|
|
177
|
+
font.setPointSize(4)
|
|
178
|
+
font.setBold(True)
|
|
179
|
+
painter.setFont(font)
|
|
180
|
+
|
|
181
|
+
window_text_color = self.palette().windowText().color()
|
|
182
|
+
painter.setPen(window_text_color)
|
|
183
|
+
|
|
184
|
+
for m in range(60):
|
|
185
|
+
angle_deg = -90 + m * 6
|
|
186
|
+
angle_rad = math.radians(angle_deg)
|
|
187
|
+
x = 108 * math.cos(angle_rad)
|
|
188
|
+
y = 108 * math.sin(angle_rad)
|
|
189
|
+
|
|
190
|
+
rect = QRectF(x - 10, y - 10, 20, 20)
|
|
191
|
+
painter.drawText(rect, Qt.AlignCenter, f"{m:02d}")
|
|
192
|
+
painter.restore()
|
|
193
|
+
|
|
194
|
+
def draw_hands(self, painter):
|
|
195
|
+
ms = self.get_elapsed_ms()
|
|
196
|
+
|
|
197
|
+
# Minute hand (smooth rotation)
|
|
198
|
+
minute_angle = (ms % 3600000) * (360.0 / 3600000.0)
|
|
199
|
+
|
|
200
|
+
if self.total_elapsed == 0 and not self.running:
|
|
201
|
+
hand_color = self.black_color
|
|
202
|
+
elif self.countdown_target_ms > 0:
|
|
203
|
+
if ms < 0:
|
|
204
|
+
hand_color = self.red_color
|
|
205
|
+
elif ms <= 0.10 * self.countdown_target_ms:
|
|
206
|
+
hand_color = self.orange_color
|
|
207
|
+
else:
|
|
208
|
+
hand_color = self.green_color
|
|
209
|
+
else:
|
|
210
|
+
hand_color = self.green_color
|
|
211
|
+
|
|
212
|
+
self.draw_capsule_hand(painter, minute_angle, hand_color, (-3, -75, 6, 95, 3.0, 3.0))
|
|
213
|
+
|
|
214
|
+
# Draw second hand (Swiss railway animation: wait at top for 1.5s)
|
|
215
|
+
sec_ms = ms % 60000
|
|
216
|
+
|
|
217
|
+
raw_elapsed = self.total_elapsed
|
|
218
|
+
if self.running:
|
|
219
|
+
raw_elapsed += self.elapsed_timer.elapsed()
|
|
220
|
+
|
|
221
|
+
if raw_elapsed >= 1500:
|
|
222
|
+
if self.countdown_target_ms > 0:
|
|
223
|
+
if sec_ms > 58500:
|
|
224
|
+
second_angle = 0.0
|
|
225
|
+
else:
|
|
226
|
+
second_angle = sec_ms * (360.0 / 60000.0)
|
|
227
|
+
else:
|
|
228
|
+
if sec_ms < 1500:
|
|
229
|
+
second_angle = 0.0
|
|
230
|
+
else:
|
|
231
|
+
second_angle = sec_ms * (360.0 / 60000.0)
|
|
232
|
+
else:
|
|
233
|
+
second_angle = sec_ms * (360.0 / 60000.0)
|
|
234
|
+
|
|
235
|
+
self.draw_swiss_second_hand(painter, second_angle)
|
|
236
|
+
|
|
237
|
+
def toggle_timer(self):
|
|
238
|
+
if self.running:
|
|
239
|
+
# Pause
|
|
240
|
+
self.total_elapsed += self.elapsed_timer.elapsed()
|
|
241
|
+
self.running = False
|
|
242
|
+
else:
|
|
243
|
+
# Start
|
|
244
|
+
self.elapsed_timer.start()
|
|
245
|
+
self.running = True
|
|
246
|
+
self.update()
|
|
247
|
+
|
|
248
|
+
def reset_timer(self):
|
|
249
|
+
self.running = False
|
|
250
|
+
self.total_elapsed = 0
|
|
251
|
+
self.update()
|
|
252
|
+
|
|
253
|
+
def set_countdown(self, ms):
|
|
254
|
+
self.countdown_target_ms = ms
|
|
255
|
+
self.reset_timer()
|
|
256
|
+
if hasattr(self.parent(), 'update_buttons'):
|
|
257
|
+
self.parent().update_buttons()
|
|
258
|
+
|
|
259
|
+
def get_elapsed_ms(self):
|
|
260
|
+
ms = self.total_elapsed
|
|
261
|
+
if self.running:
|
|
262
|
+
ms += self.elapsed_timer.elapsed()
|
|
263
|
+
|
|
264
|
+
if self.countdown_target_ms > 0:
|
|
265
|
+
return self.countdown_target_ms - ms
|
|
266
|
+
return ms
|
|
267
|
+
|
|
268
|
+
if __name__ == '__main__':
|
|
269
|
+
app = QApplication(sys.argv)
|
|
270
|
+
stopwatch = StopWatch()
|
|
271
|
+
stopwatch.resize(300, 300)
|
|
272
|
+
stopwatch.show()
|
|
273
|
+
sys.exit(app.exec())
|