termdown 2.0.0__tar.gz → 2.0.1__tar.gz
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.
- {termdown-2.0.0 → termdown-2.0.1}/CHANGELOG.md +30 -0
- {termdown-2.0.0 → termdown-2.0.1}/PKG-INFO +2 -2
- {termdown-2.0.0 → termdown-2.0.1}/pyproject.toml +1 -1
- {termdown-2.0.0 → termdown-2.0.1}/termdown/cli.py +49 -8
- termdown-2.0.1/termdown/modes.py +339 -0
- termdown-2.0.1/termdown/ticker.py +64 -0
- {termdown-2.0.0 → termdown-2.0.1}/termdown/ttf.py +1 -1
- {termdown-2.0.0 → termdown-2.0.1}/termdown/ui.py +56 -21
- {termdown-2.0.0 → termdown-2.0.1}/termdown/utils.py +72 -31
- termdown-2.0.0/termdown/modes.py +0 -287
- termdown-2.0.0/termdown/ticker.py +0 -52
- {termdown-2.0.0 → termdown-2.0.1}/AUTHORS +0 -0
- {termdown-2.0.0 → termdown-2.0.1}/LICENSE +0 -0
- {termdown-2.0.0 → termdown-2.0.1}/README.md +0 -0
- {termdown-2.0.0 → termdown-2.0.1}/termdown/__init__.py +0 -0
- {termdown-2.0.0 → termdown-2.0.1}/termdown/events.py +0 -0
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
# 2.0.1
|
|
2
|
+
|
|
3
|
+
2026-11-07
|
|
4
|
+
|
|
5
|
+
* fixed unknown font names silently falling back to a different font
|
|
6
|
+
* fixed font path missing from TTF/OTF loading error
|
|
7
|
+
* fixed display lagging behind when holding down keys (#107)
|
|
8
|
+
* fixed stopwatch going negative when reset while paused (#109)
|
|
9
|
+
* fixed stopwatch going negative when taking a lap while paused
|
|
10
|
+
* fixed stopwatch total including paused time when quitting while paused
|
|
11
|
+
* fixed countdown ending up to a second late after pausing or resetting
|
|
12
|
+
* fixed clock updating late after pausing
|
|
13
|
+
* fixed time paused before a reset being added to the countdown when unpausing
|
|
14
|
+
* fixed countdown restarting paused when it reached zero while paused
|
|
15
|
+
* fixed countdown announcing 4/9/29 minutes instead of 5/10/30
|
|
16
|
+
* fixed `--exec-cmd` receiving fractional seconds as `{0}`
|
|
17
|
+
* fixed announcements and `--exec-cmd` repeating on keypresses
|
|
18
|
+
* fixed random fonts (e.g. `-f random`) being rejected, they're now picked once at startup
|
|
19
|
+
* fixed non-ASCII characters in `--title` not being replaced
|
|
20
|
+
* fixed durations like `1h30` silently ignoring everything after the first match
|
|
21
|
+
* fixed past dates without a year (e.g. `5th 10:00`) being moved by one day
|
|
22
|
+
* fixed past times being moved to tomorrow off by an hour across DST changes
|
|
23
|
+
* fixed unparseable TIME crashing with a traceback instead of showing an error
|
|
24
|
+
* fixed `--quit-after` in stopwatch mode restarting with every lap
|
|
25
|
+
* fixed `--quit-after` quitting up to a second late in clock and stopwatch mode
|
|
26
|
+
* fixed blank space around ASCII art requiring a larger terminal than necessary (#84)
|
|
27
|
+
* fixed `--outfile` not containing the final time after quitting stopwatch mode (#60)
|
|
28
|
+
* fixed invalid options (e.g. existing `--outfile` or broken font files) crashing with a traceback instead of showing an error
|
|
29
|
+
|
|
30
|
+
|
|
1
31
|
# 2.0.0
|
|
2
32
|
|
|
3
33
|
2026-01-31
|
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
import os
|
|
2
2
|
from argparse import ArgumentParser, RawTextHelpFormatter
|
|
3
3
|
from curses import wrapper
|
|
4
|
+
from difflib import get_close_matches
|
|
4
5
|
from functools import wraps
|
|
5
6
|
from os.path import abspath, dirname
|
|
6
7
|
from sys import stderr
|
|
7
8
|
|
|
9
|
+
from art import FONT_NAMES
|
|
10
|
+
from art.utils import indirect_font
|
|
11
|
+
|
|
8
12
|
from . import VERSION
|
|
9
13
|
from .modes import clock, countdown, stopwatch
|
|
14
|
+
from .ttf import ttf_to_ascii
|
|
10
15
|
from .ui import Ui
|
|
11
|
-
from .utils import format_seconds, normalize_text
|
|
16
|
+
from .utils import format_seconds, normalize_text, parse_timestr
|
|
12
17
|
|
|
13
18
|
DEFAULT_FONT = "univers"
|
|
14
19
|
DEFAULT_TIME_FORMAT = "%H:%M:%S" # --no-seconds expects this to end with :%S
|
|
15
20
|
DEFAULT_DATE_FORMAT = "%Y-%m-%d"
|
|
21
|
+
# font names that make art pick a font at random
|
|
22
|
+
RANDOM_FONTS = {
|
|
23
|
+
f"{prefix}{suffix}"
|
|
24
|
+
for prefix in ("random", "rand", "rnd")
|
|
25
|
+
for suffix in ("", "-small", "-medium", "-large", "-xlarge", "-na")
|
|
26
|
+
} | {"wizard", "wiz", "magic"}
|
|
16
27
|
|
|
17
28
|
|
|
18
29
|
def _escape_percent_for_argparse_help(text):
|
|
@@ -166,7 +177,7 @@ parser.add_argument(
|
|
|
166
177
|
parser.add_argument(
|
|
167
178
|
"--no-text-magic",
|
|
168
179
|
action="store_true",
|
|
169
|
-
help="Don't try to replace non-ASCII characters (use with -t)",
|
|
180
|
+
help="Don't try to replace non-ASCII characters (use with -t/-T)",
|
|
170
181
|
)
|
|
171
182
|
parser.add_argument(
|
|
172
183
|
"-z",
|
|
@@ -216,7 +227,40 @@ def curses_ui(stdscr, mode, args):
|
|
|
216
227
|
def main():
|
|
217
228
|
args = parser.parse_args()
|
|
218
229
|
if args.exec_cmd and args.voice: # prevent passing both --exec-cmd and --voice
|
|
219
|
-
|
|
230
|
+
parser.error("--exec-cmd and --voice are mutually exclusive")
|
|
231
|
+
|
|
232
|
+
if args.text and not args.no_text_magic:
|
|
233
|
+
args.text = normalize_text(args.text)
|
|
234
|
+
if args.title and not args.no_text_magic:
|
|
235
|
+
args.title = normalize_text(args.title)
|
|
236
|
+
|
|
237
|
+
if not args.no_art and os.path.exists(args.font):
|
|
238
|
+
try:
|
|
239
|
+
ttf_to_ascii("", args.font, args.font_size, args.font_charset)
|
|
240
|
+
except RuntimeError as exc:
|
|
241
|
+
parser.error(str(exc))
|
|
242
|
+
elif not args.no_art:
|
|
243
|
+
if args.font.lower() in RANDOM_FONTS:
|
|
244
|
+
# pick a font once, otherwise art would pick a new one on every redraw
|
|
245
|
+
args.font = indirect_font(
|
|
246
|
+
args.font.lower(),
|
|
247
|
+
"0123456789:dhmsy" + (args.title or "") + (args.text or ""),
|
|
248
|
+
)
|
|
249
|
+
# art silently falls back to the most similar font name, so check first
|
|
250
|
+
elif args.font.lower() not in FONT_NAMES:
|
|
251
|
+
message = f"unknown font: {args.font}"
|
|
252
|
+
suggestions = get_close_matches(args.font.lower(), FONT_NAMES)
|
|
253
|
+
if suggestions:
|
|
254
|
+
message += f" (did you mean: {', '.join(suggestions)}?)"
|
|
255
|
+
parser.error(message)
|
|
256
|
+
|
|
257
|
+
if args.timespec and not args.time:
|
|
258
|
+
# Check TIME before starting curses so we can show a proper error.
|
|
259
|
+
# countdown() parses it again since durations are relative to now.
|
|
260
|
+
try:
|
|
261
|
+
parse_timestr(args.timespec)
|
|
262
|
+
except ValueError as exc:
|
|
263
|
+
parser.error(str(exc))
|
|
220
264
|
|
|
221
265
|
if args.time_format is None:
|
|
222
266
|
args.time_format = (
|
|
@@ -227,12 +271,9 @@ def main():
|
|
|
227
271
|
|
|
228
272
|
if args.outfile:
|
|
229
273
|
if os.path.exists(args.outfile):
|
|
230
|
-
|
|
274
|
+
parser.error("file already exists: {}".format(args.outfile))
|
|
231
275
|
if not os.access(dirname(abspath(args.outfile)), os.W_OK):
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if args.text and not args.no_text_magic:
|
|
235
|
-
args.text = normalize_text(args.text)
|
|
276
|
+
parser.error("unable to write file: {}".format(args.outfile))
|
|
236
277
|
|
|
237
278
|
args.voice_cmd = None
|
|
238
279
|
if args.voice:
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from curses import beep
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from math import ceil
|
|
5
|
+
from queue import Empty
|
|
6
|
+
from subprocess import DEVNULL, STDOUT, Popen
|
|
7
|
+
from sys import exit, stdout
|
|
8
|
+
from threading import Timer
|
|
9
|
+
from time import monotonic, time
|
|
10
|
+
|
|
11
|
+
from .events import (
|
|
12
|
+
INPUT_END,
|
|
13
|
+
INPUT_EXIT,
|
|
14
|
+
INPUT_LAP,
|
|
15
|
+
INPUT_MINUS,
|
|
16
|
+
INPUT_PAUSE,
|
|
17
|
+
INPUT_PLUS,
|
|
18
|
+
INPUT_RESET,
|
|
19
|
+
TIME_TICK,
|
|
20
|
+
)
|
|
21
|
+
from .ticker import Metronome
|
|
22
|
+
from .utils import (
|
|
23
|
+
format_seconds,
|
|
24
|
+
format_seconds_alt,
|
|
25
|
+
format_target,
|
|
26
|
+
parse_timestr,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _wake_up_in(seconds, ui):
|
|
31
|
+
"""
|
|
32
|
+
Makes sure the mode loop runs again after the given number of seconds,
|
|
33
|
+
if that is before the next regular tick.
|
|
34
|
+
"""
|
|
35
|
+
if seconds < 1:
|
|
36
|
+
timer = Timer(seconds, ui.input_queue.put, (TIME_TICK,))
|
|
37
|
+
timer.daemon = True
|
|
38
|
+
timer.start()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def countdown(ui, args):
|
|
42
|
+
target_time = parse_timestr(args.timespec)
|
|
43
|
+
# tick whenever the number of seconds left crosses a whole number
|
|
44
|
+
ticker = Metronome(ui.input_queue, offset=target_time.timestamp())
|
|
45
|
+
ticker.start()
|
|
46
|
+
last_announced = None
|
|
47
|
+
|
|
48
|
+
while True: # Outer loop to allow restarting countdown from scratch
|
|
49
|
+
while True: # Active countdown loop
|
|
50
|
+
seconds_left = (target_time - datetime.now(timezone.utc)).total_seconds()
|
|
51
|
+
if seconds_left <= 0:
|
|
52
|
+
# If seconds_left is zero or negative, immediately break to handle the
|
|
53
|
+
# "finished" state. This prevents displaying "0" for an entire second
|
|
54
|
+
# while waiting for the next tick.
|
|
55
|
+
break
|
|
56
|
+
|
|
57
|
+
if args.alt_format:
|
|
58
|
+
countdown_text = format_seconds_alt(
|
|
59
|
+
seconds_left, hide_seconds=args.no_seconds
|
|
60
|
+
)
|
|
61
|
+
else:
|
|
62
|
+
countdown_text = format_seconds(
|
|
63
|
+
seconds_left, hide_seconds=args.no_seconds
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
with ui.curses_lock:
|
|
67
|
+
ui.set_window_title(countdown_text)
|
|
68
|
+
if args.outfile:
|
|
69
|
+
with open(args.outfile, "w") as f:
|
|
70
|
+
f.write("{}\n{}\n".format(countdown_text, seconds_left))
|
|
71
|
+
end_text = (
|
|
72
|
+
format_target(
|
|
73
|
+
target_time,
|
|
74
|
+
time_format=args.time_format,
|
|
75
|
+
date_format=args.date_format,
|
|
76
|
+
)
|
|
77
|
+
if args.end
|
|
78
|
+
else None
|
|
79
|
+
)
|
|
80
|
+
color = 0
|
|
81
|
+
if ticker.is_paused:
|
|
82
|
+
color = 3
|
|
83
|
+
elif seconds_left <= args.critical:
|
|
84
|
+
color = 1
|
|
85
|
+
ui.draw_text(countdown_text, color=color, end=end_text)
|
|
86
|
+
|
|
87
|
+
# the number of seconds as displayed
|
|
88
|
+
whole_seconds_left = int(ceil(seconds_left))
|
|
89
|
+
annunciation = None
|
|
90
|
+
if whole_seconds_left <= args.critical:
|
|
91
|
+
annunciation = str(whole_seconds_left)
|
|
92
|
+
elif whole_seconds_left in (5, 10, 20, 30, 60):
|
|
93
|
+
annunciation = "{} {} seconds".format(
|
|
94
|
+
args.voice_prefix, whole_seconds_left
|
|
95
|
+
)
|
|
96
|
+
elif whole_seconds_left in (300, 600, 1800):
|
|
97
|
+
annunciation = "{} {} minutes".format(
|
|
98
|
+
args.voice_prefix, whole_seconds_left // 60
|
|
99
|
+
)
|
|
100
|
+
elif whole_seconds_left == 3600:
|
|
101
|
+
annunciation = "{} one hour".format(args.voice_prefix)
|
|
102
|
+
# don't repeat ourselves when redrawing due to keypresses
|
|
103
|
+
if whole_seconds_left != last_announced:
|
|
104
|
+
last_announced = whole_seconds_left
|
|
105
|
+
if (
|
|
106
|
+
annunciation and args.voice_cmd
|
|
107
|
+
): # Only announce if there is something to say
|
|
108
|
+
Popen(
|
|
109
|
+
[args.voice_cmd, "-v", args.voice, annunciation.strip()],
|
|
110
|
+
stdout=DEVNULL,
|
|
111
|
+
stderr=STDOUT,
|
|
112
|
+
)
|
|
113
|
+
if args.exec_cmd:
|
|
114
|
+
# Pass annunciation even if it's empty, format() handles it.
|
|
115
|
+
Popen(
|
|
116
|
+
args.exec_cmd.format(
|
|
117
|
+
whole_seconds_left, annunciation or ""
|
|
118
|
+
),
|
|
119
|
+
stdout=DEVNULL,
|
|
120
|
+
stderr=STDOUT,
|
|
121
|
+
shell=True,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
for input_action in ui.get_input_actions():
|
|
125
|
+
if input_action == INPUT_PAUSE:
|
|
126
|
+
duration = ticker.pause()
|
|
127
|
+
if duration:
|
|
128
|
+
target_time += timedelta(seconds=duration)
|
|
129
|
+
ticker.align(target_time.timestamp())
|
|
130
|
+
elif input_action == INPUT_EXIT:
|
|
131
|
+
exit(1)
|
|
132
|
+
elif input_action == INPUT_RESET:
|
|
133
|
+
target_time = parse_timestr(args.timespec)
|
|
134
|
+
ticker.align(target_time.timestamp())
|
|
135
|
+
# don't add time paused before the reset when unpausing
|
|
136
|
+
ticker.restart_pause()
|
|
137
|
+
elif input_action == INPUT_PLUS:
|
|
138
|
+
target_time += timedelta(seconds=10)
|
|
139
|
+
elif input_action == INPUT_MINUS:
|
|
140
|
+
target_time -= timedelta(seconds=10)
|
|
141
|
+
elif input_action == INPUT_END:
|
|
142
|
+
args.end = not args.end
|
|
143
|
+
|
|
144
|
+
# After the active countdown loop, handle the "time is up" state.
|
|
145
|
+
|
|
146
|
+
if not args.no_bell:
|
|
147
|
+
with ui.curses_lock:
|
|
148
|
+
beep()
|
|
149
|
+
|
|
150
|
+
if args.outfile:
|
|
151
|
+
with open(args.outfile, "w") as f:
|
|
152
|
+
f.write("{}\n{}\n".format(args.text if args.text else "DONE", 0))
|
|
153
|
+
|
|
154
|
+
if args.blink or args.text:
|
|
155
|
+
base_color = 1 if args.blink else 0
|
|
156
|
+
flip = True
|
|
157
|
+
if not ticker.is_paused: # we might have reached zero while paused
|
|
158
|
+
ticker.pause() # Pause ticker during blinking phase
|
|
159
|
+
while True:
|
|
160
|
+
if args.quit_after and (
|
|
161
|
+
datetime.now(timezone.utc) - target_time
|
|
162
|
+
).total_seconds() > float(args.quit_after):
|
|
163
|
+
return
|
|
164
|
+
with ui.curses_lock:
|
|
165
|
+
ui.set_window_title("/" if flip else "\\")
|
|
166
|
+
if args.text:
|
|
167
|
+
ui.draw_text(args.text, color=base_color if flip else 4)
|
|
168
|
+
else:
|
|
169
|
+
ui.draw_text("", color=base_color if flip else 4)
|
|
170
|
+
if args.blink:
|
|
171
|
+
flip = not flip
|
|
172
|
+
try:
|
|
173
|
+
input_action = ui.input_queue.get(True, 0.5)
|
|
174
|
+
except Empty:
|
|
175
|
+
input_action = None
|
|
176
|
+
if input_action == INPUT_EXIT:
|
|
177
|
+
return
|
|
178
|
+
elif input_action == INPUT_RESET:
|
|
179
|
+
target_time = parse_timestr(args.timespec)
|
|
180
|
+
ticker.align(target_time.timestamp())
|
|
181
|
+
ticker.pause() # resume
|
|
182
|
+
break # Break out of the blinking loop to restart the main countdown
|
|
183
|
+
else:
|
|
184
|
+
break
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def clock(ui, args):
|
|
188
|
+
time_started = monotonic()
|
|
189
|
+
seconds_elapsed = 0
|
|
190
|
+
offset = timedelta(0)
|
|
191
|
+
ticker = Metronome(ui.input_queue)
|
|
192
|
+
ticker.start()
|
|
193
|
+
while True:
|
|
194
|
+
seconds_elapsed = monotonic() - time_started
|
|
195
|
+
if args.quit_after:
|
|
196
|
+
seconds_until_quit = float(args.quit_after) - seconds_elapsed
|
|
197
|
+
if seconds_until_quit <= 0:
|
|
198
|
+
return
|
|
199
|
+
# ticks happen on full seconds of the clock, not since we started
|
|
200
|
+
_wake_up_in(seconds_until_quit, ui)
|
|
201
|
+
clock_text = (datetime.now() + offset).strftime(args.time_format)
|
|
202
|
+
with ui.curses_lock:
|
|
203
|
+
ui.set_window_title(clock_text)
|
|
204
|
+
|
|
205
|
+
if args.outfile:
|
|
206
|
+
with open(args.outfile, "w") as f:
|
|
207
|
+
f.write("{}\n{}\n".format(clock_text, seconds_elapsed))
|
|
208
|
+
|
|
209
|
+
ui.draw_text(clock_text, color=3 if ticker.is_paused else 0)
|
|
210
|
+
|
|
211
|
+
for input_action in ui.get_input_actions():
|
|
212
|
+
if input_action == INPUT_EXIT:
|
|
213
|
+
return
|
|
214
|
+
if input_action == INPUT_PLUS:
|
|
215
|
+
offset += timedelta(seconds=10)
|
|
216
|
+
elif input_action == INPUT_MINUS:
|
|
217
|
+
offset -= timedelta(seconds=10)
|
|
218
|
+
elif input_action == INPUT_PAUSE:
|
|
219
|
+
ticker.pause()
|
|
220
|
+
elif input_action == INPUT_RESET:
|
|
221
|
+
offset = timedelta(0)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def stopwatch(ui, args):
|
|
225
|
+
time_started = monotonic()
|
|
226
|
+
ticker = Metronome(ui.input_queue, offset=time() % 1.0)
|
|
227
|
+
ticker.start()
|
|
228
|
+
time_paused = None
|
|
229
|
+
seconds_elapsed = 0
|
|
230
|
+
last_announced = None
|
|
231
|
+
laps = []
|
|
232
|
+
|
|
233
|
+
def now():
|
|
234
|
+
# while paused, time stands still at the moment we paused
|
|
235
|
+
return monotonic() if time_paused is None else time_paused
|
|
236
|
+
|
|
237
|
+
def align_ticker():
|
|
238
|
+
# tick whenever the number of seconds elapsed crosses a whole number
|
|
239
|
+
ticker.align(time() - (now() - time_started))
|
|
240
|
+
|
|
241
|
+
def format_elapsed(seconds):
|
|
242
|
+
formatter = format_seconds_alt if args.alt_format else format_seconds
|
|
243
|
+
return formatter(round(seconds), hide_seconds=args.no_seconds)
|
|
244
|
+
|
|
245
|
+
def write_outfile(seconds):
|
|
246
|
+
if args.outfile:
|
|
247
|
+
with open(args.outfile, "w") as f:
|
|
248
|
+
f.write("{}\n{}\n".format(format_elapsed(seconds), seconds))
|
|
249
|
+
|
|
250
|
+
def finish(seconds):
|
|
251
|
+
# leave the final time in the outfile, not the one last displayed
|
|
252
|
+
write_outfile(seconds)
|
|
253
|
+
return seconds, laps
|
|
254
|
+
|
|
255
|
+
while True:
|
|
256
|
+
seconds_elapsed = now() - time_started
|
|
257
|
+
|
|
258
|
+
if args.quit_after:
|
|
259
|
+
# laps restart seconds_elapsed, but not the stopwatch as a whole
|
|
260
|
+
seconds_until_quit = float(args.quit_after) - sum(laps) - seconds_elapsed
|
|
261
|
+
if seconds_until_quit <= 0:
|
|
262
|
+
return finish(seconds_elapsed)
|
|
263
|
+
if not ticker.is_paused:
|
|
264
|
+
# after laps, ticks no longer line up with the total time
|
|
265
|
+
_wake_up_in(seconds_until_quit, ui)
|
|
266
|
+
|
|
267
|
+
stopwatch_text = format_elapsed(seconds_elapsed)
|
|
268
|
+
with ui.curses_lock:
|
|
269
|
+
if not args.no_window_title:
|
|
270
|
+
os.write(
|
|
271
|
+
stdout.fileno(),
|
|
272
|
+
"\033]2;{0}\007".format(stopwatch_text).encode(),
|
|
273
|
+
)
|
|
274
|
+
write_outfile(seconds_elapsed)
|
|
275
|
+
ui.draw_text(stopwatch_text, color=3 if ticker.is_paused else 0)
|
|
276
|
+
|
|
277
|
+
whole_seconds_elapsed = int(seconds_elapsed)
|
|
278
|
+
annunciation = None
|
|
279
|
+
if 1 <= whole_seconds_elapsed <= args.critical:
|
|
280
|
+
annunciation = str(whole_seconds_elapsed)
|
|
281
|
+
elif whole_seconds_elapsed in (5, 10, 20, 30, 40, 50, 60):
|
|
282
|
+
annunciation = "{} {} seconds".format(
|
|
283
|
+
args.voice_prefix, whole_seconds_elapsed
|
|
284
|
+
)
|
|
285
|
+
elif whole_seconds_elapsed in (120, 180, 300, 600, 1800):
|
|
286
|
+
annunciation = "{} {} minutes".format(
|
|
287
|
+
args.voice_prefix, whole_seconds_elapsed // 60
|
|
288
|
+
)
|
|
289
|
+
elif whole_seconds_elapsed == 3600:
|
|
290
|
+
annunciation = "{} one hour".format(args.voice_prefix)
|
|
291
|
+
elif whole_seconds_elapsed % 3600 == 0 and whole_seconds_elapsed > 0:
|
|
292
|
+
annunciation = "{} {} hours".format(
|
|
293
|
+
args.voice_prefix, whole_seconds_elapsed // 3600
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# don't repeat ourselves when redrawing due to keypresses
|
|
297
|
+
if whole_seconds_elapsed != last_announced:
|
|
298
|
+
last_announced = whole_seconds_elapsed
|
|
299
|
+
if annunciation and args.voice_cmd:
|
|
300
|
+
Popen(
|
|
301
|
+
[args.voice_cmd, "-v", args.voice, annunciation.strip()],
|
|
302
|
+
stdout=DEVNULL,
|
|
303
|
+
stderr=STDOUT,
|
|
304
|
+
)
|
|
305
|
+
if args.exec_cmd:
|
|
306
|
+
Popen(
|
|
307
|
+
args.exec_cmd.format(whole_seconds_elapsed, annunciation or ""),
|
|
308
|
+
stdout=DEVNULL,
|
|
309
|
+
stderr=STDOUT,
|
|
310
|
+
shell=True,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
for input_action in ui.get_input_actions():
|
|
314
|
+
if input_action == INPUT_PLUS:
|
|
315
|
+
time_started -= 10
|
|
316
|
+
elif input_action == INPUT_MINUS:
|
|
317
|
+
time_started += 10
|
|
318
|
+
elif input_action == INPUT_PAUSE:
|
|
319
|
+
duration = ticker.pause()
|
|
320
|
+
if duration: # unpaused
|
|
321
|
+
time_started += duration
|
|
322
|
+
time_paused = None
|
|
323
|
+
align_ticker()
|
|
324
|
+
else:
|
|
325
|
+
time_paused = monotonic()
|
|
326
|
+
elif input_action == INPUT_EXIT:
|
|
327
|
+
return finish(now() - time_started)
|
|
328
|
+
elif input_action == INPUT_RESET:
|
|
329
|
+
laps = []
|
|
330
|
+
# If paused, this rebases the start to the pause instant so the
|
|
331
|
+
# display resets to 0 instead of going negative by the time spent
|
|
332
|
+
# paused before the reset.
|
|
333
|
+
time_started = now()
|
|
334
|
+
align_ticker()
|
|
335
|
+
elif input_action == INPUT_LAP:
|
|
336
|
+
lap_time = now()
|
|
337
|
+
laps.append(lap_time - time_started)
|
|
338
|
+
time_started = lap_time
|
|
339
|
+
align_ticker()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from threading import Event, Thread
|
|
2
|
+
from time import monotonic, time
|
|
3
|
+
|
|
4
|
+
from .events import TIME_TICK
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Metronome:
|
|
8
|
+
"""
|
|
9
|
+
Will put a TIME_TICK event into the given queue every second, at the
|
|
10
|
+
moments when the fractional part of time() equals offset.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, queue, offset=0.0):
|
|
14
|
+
self._offset = offset % 1.0
|
|
15
|
+
self._queue = queue
|
|
16
|
+
self._realigned = Event()
|
|
17
|
+
self._thread = Thread(target=self._run, daemon=True)
|
|
18
|
+
self._pause_time = None
|
|
19
|
+
|
|
20
|
+
def _run(self):
|
|
21
|
+
while True:
|
|
22
|
+
# Sleep until the next tick, plus a small margin so we never wake
|
|
23
|
+
# up just before it (which would e.g. make a countdown display the
|
|
24
|
+
# previous second for another full second).
|
|
25
|
+
sleep_time = (self._offset - time()) % 1.0 + 0.002
|
|
26
|
+
if self._realigned.wait(sleep_time):
|
|
27
|
+
# offset changed while we were sleeping, start over
|
|
28
|
+
self._realigned.clear()
|
|
29
|
+
continue
|
|
30
|
+
if not self.is_paused:
|
|
31
|
+
self._queue.put(TIME_TICK)
|
|
32
|
+
|
|
33
|
+
def start(self):
|
|
34
|
+
self._thread.start()
|
|
35
|
+
|
|
36
|
+
def align(self, offset):
|
|
37
|
+
"""
|
|
38
|
+
Make future ticks happen when the fractional part of time() equals
|
|
39
|
+
offset.
|
|
40
|
+
"""
|
|
41
|
+
self._offset = offset % 1.0
|
|
42
|
+
self._realigned.set()
|
|
43
|
+
|
|
44
|
+
def pause(self):
|
|
45
|
+
"""
|
|
46
|
+
Toggles pause. Returns the duration of the pause when unpausing.
|
|
47
|
+
"""
|
|
48
|
+
if self.is_paused: # unpause
|
|
49
|
+
duration = monotonic() - self._pause_time
|
|
50
|
+
self._pause_time = None
|
|
51
|
+
return duration
|
|
52
|
+
else: # pause
|
|
53
|
+
self._pause_time = monotonic()
|
|
54
|
+
|
|
55
|
+
def restart_pause(self):
|
|
56
|
+
"""
|
|
57
|
+
If paused, forget about the time spent paused so far.
|
|
58
|
+
"""
|
|
59
|
+
if self.is_paused:
|
|
60
|
+
self._pause_time = monotonic()
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def is_paused(self):
|
|
64
|
+
return self._pause_time is not None
|
|
@@ -5,7 +5,7 @@ def ttf_to_ascii(text, font_path, font_size, char_set):
|
|
|
5
5
|
try:
|
|
6
6
|
font = ImageFont.truetype(font_path, font_size)
|
|
7
7
|
except IOError:
|
|
8
|
-
raise RuntimeError("
|
|
8
|
+
raise RuntimeError(f"could not load font from {font_path}")
|
|
9
9
|
|
|
10
10
|
# Figure out text dimensions
|
|
11
11
|
dummy_img = Image.new("L", (1, 1))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import curses
|
|
2
2
|
import os
|
|
3
|
-
from queue import Queue
|
|
3
|
+
from queue import Empty, Queue
|
|
4
4
|
from sys import stdout
|
|
5
5
|
from threading import Lock, Thread
|
|
6
6
|
from time import sleep
|
|
@@ -17,7 +17,7 @@ from .events import (
|
|
|
17
17
|
INPUT_RESET,
|
|
18
18
|
)
|
|
19
19
|
from .ttf import ttf_to_ascii
|
|
20
|
-
from .utils import pad_to_size
|
|
20
|
+
from .utils import pad_to_size, trim_block
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
class Ui:
|
|
@@ -70,6 +70,11 @@ class Ui:
|
|
|
70
70
|
art_title = text2art(title, font=self._args.font)
|
|
71
71
|
art_text = text2art(text, font=self._args.font)
|
|
72
72
|
art_end = text2art(end, font=self._args.font)
|
|
73
|
+
# fonts often add blank space around the text, which would make us
|
|
74
|
+
# fall back to smaller variants earlier than necessary
|
|
75
|
+
art_title = trim_block(art_title)
|
|
76
|
+
art_text = trim_block(art_text)
|
|
77
|
+
art_end = trim_block(art_end)
|
|
73
78
|
variants = [
|
|
74
79
|
(art_title + "\n\n\n\n" + art_text + "\n\n\n\n" + art_end).strip("\n"),
|
|
75
80
|
(art_title + "\n\n" + art_text + "\n\n" + art_end).strip("\n"),
|
|
@@ -79,6 +84,14 @@ class Ui:
|
|
|
79
84
|
|
|
80
85
|
y, x = self.stdscr.getmaxyx()
|
|
81
86
|
for variant in variants:
|
|
87
|
+
variant_lines = variant.split("\n")
|
|
88
|
+
if (
|
|
89
|
+
len(variant_lines) > y
|
|
90
|
+
or max(len(line) for line in variant_lines) > x
|
|
91
|
+
):
|
|
92
|
+
# insstr() silently truncates lines that are too wide,
|
|
93
|
+
# so we have to check ourselves whether this variant fits
|
|
94
|
+
continue
|
|
82
95
|
lines = pad_to_size(variant, x, y).rstrip("\n").split("\n")
|
|
83
96
|
self.stdscr.erase()
|
|
84
97
|
try:
|
|
@@ -106,23 +119,45 @@ class Ui:
|
|
|
106
119
|
|
|
107
120
|
def _input_thread_body(self):
|
|
108
121
|
while True:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
self.
|
|
120
|
-
elif key in ("r", "R"):
|
|
121
|
-
self.input_queue.put(INPUT_RESET)
|
|
122
|
-
elif key in ("l", "L"):
|
|
123
|
-
self.input_queue.put(INPUT_LAP)
|
|
124
|
-
elif key == "+":
|
|
125
|
-
self.input_queue.put(INPUT_PLUS)
|
|
126
|
-
elif key == "-":
|
|
127
|
-
self.input_queue.put(INPUT_MINUS)
|
|
122
|
+
keys = []
|
|
123
|
+
with self.curses_lock:
|
|
124
|
+
# Read every pending key at once so held keys (autorepeat)
|
|
125
|
+
# don't pile up in the terminal's input buffer.
|
|
126
|
+
while True:
|
|
127
|
+
try:
|
|
128
|
+
keys.append(self.stdscr.getkey())
|
|
129
|
+
except Exception:
|
|
130
|
+
break
|
|
131
|
+
for key in keys:
|
|
132
|
+
self._handle_key(key)
|
|
128
133
|
sleep(0.01)
|
|
134
|
+
|
|
135
|
+
def get_input_actions(self):
|
|
136
|
+
"""
|
|
137
|
+
Blocks until at least one input action is available, then returns it
|
|
138
|
+
along with all other actions that are already queued. This lets the
|
|
139
|
+
caller apply a burst of events (e.g. from holding down a key) before
|
|
140
|
+
redrawing just once.
|
|
141
|
+
"""
|
|
142
|
+
actions = [self.input_queue.get()]
|
|
143
|
+
while True:
|
|
144
|
+
try:
|
|
145
|
+
actions.append(self.input_queue.get_nowait())
|
|
146
|
+
except Empty:
|
|
147
|
+
return actions
|
|
148
|
+
|
|
149
|
+
def _handle_key(self, key):
|
|
150
|
+
if key in ("q", "Q"):
|
|
151
|
+
self.input_queue.put(INPUT_EXIT)
|
|
152
|
+
elif key == " ":
|
|
153
|
+
self.input_queue.put(INPUT_PAUSE)
|
|
154
|
+
elif key in ("e", "E"):
|
|
155
|
+
self.input_queue.put(INPUT_END)
|
|
156
|
+
elif key in ("r", "R"):
|
|
157
|
+
self.input_queue.put(INPUT_RESET)
|
|
158
|
+
elif key in ("l", "L"):
|
|
159
|
+
self.input_queue.put(INPUT_LAP)
|
|
160
|
+
elif key == "+":
|
|
161
|
+
self.input_queue.put(INPUT_PLUS)
|
|
162
|
+
elif key == "-":
|
|
163
|
+
self.input_queue.put(INPUT_MINUS)
|
|
@@ -16,17 +16,11 @@ NORMALIZE_TEXT_MAP = {
|
|
|
16
16
|
"ß": "ss",
|
|
17
17
|
}
|
|
18
18
|
TIMEDELTA_REGEX = re.compile(
|
|
19
|
-
r"((?P<years>\d+)y
|
|
20
|
-
r"((?P<days>\d+)d
|
|
21
|
-
r"((?P<hours>\d+)h
|
|
22
|
-
r"((?P<minutes>\d+)m
|
|
23
|
-
r"((?P<seconds>\d+)s
|
|
24
|
-
)
|
|
25
|
-
DATE_COMPONENT_REGEX = re.compile(
|
|
26
|
-
r"\d{4}|" # year
|
|
27
|
-
r"jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|"
|
|
28
|
-
r"\d{1,2}[/\-]\d{1,2}", # day/month with separator
|
|
29
|
-
re.IGNORECASE,
|
|
19
|
+
r"((?P<years>\d+)y\s*)?"
|
|
20
|
+
r"((?P<days>\d+)d\s*)?"
|
|
21
|
+
r"((?P<hours>\d+)h\s*)?"
|
|
22
|
+
r"((?P<minutes>\d+)m\s*)?"
|
|
23
|
+
r"((?P<seconds>\d+)s\s*)?"
|
|
30
24
|
)
|
|
31
25
|
|
|
32
26
|
|
|
@@ -101,6 +95,23 @@ def normalize_text(input_str):
|
|
|
101
95
|
)
|
|
102
96
|
|
|
103
97
|
|
|
98
|
+
def trim_block(text):
|
|
99
|
+
"""
|
|
100
|
+
Removes blank lines and columns around a block of text such as ASCII art.
|
|
101
|
+
All lines keep the same width so the block stays aligned when centered.
|
|
102
|
+
"""
|
|
103
|
+
lines = [line.rstrip() for line in text.split("\n")]
|
|
104
|
+
while lines and not lines[0]:
|
|
105
|
+
lines.pop(0)
|
|
106
|
+
while lines and not lines[-1]:
|
|
107
|
+
lines.pop()
|
|
108
|
+
if not lines:
|
|
109
|
+
return ""
|
|
110
|
+
indent = min(len(line) - len(line.lstrip()) for line in lines if line)
|
|
111
|
+
width = max(len(line) for line in lines) - indent
|
|
112
|
+
return "\n".join(line[indent:].ljust(width) for line in lines)
|
|
113
|
+
|
|
114
|
+
|
|
104
115
|
def pad_to_size(text, x, y):
|
|
105
116
|
"""
|
|
106
117
|
Adds whitespace to text to center it within a frame of the given
|
|
@@ -130,38 +141,68 @@ def parse_timestr(timestr):
|
|
|
130
141
|
"""
|
|
131
142
|
timedelta_secs = parse_timedelta(timestr)
|
|
132
143
|
|
|
133
|
-
if timedelta_secs:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if target.tzinfo is None:
|
|
144
|
-
target = target.replace(tzinfo=tz.tzlocal())
|
|
145
|
-
target = target.astimezone(timezone.utc)
|
|
146
|
-
|
|
147
|
-
if (
|
|
148
|
-
target <= datetime.now(timezone.utc) and
|
|
149
|
-
not DATE_COMPONENT_REGEX.search(timestr)
|
|
144
|
+
if timedelta_secs is not None:
|
|
145
|
+
return datetime.now(timezone.utc) + timedelta(seconds=timedelta_secs)
|
|
146
|
+
if timestr.isdigit():
|
|
147
|
+
return datetime.now(timezone.utc) + timedelta(seconds=int(timestr))
|
|
148
|
+
|
|
149
|
+
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
|
150
|
+
try:
|
|
151
|
+
target = _parse_datetime(timestr, today)
|
|
152
|
+
if target <= datetime.now(timezone.utc) and not _contains_date(
|
|
153
|
+
timestr, today, target
|
|
150
154
|
):
|
|
151
155
|
# User only gave us a time and it's in the past - probably
|
|
152
156
|
# means that time tomorrow.
|
|
153
|
-
target
|
|
157
|
+
target = _parse_datetime(timestr, today + timedelta(days=1))
|
|
158
|
+
except Exception:
|
|
159
|
+
# unfortunately, dateutil doesn't raise the best exceptions
|
|
160
|
+
raise ValueError("Unable to parse '{}'".format(timestr))
|
|
161
|
+
return target.astimezone(timezone.utc)
|
|
162
|
+
|
|
154
163
|
|
|
164
|
+
def _parse_datetime(timestr, default):
|
|
165
|
+
"""
|
|
166
|
+
Parses timestr, filling in missing components from default. Returns a
|
|
167
|
+
timezone-aware datetime, assuming local time if no timezone is given.
|
|
168
|
+
"""
|
|
169
|
+
target = parse(timestr, default=default)
|
|
170
|
+
if target.tzinfo is None:
|
|
171
|
+
target = target.replace(tzinfo=tz.tzlocal())
|
|
155
172
|
return target
|
|
156
173
|
|
|
157
174
|
|
|
175
|
+
def _contains_date(timestr, default, parsed):
|
|
176
|
+
"""
|
|
177
|
+
Returns True if timestr specifies (part of) a date or a weekday, i.e.
|
|
178
|
+
if parsing it doesn't just use the date from the given default.
|
|
179
|
+
"""
|
|
180
|
+
if parsed.date() != default.date():
|
|
181
|
+
return True
|
|
182
|
+
# Parse again with a default that differs in year, month, day and weekday.
|
|
183
|
+
for day in (1, 2, 3):
|
|
184
|
+
other = datetime(default.year - 1, default.month % 12 + 1, day)
|
|
185
|
+
if day != default.day and other.weekday() != default.weekday():
|
|
186
|
+
break
|
|
187
|
+
try:
|
|
188
|
+
return parse(timestr, default=other).date() != other.date()
|
|
189
|
+
except ValueError:
|
|
190
|
+
# e.g. Feb 29 in a non-leap year, so there definitely is a date
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
|
|
158
194
|
def parse_timedelta(deltastr):
|
|
159
195
|
"""
|
|
160
|
-
Parse a string describing a period of time.
|
|
196
|
+
Parse a string describing a period of time. Returns None if deltastr
|
|
197
|
+
doesn't look like one.
|
|
161
198
|
"""
|
|
199
|
+
deltastr = deltastr.strip()
|
|
162
200
|
matches = TIMEDELTA_REGEX.match(deltastr)
|
|
163
|
-
if not matches:
|
|
201
|
+
if not matches.group(0):
|
|
164
202
|
return None
|
|
203
|
+
if matches.end() != len(deltastr):
|
|
204
|
+
# e.g. "1h30", don't silently ignore the rest
|
|
205
|
+
raise ValueError("Unable to parse '{}'".format(deltastr))
|
|
165
206
|
components = {}
|
|
166
207
|
for name, value in matches.groupdict().items():
|
|
167
208
|
if value:
|
termdown-2.0.0/termdown/modes.py
DELETED
|
@@ -1,287 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
from curses import beep
|
|
3
|
-
from datetime import datetime, timedelta, timezone
|
|
4
|
-
from math import ceil
|
|
5
|
-
from queue import Empty
|
|
6
|
-
from subprocess import DEVNULL, STDOUT, Popen
|
|
7
|
-
from sys import exit, stdout
|
|
8
|
-
from time import monotonic, time
|
|
9
|
-
|
|
10
|
-
from .events import (
|
|
11
|
-
INPUT_END,
|
|
12
|
-
INPUT_EXIT,
|
|
13
|
-
INPUT_LAP,
|
|
14
|
-
INPUT_MINUS,
|
|
15
|
-
INPUT_PAUSE,
|
|
16
|
-
INPUT_PLUS,
|
|
17
|
-
INPUT_RESET,
|
|
18
|
-
)
|
|
19
|
-
from .ticker import Metronome
|
|
20
|
-
from .utils import (
|
|
21
|
-
format_seconds,
|
|
22
|
-
format_seconds_alt,
|
|
23
|
-
format_target,
|
|
24
|
-
parse_timestr,
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def countdown(ui, args):
|
|
29
|
-
target_time = parse_timestr(args.timespec)
|
|
30
|
-
offset = (target_time.microsecond / 1_000_000)
|
|
31
|
-
ticker = Metronome(ui.input_queue, offset=offset)
|
|
32
|
-
ticker.start()
|
|
33
|
-
|
|
34
|
-
while True: # Outer loop to allow restarting countdown from scratch
|
|
35
|
-
while True: # Active countdown loop
|
|
36
|
-
seconds_left = (target_time - datetime.now(timezone.utc)).total_seconds()
|
|
37
|
-
if seconds_left <= 0:
|
|
38
|
-
# If seconds_left is zero or negative, immediately break to handle the
|
|
39
|
-
# "finished" state. This prevents displaying "0" for an entire second
|
|
40
|
-
# while waiting for the next tick.
|
|
41
|
-
break
|
|
42
|
-
|
|
43
|
-
if args.alt_format:
|
|
44
|
-
countdown_text = format_seconds_alt(
|
|
45
|
-
seconds_left, hide_seconds=args.no_seconds
|
|
46
|
-
)
|
|
47
|
-
else:
|
|
48
|
-
countdown_text = format_seconds(
|
|
49
|
-
seconds_left, hide_seconds=args.no_seconds
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
with ui.curses_lock:
|
|
53
|
-
ui.set_window_title(countdown_text)
|
|
54
|
-
if args.outfile:
|
|
55
|
-
with open(args.outfile, "w") as f:
|
|
56
|
-
f.write("{}\n{}\n".format(countdown_text, seconds_left))
|
|
57
|
-
end_text = (
|
|
58
|
-
format_target(
|
|
59
|
-
target_time,
|
|
60
|
-
time_format=args.time_format,
|
|
61
|
-
date_format=args.date_format,
|
|
62
|
-
)
|
|
63
|
-
if args.end
|
|
64
|
-
else None
|
|
65
|
-
)
|
|
66
|
-
color = 0
|
|
67
|
-
if ticker.is_paused:
|
|
68
|
-
color = 3
|
|
69
|
-
elif seconds_left <= args.critical:
|
|
70
|
-
color = 1
|
|
71
|
-
ui.draw_text(countdown_text, color=color, end=end_text)
|
|
72
|
-
|
|
73
|
-
annunciation = None
|
|
74
|
-
if seconds_left <= args.critical:
|
|
75
|
-
annunciation = str(int(ceil(seconds_left))) # Announce whole seconds
|
|
76
|
-
elif int(ceil(seconds_left)) in (5, 10, 20, 30, 60):
|
|
77
|
-
annunciation = "{} {} seconds".format(
|
|
78
|
-
args.voice_prefix, int(ceil(seconds_left))
|
|
79
|
-
)
|
|
80
|
-
elif int(ceil(seconds_left)) in (300, 600, 1800):
|
|
81
|
-
annunciation = "{} {} minutes".format(
|
|
82
|
-
args.voice_prefix, int(seconds_left / 60)
|
|
83
|
-
)
|
|
84
|
-
elif int(ceil(seconds_left)) == 3600:
|
|
85
|
-
annunciation = "{} one hour".format(args.voice_prefix)
|
|
86
|
-
if annunciation or args.exec_cmd:
|
|
87
|
-
if (
|
|
88
|
-
annunciation and args.voice_cmd
|
|
89
|
-
): # Only announce if there is something to say
|
|
90
|
-
Popen(
|
|
91
|
-
[args.voice_cmd, "-v", args.voice, annunciation.strip()],
|
|
92
|
-
stdout=DEVNULL,
|
|
93
|
-
stderr=STDOUT,
|
|
94
|
-
)
|
|
95
|
-
if args.exec_cmd:
|
|
96
|
-
# Pass annunciation even if it's empty, format() handles it.
|
|
97
|
-
Popen(
|
|
98
|
-
args.exec_cmd.format(seconds_left, annunciation or ""),
|
|
99
|
-
stdout=DEVNULL,
|
|
100
|
-
stderr=STDOUT,
|
|
101
|
-
shell=True,
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
input_action = ui.input_queue.get()
|
|
105
|
-
if input_action == INPUT_PAUSE:
|
|
106
|
-
duration = ticker.pause()
|
|
107
|
-
if duration:
|
|
108
|
-
target_time += timedelta(seconds=duration)
|
|
109
|
-
elif input_action == INPUT_EXIT:
|
|
110
|
-
exit(1)
|
|
111
|
-
elif input_action == INPUT_RESET:
|
|
112
|
-
target_time = parse_timestr(args.timespec)
|
|
113
|
-
# Continue the inner loop, seconds_left will be re-evaluated
|
|
114
|
-
continue
|
|
115
|
-
elif input_action == INPUT_PLUS:
|
|
116
|
-
target_time += timedelta(seconds=10)
|
|
117
|
-
elif input_action == INPUT_MINUS:
|
|
118
|
-
target_time -= timedelta(seconds=10)
|
|
119
|
-
elif input_action == INPUT_END:
|
|
120
|
-
args.end = not args.end
|
|
121
|
-
continue
|
|
122
|
-
|
|
123
|
-
# After the active countdown loop, handle the "time is up" state.
|
|
124
|
-
|
|
125
|
-
if not args.no_bell:
|
|
126
|
-
with ui.curses_lock:
|
|
127
|
-
beep()
|
|
128
|
-
|
|
129
|
-
if args.outfile:
|
|
130
|
-
with open(args.outfile, "w") as f:
|
|
131
|
-
f.write("{}\n{}\n".format(args.text if args.text else "DONE", 0))
|
|
132
|
-
|
|
133
|
-
if args.blink or args.text:
|
|
134
|
-
base_color = 1 if args.blink else 0
|
|
135
|
-
flip = True
|
|
136
|
-
ticker.pause() # Pause ticker during blinking phase
|
|
137
|
-
while True:
|
|
138
|
-
if args.quit_after and (
|
|
139
|
-
datetime.now(timezone.utc) - target_time
|
|
140
|
-
).total_seconds() > float(args.quit_after):
|
|
141
|
-
return
|
|
142
|
-
with ui.curses_lock:
|
|
143
|
-
ui.set_window_title("/" if flip else "\\")
|
|
144
|
-
if args.text:
|
|
145
|
-
ui.draw_text(args.text, color=base_color if flip else 4)
|
|
146
|
-
else:
|
|
147
|
-
ui.draw_text("", color=base_color if flip else 4)
|
|
148
|
-
if args.blink:
|
|
149
|
-
flip = not flip
|
|
150
|
-
try:
|
|
151
|
-
input_action = ui.input_queue.get(True, 0.5)
|
|
152
|
-
except Empty:
|
|
153
|
-
input_action = None
|
|
154
|
-
if input_action == INPUT_EXIT:
|
|
155
|
-
return
|
|
156
|
-
elif input_action == INPUT_RESET:
|
|
157
|
-
target_time = parse_timestr(args.timespec)
|
|
158
|
-
ticker.pause() # resume
|
|
159
|
-
break # Break out of the blinking loop to restart the main countdown
|
|
160
|
-
else:
|
|
161
|
-
break
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def clock(ui, args):
|
|
165
|
-
time_started = monotonic()
|
|
166
|
-
seconds_elapsed = 0
|
|
167
|
-
offset = timedelta(0)
|
|
168
|
-
ticker = Metronome(ui.input_queue)
|
|
169
|
-
ticker.start()
|
|
170
|
-
while True:
|
|
171
|
-
seconds_elapsed = monotonic() - time_started
|
|
172
|
-
if args.quit_after and seconds_elapsed >= float(args.quit_after):
|
|
173
|
-
return
|
|
174
|
-
clock_text = (datetime.now() + offset).strftime(args.time_format)
|
|
175
|
-
with ui.curses_lock:
|
|
176
|
-
ui.set_window_title(clock_text)
|
|
177
|
-
|
|
178
|
-
if args.outfile:
|
|
179
|
-
with open(args.outfile, "w") as f:
|
|
180
|
-
f.write("{}\n{}\n".format(clock_text, seconds_elapsed))
|
|
181
|
-
|
|
182
|
-
ui.draw_text(clock_text, color=3 if ticker.is_paused else 0)
|
|
183
|
-
|
|
184
|
-
input_action = ui.input_queue.get()
|
|
185
|
-
if input_action == INPUT_EXIT:
|
|
186
|
-
break
|
|
187
|
-
if input_action == INPUT_PLUS:
|
|
188
|
-
offset += timedelta(seconds=10)
|
|
189
|
-
elif input_action == INPUT_MINUS:
|
|
190
|
-
offset -= timedelta(seconds=10)
|
|
191
|
-
elif input_action == INPUT_PAUSE:
|
|
192
|
-
ticker.pause()
|
|
193
|
-
elif input_action == INPUT_RESET:
|
|
194
|
-
offset = timedelta(0)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def stopwatch(ui, args):
|
|
198
|
-
time_started = monotonic()
|
|
199
|
-
ticker = Metronome(ui.input_queue, offset=time() % 1.0)
|
|
200
|
-
ticker.start()
|
|
201
|
-
time_paused = None
|
|
202
|
-
seconds_elapsed = 0
|
|
203
|
-
laps = []
|
|
204
|
-
while True:
|
|
205
|
-
if not time_paused:
|
|
206
|
-
seconds_elapsed = monotonic() - time_started
|
|
207
|
-
else:
|
|
208
|
-
seconds_elapsed = time_paused - time_started
|
|
209
|
-
|
|
210
|
-
if args.quit_after and seconds_elapsed >= float(args.quit_after):
|
|
211
|
-
return seconds_elapsed, laps
|
|
212
|
-
|
|
213
|
-
if args.alt_format:
|
|
214
|
-
stopwatch_text = format_seconds_alt(
|
|
215
|
-
round(seconds_elapsed), hide_seconds=args.no_seconds
|
|
216
|
-
)
|
|
217
|
-
else:
|
|
218
|
-
stopwatch_text = format_seconds(
|
|
219
|
-
round(seconds_elapsed), hide_seconds=args.no_seconds
|
|
220
|
-
)
|
|
221
|
-
with ui.curses_lock:
|
|
222
|
-
if not args.no_window_title:
|
|
223
|
-
os.write(
|
|
224
|
-
stdout.fileno(),
|
|
225
|
-
"\033]2;{0}\007".format(stopwatch_text).encode(),
|
|
226
|
-
)
|
|
227
|
-
if args.outfile:
|
|
228
|
-
with open(args.outfile, "w") as f:
|
|
229
|
-
f.write("{}\n{}\n".format(stopwatch_text, seconds_elapsed))
|
|
230
|
-
|
|
231
|
-
ui.draw_text(stopwatch_text, color=3 if ticker.is_paused else 0)
|
|
232
|
-
|
|
233
|
-
annunciation = None
|
|
234
|
-
if int(seconds_elapsed) <= args.critical and seconds_elapsed >= 1:
|
|
235
|
-
annunciation = str(int(seconds_elapsed))
|
|
236
|
-
elif int(seconds_elapsed) in (5, 10, 20, 30, 40, 50, 60):
|
|
237
|
-
annunciation = "{} {} seconds".format(
|
|
238
|
-
args.voice_prefix, int(seconds_elapsed)
|
|
239
|
-
)
|
|
240
|
-
elif int(seconds_elapsed) in (120, 180, 300, 600, 1800):
|
|
241
|
-
annunciation = "{} {} minutes".format(
|
|
242
|
-
args.voice_prefix, int(seconds_elapsed / 60)
|
|
243
|
-
)
|
|
244
|
-
elif int(seconds_elapsed) == 3600:
|
|
245
|
-
annunciation = "{} one hour".format(args.voice_prefix)
|
|
246
|
-
elif int(seconds_elapsed) % 3600 == 0 and seconds_elapsed > 1:
|
|
247
|
-
annunciation = "{} {} hours".format(
|
|
248
|
-
args.voice_prefix, int(seconds_elapsed / 3600)
|
|
249
|
-
)
|
|
250
|
-
|
|
251
|
-
if annunciation and args.voice_cmd:
|
|
252
|
-
Popen(
|
|
253
|
-
[args.voice_cmd, "-v", args.voice, annunciation.strip()],
|
|
254
|
-
stdout=DEVNULL,
|
|
255
|
-
stderr=STDOUT,
|
|
256
|
-
)
|
|
257
|
-
if args.exec_cmd:
|
|
258
|
-
Popen(
|
|
259
|
-
args.exec_cmd.format(seconds_elapsed, annunciation or ""),
|
|
260
|
-
stdout=DEVNULL,
|
|
261
|
-
stderr=STDOUT,
|
|
262
|
-
shell=True,
|
|
263
|
-
)
|
|
264
|
-
|
|
265
|
-
input_action = ui.input_queue.get()
|
|
266
|
-
if input_action == INPUT_PLUS:
|
|
267
|
-
time_started -= 10
|
|
268
|
-
elif input_action == INPUT_MINUS:
|
|
269
|
-
time_started += 10
|
|
270
|
-
elif input_action == INPUT_PAUSE:
|
|
271
|
-
duration = ticker.pause()
|
|
272
|
-
if duration: # unpaused
|
|
273
|
-
time_started += duration
|
|
274
|
-
time_paused = None
|
|
275
|
-
else:
|
|
276
|
-
time_paused = monotonic()
|
|
277
|
-
elif input_action == INPUT_EXIT:
|
|
278
|
-
break
|
|
279
|
-
elif input_action == INPUT_RESET:
|
|
280
|
-
laps = []
|
|
281
|
-
time_started = monotonic()
|
|
282
|
-
elif input_action == INPUT_LAP:
|
|
283
|
-
lap_time = monotonic()
|
|
284
|
-
laps.append(lap_time - time_started)
|
|
285
|
-
time_started = lap_time
|
|
286
|
-
|
|
287
|
-
return (monotonic() - time_started, laps)
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
from math import ceil
|
|
2
|
-
from threading import Thread
|
|
3
|
-
from time import monotonic, sleep, time
|
|
4
|
-
|
|
5
|
-
from .events import TIME_TICK
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class Metronome:
|
|
9
|
-
"""
|
|
10
|
-
Will put a TIME_TICK event into the given queue every full second, delayed by offset.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
def __init__(self, queue, offset=0):
|
|
14
|
-
self._offset = offset
|
|
15
|
-
self._queue = queue
|
|
16
|
-
self._thread = Thread(target=self._run, daemon=True)
|
|
17
|
-
self._pause_time = None
|
|
18
|
-
|
|
19
|
-
def _run(self):
|
|
20
|
-
while True:
|
|
21
|
-
# Calculate the time to sleep until the next full second
|
|
22
|
-
current_time = time()
|
|
23
|
-
target_time = ceil(current_time) + self._offset
|
|
24
|
-
sleep_time = target_time - current_time
|
|
25
|
-
|
|
26
|
-
# If sleep_time is very small (e.g., we're just before the second),
|
|
27
|
-
# we should wait for the next second.
|
|
28
|
-
if sleep_time < 0.001: # Small buffer to avoid missing the second
|
|
29
|
-
sleep_time += 1.0
|
|
30
|
-
|
|
31
|
-
sleep(sleep_time)
|
|
32
|
-
if not self.is_paused:
|
|
33
|
-
self._queue.put(TIME_TICK)
|
|
34
|
-
|
|
35
|
-
def start(self):
|
|
36
|
-
self._thread.start()
|
|
37
|
-
|
|
38
|
-
def pause(self):
|
|
39
|
-
if self.is_paused: # unpause
|
|
40
|
-
duration = monotonic() - self._pause_time
|
|
41
|
-
self._pause_time = None
|
|
42
|
-
if self._offset is not None:
|
|
43
|
-
# If we're not running in clock mode, change the offset so the
|
|
44
|
-
# next tick will be 1s from now.
|
|
45
|
-
self._offset = time() % 1.0
|
|
46
|
-
return duration
|
|
47
|
-
else: # pause
|
|
48
|
-
self._pause_time = monotonic()
|
|
49
|
-
|
|
50
|
-
@property
|
|
51
|
-
def is_paused(self):
|
|
52
|
-
return self._pause_time is not None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|