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.
@@ -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,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: termdown
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: Countdown timer for your terminal
5
5
  Project-URL: Homepage, https://github.com/trehn/termdown
6
6
  Project-URL: Repository, https://github.com/trehn/termdown
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "termdown"
7
- version = "2.0.0"
7
+ version = "2.0.1"
8
8
  description = "Countdown timer for your terminal"
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-only"
@@ -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
- raise RuntimeError("--exec-cmd and --voice are mutually exclusive")
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
- raise RuntimeError("File already exists: {}".format(args.outfile))
274
+ parser.error("file already exists: {}".format(args.outfile))
231
275
  if not os.access(dirname(abspath(args.outfile)), os.W_OK):
232
- raise RuntimeError("Unable to write file: {}".format(args.outfile))
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("Error: Could not load font from {font_path}")
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
- try:
110
- with self.curses_lock:
111
- key = self.stdscr.getkey()
112
- except Exception:
113
- key = None
114
- if key in ("q", "Q"):
115
- self.input_queue.put(INPUT_EXIT)
116
- elif key == " ":
117
- self.input_queue.put(INPUT_PAUSE)
118
- elif key in ("e", "E"):
119
- self.input_queue.put(INPUT_END)
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
- target = datetime.now(timezone.utc) + timedelta(seconds=timedelta_secs)
135
- elif timestr.isdigit():
136
- target = datetime.now(timezone.utc) + timedelta(seconds=int(timestr))
137
- else:
138
- try:
139
- target = parse(timestr)
140
- except Exception:
141
- # unfortunately, dateutil doesn't raise the best exceptions
142
- raise ValueError("Unable to parse '{}'".format(timestr))
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 += timedelta(days=1)
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:
@@ -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