terminal-clock 0.2.1__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.
pyclock.py ADDED
@@ -0,0 +1,2322 @@
1
+ #!/usr/bin/env python3
2
+ """Live terminal clock: one analog face per time zone, digital underneath.
3
+
4
+ Faces are drawn on a braille canvas (2x4 dots per cell). Refreshes every 19ms,
5
+ so the second hand sweeps smoothly rather than stepping. Press q (or Ctrl+C)
6
+ to quit.
7
+ """
8
+
9
+ import collections
10
+ import contextlib
11
+ import math
12
+ import os
13
+ import signal
14
+ import sys
15
+ import time
16
+ from datetime import datetime, timedelta, timezone
17
+ from zoneinfo import ZoneInfo
18
+
19
+ # ZIP resolution is a pair of tables with a release cycle of its own -- new
20
+ # gazetteer, redrawn boundaries -- so it lives in ziptz, a module installable
21
+ # and usable on its own. Absent, the clock is the same program minus ZIP
22
+ # tokens, which is worth more than refusing to start over a feature most
23
+ # invocations never reach; clock.go links the Go package beside it in at build
24
+ # time and so cannot make the same offer.
25
+ #
26
+ # The attribute lookup is the check that matters: a bare directory named ziptz
27
+ # anywhere on sys.path imports as an empty namespace package instead of
28
+ # failing, and an empty one answers no ZIP codes at all.
29
+ try:
30
+ import ziptz
31
+
32
+ ziptz.location
33
+ except (ImportError, AttributeError): # pragma: no cover - ziptz not installed
34
+ ziptz = None
35
+
36
+ # The clock needs a POSIX terminal: cbreak mode via termios, and select() on
37
+ # stdin. Neither exists on Windows, where select() handles sockets only. Say so
38
+ # instead of dying in an import traceback; clock.go prints the same line.
39
+ try:
40
+ import select
41
+ import termios
42
+ except ImportError: # pragma: no cover - Windows only
43
+ sys.stderr.write(
44
+ "clock: Windows is not supported (needs a POSIX terminal); try WSL\n"
45
+ )
46
+ raise SystemExit(1)
47
+
48
+ HIDE_CURSOR = "\x1b[?25l"
49
+ SHOW_CURSOR = "\x1b[?25h"
50
+ CLEAR_EOL = "\x1b[K"
51
+ CLEAR_BELOW = "\x1b[J"
52
+ HOME = "\x1b[H"
53
+ ENTER_ALT = "\x1b[?1049h"
54
+ LEAVE_ALT = "\x1b[?1049l"
55
+
56
+ # The release this source belongs to. clock.go and pyproject.toml carry the
57
+ # same string, and difftest holds all three together: a clock that cannot say
58
+ # what it is turns every bug report into a round trip, and one that says the
59
+ # wrong thing is worse than one that says nothing at all.
60
+ VERSION = "0.2.1"
61
+
62
+ # What both ports exit with when the reader goes away -- `clock | head`. 128
63
+ # plus SIGPIPE, which is what a shell reports for a filter that died of it;
64
+ # see main(), and pipeStatus in clock.go.
65
+ PIPE_STATUS = 141
66
+
67
+ USAGE = """clock - analog terminal clocks
68
+
69
+ usage: clock [-n N | --per-row N] [--color[=WHEN]] [--day[=WHEN]] [-q | --quiet]
70
+ [--halign WHERE] [--valign WHERE] [--hpad SPACE] [--vpad SPACE]
71
+ [--cell-ratio N] [--scale N] [ZONES]
72
+
73
+ ZONES comma-separated zone names, described below; default is
74
+ your local zone
75
+ -n, --per-row N clocks per row before wrapping, reduced to fit; auto
76
+ (default) picks whatever grows --scale auto the most
77
+ --color[=WHEN] colour the hands: always, auto (default), never or off
78
+ --no-color same as --color=never
79
+ --day[=WHEN] weekday on the readout: always, auto (default), never
80
+ --no-day same as --day=never
81
+ -q, --quiet skip the "press q to quit" hint shown at startup
82
+ --halign WHERE the grid across the window: left, center (default), right
83
+ --valign WHERE the grid down the window: top, center (default), bottom
84
+ --hpad SPACE between clocks: even (default), or a share of the width
85
+ like 10%
86
+ --vpad SPACE between rows: even (default), or a share of the height
87
+ like 5%
88
+ --cell-ratio N font cell height / width (default 2.1); raise if the
89
+ face looks squished, lower if it bulges sideways
90
+ --scale N resize every face by this factor; auto (default) fills
91
+ the window, at minimum padding
92
+ -h, --help this message
93
+ --version print the version and exit
94
+
95
+ A zone is an IANA name (Europe/Berlin), the city off the end of one where
96
+ that is unambiguous (Berlin, Jakarta), a regional abbreviation (ET CT MT PT
97
+ AKT HT BST IST JST AET ...), a 2-letter country code (JP, GB), or a US ZIP
98
+ code (94110). ET/CT/MT/PT follow daylight saving, so they read EST or EDT
99
+ depending on the date; EST/EDT/PST/PDT and the rest are the fixed offsets,
100
+ which never shift.
101
+
102
+ The hands are coloured on a terminal and plain when redirected; NO_COLOR
103
+ turns the colour off everywhere. Auto puts a weekday on the readouts only
104
+ when the clocks on screen disagree about the date. An even fill spreads the
105
+ clocks over the whole window; --hpad 10% sets the gaps instead, as a share of
106
+ the window, and then the alignment decides where the grid sits. CLOCK_CELL_RATIO
107
+ sets the same thing as --cell-ratio, for when it wants to be set once per
108
+ terminal rather than typed every time; the flag wins if both are given.
109
+
110
+ Space holds the frame still, for a screenshot, and h or ? opens the key list.
111
+ Press q or Ctrl+C to quit.
112
+
113
+ examples:
114
+ clock
115
+ clock ET,PT,UTC
116
+ clock -n 2 ET,PT,UTC
117
+ clock Berlin,Jakarta
118
+ clock Europe/Berlin,Asia/Tokyo,94110 --per-row 2
119
+ """
120
+
121
+ # The key list h or ? puts up in a modal. In the order the keys are reached
122
+ # for rather than alphabetically, and kept in the same order as clock.go's
123
+ # table.
124
+ HOTKEYS = (
125
+ ("space", "hold the frame"),
126
+ ("h ?", "toggle this list"),
127
+ ("q", "quit, or Ctrl+C"),
128
+ )
129
+ HOTKEY_COL = 8 # where the descriptions start, so the keys get a gutter
130
+
131
+ # Said once, in the same modal the key list uses, and then dropped: a clock
132
+ # that has taken the whole screen owes the reader a way back out, but only
133
+ # until it is read. -q/--quiet skips it outright.
134
+ FLASH = "Press q or Ctrl+C to quit"
135
+ FLASH_SECONDS = 3.0
136
+
137
+ DEFAULT_PER_ROW = 3 # faces per row before wrapping
138
+ MAX_PER_ROW = 64 # an upper bound so a typo can't ask for a million faces
139
+
140
+ # 19ms, not 20: coprime to 10, so the millisecond ones digit cycles through
141
+ # all ten values instead of sitting still. Reads as a live clock.
142
+ TICK = 0.019
143
+
144
+ DEFAULT_ROWS_N = 11 # face height, in terminal rows, at --scale 1
145
+ DEFAULT_CELL_RATIO = 2.1 # cell height / width; braille dots are square at 2
146
+
147
+ # ROWS keeps a face big enough for the hour numerals to have somewhere to
148
+ # sit; MAX_ROWS_N is just a guard against a typo asking for a giant canvas.
149
+ MIN_ROWS_N = 4
150
+ MAX_ROWS_N = 200
151
+
152
+ # How many rows below the largest fit auto_scale will give up looking for
153
+ # one where both ROWS and COLS are odd -- see its own comment for why that
154
+ # is worth a few rows of size.
155
+ SYMMETRY_WINDOW = 8
156
+
157
+ # ROWS, CELL_RATIO and COLS start at the defaults and are set for real in
158
+ # run(), once --scale, --cell-ratio and CLOCK_CELL_RATIO have been read;
159
+ # nothing touches any of them before then.
160
+ ROWS = DEFAULT_ROWS_N
161
+ CELL_RATIO = DEFAULT_CELL_RATIO
162
+ COLS = math.floor(ROWS * DEFAULT_CELL_RATIO + 0.5) # face width, in terminal columns
163
+
164
+ # "02:53:07.123" -- what digital() writes under every face, and the narrowest a
165
+ # face's column can be however small the face itself gets. A face is drawn to
166
+ # whatever COLS the scale asks for, but the readout underneath is a fixed
167
+ # twelve characters and cannot be shrunk, so the *cell* a face occupies is the
168
+ # wider of the two. Without this a narrow enough window lays out by face width
169
+ # and then writes a readout straight past the right edge -- which wraps, and a
170
+ # wrapped line desynchronises the rewind exactly as fit_per_row exists to
171
+ # prevent. The weekday is the same problem solved the other way: at DAY_COLS it
172
+ # is dropped rather than widening every cell to hold it.
173
+ READOUT_COLS = 12
174
+ GAP = 3 # fewest blank columns between adjacent faces
175
+ VGAP = 1 # fewest blank rows between rows of faces
176
+
177
+
178
+ # The characters a number may be spelled with here, which is the intersection
179
+ # of what the two languages read rather than what either offers. float() takes
180
+ # surrounding whitespace and non-ASCII digits -- "\u0661" and "\uff11" are both
181
+ # one to it -- where Go's ParseFloat takes neither; ParseFloat takes a
182
+ # hexadecimal float, "0x1p2", where float() does not. Every one of those was a
183
+ # clock drawn by one implementation and a complaint printed by the other. What
184
+ # is left after this is read identically by both, underscores and exponents
185
+ # included, so the parse itself can still be each language's own.
186
+ NUMBER_CHARS = frozenset("0123456789+-._eE")
187
+
188
+ # A ceiling on the two knobs that scale a face, which is not about taste: the
189
+ # face's width is an integer derived from them, and Python's integers are
190
+ # unbounded where Go's are 64 bits. At --cell-ratio 1e19 one clock face needed
191
+ # 400000000000000000000 columns here and 9223372036854775807 there -- the same
192
+ # refusal, in two different numbers. A million is past any font's aspect ratio
193
+ # and any terminal's width, and leaves the arithmetic identical either side.
194
+ NUMBER_MAX = 1000000
195
+
196
+
197
+ def positive_number(val):
198
+ """A positive, finite decimal out of a string, or None.
199
+
200
+ --scale, --cell-ratio and CLOCK_CELL_RATIO are the same question asked
201
+ three times; this is the one answer, so a value one of them takes cannot
202
+ be a value another refuses.
203
+ """
204
+ if not val or not NUMBER_CHARS.issuperset(val):
205
+ return None
206
+ try:
207
+ value = float(val)
208
+ except ValueError:
209
+ return None
210
+ if math.isnan(value) or math.isinf(value) or value <= 0 or value > NUMBER_MAX:
211
+ return None
212
+ return value
213
+
214
+
215
+ def env_cell_ratio():
216
+ """How tall a terminal cell is relative to its width, from CLOCK_CELL_RATIO.
217
+
218
+ This is the only knob that decides whether the face is round, and it varies
219
+ by font and line spacing. Override without editing: CLOCK_CELL_RATIO=2.7
220
+ Raise it if the face looks squished, lower it if it bulges sideways.
221
+
222
+ Unlike --cell-ratio, an environment variable might be stale or set for
223
+ some other program, so a bad value is not a user error -- it is simply
224
+ ignored, the same way an unset one is.
225
+ """
226
+ value = positive_number(os.environ.get("CLOCK_CELL_RATIO", ""))
227
+ return DEFAULT_CELL_RATIO if value is None else value
228
+
229
+
230
+ def parse_ratio(val):
231
+ """Read a positive, finite decimal for --cell-ratio."""
232
+ value = positive_number(val)
233
+ if value is None:
234
+ raise ClockError(
235
+ "--cell-ratio wants a positive number up to 1000000, "
236
+ f'e.g. --cell-ratio 2.6, got "{val}"'
237
+ )
238
+ return value
239
+
240
+
241
+ def parse_scale(val):
242
+ """Read a positive, finite decimal for --scale."""
243
+ value = positive_number(val)
244
+ if value is None:
245
+ raise ClockError(
246
+ "--scale wants auto or a positive number up to 1000000, "
247
+ f'e.g. --scale 1.5, got "{val}"'
248
+ )
249
+ return value
250
+
251
+ # Where the grid sits when it does not fill the window, and what --halign and
252
+ # --valign accept. Same order as clock.go's tables, and the wording of the
253
+ # error they raise comes off these lists.
254
+ HALIGNS = ("left", "center", "right")
255
+ VALIGNS = ("top", "center", "bottom")
256
+
257
+ # A day inside datetime's range at each end. The pinned instant is converted
258
+ # into every zone on screen, and a zone can sit 14 hours from UTC, so an
259
+ # instant on datetime.min itself overflows the moment it is shown in Los
260
+ # Angeles -- where Go's time, which has no such bound, draws it without
261
+ # comment. A day of headroom is more than the 14 hours anywhere is away.
262
+ FREEZE_FIRST = datetime(1, 1, 2, tzinfo=timezone.utc)
263
+ FREEZE_LAST = datetime(9999, 12, 30, 23, 59, 59, 999999, tzinfo=timezone.utc)
264
+
265
+
266
+ class ClockError(Exception):
267
+ """A startup failure to report before the terminal has been touched."""
268
+
269
+
270
+ def _freeze_digits(s):
271
+ """s read as an unsigned decimal integer, or None if it is not one.
272
+
273
+ Not str.isdigit(), which is true of "١" and "1" as well as
274
+ "1": the two ports have to accept exactly the same strings, so every
275
+ byte is checked against the ASCII range by hand, the same way
276
+ parse_count and parse_pad already do.
277
+ """
278
+ if not s or not all("0" <= c <= "9" for c in s):
279
+ return None
280
+ return int(s)
281
+
282
+
283
+ def _parse_freeze_instant(value):
284
+ """The strict ISO instant CLOCK_FREEZE accepts, or None if value is not
285
+ one: a four-digit year, two-digit month and day, two-digit hour, minute
286
+ and second, an optional one-to-six-digit fraction, and a literal Z.
287
+
288
+ Hand-scanned rather than handed to strptime -- which takes fewer than six
289
+ fractional digits, where Go's time.Parse takes a one-digit month -- so
290
+ that the shape accepted is controlled entirely in this file, and
291
+ clock.go's version of this function can be made to agree with it
292
+ deliberately rather than by coincidence.
293
+
294
+ Not range-checked field by field either: the parsed numbers are handed to
295
+ datetime, whose constructor raises on a day like 30 in February, the same
296
+ way Go's time.Date -- which does not raise, only normalizes such a day
297
+ into March -- is made to catch it by reading the fields back and finding
298
+ them changed.
299
+ """
300
+ if len(value) < 20 or value[-1] != "Z":
301
+ return None
302
+ core = value[:-1]
303
+ frac_digits = ""
304
+ if len(core) == 19:
305
+ pass
306
+ elif 21 <= len(core) <= 26 and core[19] == ".":
307
+ frac_digits = core[20:]
308
+ core = core[:19]
309
+ else:
310
+ return None
311
+ if core[4] != "-" or core[7] != "-" or core[10] != "T" or core[13] != ":" or core[16] != ":":
312
+ return None
313
+ year = _freeze_digits(core[0:4])
314
+ month = _freeze_digits(core[5:7])
315
+ day = _freeze_digits(core[8:10])
316
+ hour = _freeze_digits(core[11:13])
317
+ minute = _freeze_digits(core[14:16])
318
+ second = _freeze_digits(core[17:19])
319
+ if None in (year, month, day, hour, minute, second):
320
+ return None
321
+ frac = _freeze_digits(frac_digits) if frac_digits else 0
322
+ if frac is None:
323
+ return None
324
+ microsecond = frac * 10 ** (6 - len(frac_digits))
325
+ try:
326
+ return datetime(year, month, day, hour, minute, second, microsecond, tzinfo=timezone.utc)
327
+ except ValueError:
328
+ return None
329
+
330
+
331
+ def _parse_freeze_clock(s):
332
+ """The HH[:MM[:SS]] half of _parse_freeze_clock_utc, or None if s is not
333
+ one.
334
+ """
335
+ fields = s.split(":")
336
+ if len(fields) > 3 or not (1 <= len(fields[0]) <= 2):
337
+ return None
338
+ hour = _freeze_digits(fields[0])
339
+ if hour is None or hour > 23:
340
+ return None
341
+ minute = second = 0
342
+ if len(fields) >= 2:
343
+ if len(fields[1]) != 2:
344
+ return None
345
+ minute = _freeze_digits(fields[1])
346
+ if minute is None or minute > 59:
347
+ return None
348
+ if len(fields) == 3:
349
+ if len(fields[2]) != 2:
350
+ return None
351
+ second = _freeze_digits(fields[2])
352
+ if second is None or second > 59:
353
+ return None
354
+ return hour, minute, second
355
+
356
+
357
+ def _parse_freeze_date(s):
358
+ """The date ahead of a clock time -- "2026-07-22", "2026/07/22", "7/22"
359
+ or "8/22/26" -- as (year, month, day, has_year), or None if s is not one.
360
+
361
+ The two separators inside one date must be the same character; mixing
362
+ them, as in "2026-07/22", falls out of the two-field case rather than
363
+ being caught on purpose, since the year then reads as a two-digit month
364
+ and is refused for being neither.
365
+
366
+ Three fields read year-month-day when the first is four digits -- the
367
+ only length a year is ever spelled with here -- and month-day-year
368
+ otherwise, with the year itself two digits (2000 added) or four. A first
369
+ field of three digits is neither and is refused either way.
370
+ """
371
+ sep = next((c for c in s if c in "-/"), None)
372
+ if sep is None:
373
+ return None
374
+ fields = s.split(sep)
375
+ if len(fields) == 2:
376
+ if not (1 <= len(fields[0]) <= 2) or not (1 <= len(fields[1]) <= 2):
377
+ return None
378
+ month = _freeze_digits(fields[0])
379
+ day = _freeze_digits(fields[1])
380
+ if month is None or day is None:
381
+ return None
382
+ return None, month, day, False
383
+ if len(fields) != 3:
384
+ return None
385
+ if len(fields[0]) == 4:
386
+ if not (1 <= len(fields[1]) <= 2) or not (1 <= len(fields[2]) <= 2):
387
+ return None
388
+ year = _freeze_digits(fields[0])
389
+ month = _freeze_digits(fields[1])
390
+ day = _freeze_digits(fields[2])
391
+ if None in (year, month, day):
392
+ return None
393
+ return year, month, day, True
394
+ if len(fields[0]) in (1, 2):
395
+ if not (1 <= len(fields[1]) <= 2) or len(fields[2]) not in (2, 4):
396
+ return None
397
+ month = _freeze_digits(fields[0])
398
+ day = _freeze_digits(fields[1])
399
+ year = _freeze_digits(fields[2])
400
+ if None in (month, day, year):
401
+ return None
402
+ if len(fields[2]) == 2:
403
+ year += 2000
404
+ return year, month, day, True
405
+ return None
406
+
407
+
408
+ def _freeze_valid_calendar_date(year, month, day):
409
+ """Whether year-month-day is a real date, independent of any zone -- day
410
+ 30 of February is not, whatever clock time or zone rides along with it.
411
+ """
412
+ try:
413
+ datetime(year, month, day)
414
+ return True
415
+ except ValueError:
416
+ return False
417
+
418
+
419
+ def _freeze_reproduces(t, zone, year, month, day, hour, minute, second):
420
+ """Whether t, read back in zone, is exactly the wall clock
421
+ _freeze_local_to_utc was asked to convert.
422
+ """
423
+ lt = in_zone(t, zone)
424
+ return (lt.year, lt.month, lt.day, lt.hour, lt.minute, lt.second) == (
425
+ year,
426
+ month,
427
+ day,
428
+ hour,
429
+ minute,
430
+ second,
431
+ )
432
+
433
+
434
+ def _freeze_local_to_utc(zone, year, month, day, hour, minute, second):
435
+ """A wall-clock reading -- already known to be a real calendar date --
436
+ in zone, converted to the UTC instant it names, deciding a
437
+ daylight-saving edge case by hand rather than leaning on datetime's own
438
+ default: zoneinfo's fold and Go's time.Date do not agree with each other
439
+ on a reading a spring-forward skips entirely, so this is the one place
440
+ that disagreement could reach the frame drawn, and both ports implement
441
+ this same explicit rule instead of either default.
442
+
443
+ The offset a full day before and a full day after settle it. Equal,
444
+ there is no transition anywhere near this reading and the obvious
445
+ instant is the answer -- true on all but at most two calls a year, per
446
+ zone. Unequal, one transition sits somewhere in that two-day window;
447
+ both candidate instants, one built from each offset, are checked by
448
+ converting back into zone and comparing against what was asked for. Both
449
+ matching is a fall-back reading that happened twice, resolved to the
450
+ earlier of the two -- the offset still in effect right up to the
451
+ transition. Neither matching is a spring-forward reading that never
452
+ happened at all, resolved as though the spring-forward had already gone
453
+ -- the later offset.
454
+ """
455
+ naive = datetime(year, month, day, hour, minute, second, tzinfo=timezone.utc)
456
+ off_prev = utc_offset(naive - timedelta(days=1), zone)
457
+ off_next = utc_offset(naive + timedelta(days=1), zone)
458
+ if off_prev == off_next:
459
+ return naive - timedelta(seconds=off_prev)
460
+ cand_prev = naive - timedelta(seconds=off_prev)
461
+ cand_next = naive - timedelta(seconds=off_next)
462
+ valid_prev = _freeze_reproduces(cand_prev, zone, year, month, day, hour, minute, second)
463
+ valid_next = _freeze_reproduces(cand_next, zone, year, month, day, hour, minute, second)
464
+ if valid_prev and valid_next:
465
+ return cand_prev if cand_prev < cand_next else cand_next
466
+ if valid_prev:
467
+ return cand_prev
468
+ return cand_next
469
+
470
+
471
+ def _freeze_date(zone, year, month, day, hour, minute, second):
472
+ """One specific instant in zone, or None if the day does not exist in
473
+ that month -- day 30 of February, say -- checked before asking what UTC
474
+ instant it names in that zone.
475
+ """
476
+ if not _freeze_valid_calendar_date(year, month, day):
477
+ return None
478
+ return _freeze_local_to_utc(zone, year, month, day, hour, minute, second)
479
+
480
+
481
+ def _freeze_closest_day(now, zone, hour, minute, second):
482
+ """An undated clock time in zone, resolved to whichever of yesterday,
483
+ today or tomorrow -- by zone's own calendar, not UTC's -- lands closest
484
+ to now. A tie favors today: today is checked first and only a strictly
485
+ closer candidate replaces it.
486
+ """
487
+ local_now = in_zone(now, zone)
488
+ y, m, d = local_now.year, local_now.month, local_now.day
489
+ best = _freeze_local_to_utc(zone, y, m, d, hour, minute, second)
490
+ best_diff = abs(best - now)
491
+ base_date = datetime(y, m, d, tzinfo=timezone.utc) # a pure calendar calculator
492
+ for days in (-1, 1):
493
+ cd = base_date + timedelta(days=days)
494
+ candidate = _freeze_local_to_utc(zone, cd.year, cd.month, cd.day, hour, minute, second)
495
+ diff = abs(candidate - now)
496
+ if diff < best_diff:
497
+ best, best_diff = candidate, diff
498
+ return best
499
+
500
+
501
+ # How far from now's year _freeze_closest_year looks for a year the given
502
+ # month and day exist in. Only February 29 can be missing from a year at
503
+ # all, and the longest it is ever missing for is eight years -- 1900 was not
504
+ # a leap year, between 1896 and 1904, which both were -- so searching this
505
+ # far always finds a February 29 if the true closest one lies outside the
506
+ # plain +-1 year that every other date is already found within.
507
+ _FREEZE_YEAR_DELTAS = (0, -1, 1, -2, 2, -3, 3, -4, 4, -5, 5, -6, 6, -7, 7, -8, 8)
508
+
509
+
510
+ def _freeze_closest_year(now, zone, month, day, hour, minute, second):
511
+ """A clock time in zone on a month and day with no year, resolved to
512
+ whichever year, among _FREEZE_YEAR_DELTAS away from now's year in
513
+ zone's own calendar, lands closest to now -- skipping a year the day
514
+ does not exist in, which for any month and day but February 29 is none
515
+ of them. None only if no year in range has the day, which for every
516
+ month and day but February 29 means the date does not exist regardless
517
+ of year.
518
+ """
519
+ y = in_zone(now, zone).year
520
+ best = best_diff = None
521
+ for delta in _FREEZE_YEAR_DELTAS:
522
+ cy = y + delta
523
+ if not _freeze_valid_calendar_date(cy, month, day):
524
+ continue
525
+ candidate = _freeze_local_to_utc(zone, cy, month, day, hour, minute, second)
526
+ diff = abs(candidate - now)
527
+ if best is None or diff < best_diff:
528
+ best, best_diff = candidate, diff
529
+ return best
530
+
531
+
532
+ def _parse_freeze_clock_zone(value, now):
533
+ """A clock time in some zone, with an optional date ahead of it --
534
+ "15:30 UTC", "15:30 PT", "2026-07-22 15:30 UTC", "2026/07/22 15:30 UTC"
535
+ or "7/22 15:30 PT" -- or None if value is not shaped like this format at
536
+ all. The zone is anything resolve_zone accepts: an alias, an IANA name,
537
+ a fixed offset abbreviation, a country code, a city -- and resolve_zone
538
+ raises its own ClockError, which is left to propagate rather than
539
+ caught, once a date and a clock have already parsed and the trailing
540
+ word is clearly meant as a zone.
541
+
542
+ Fills in whatever the date left out: no date at all leaves the day
543
+ itself open, and a date with no year leaves the year open. What is left
544
+ open resolves to whichever candidate, by the wall clock right now, lands
545
+ closest to this instant -- the reading needs no date, or no year, typed
546
+ at all for the moment that is happening soon, whichever side of midnight
547
+ or new year's it falls on, in that zone's own calendar.
548
+ """
549
+ tokens = value.split(" ")
550
+ if len(tokens) == 2:
551
+ date_part, clock_part, zone_part = "", tokens[0], tokens[1]
552
+ elif len(tokens) == 3:
553
+ date_part, clock_part, zone_part = tokens
554
+ else:
555
+ return None
556
+ clock = _parse_freeze_clock(clock_part)
557
+ if clock is None:
558
+ return None
559
+ hour, minute, second = clock
560
+ zone = resolve_zone(zone_part, now)
561
+ if not date_part:
562
+ return _freeze_closest_day(now, zone, hour, minute, second)
563
+ date = _parse_freeze_date(date_part)
564
+ if date is None:
565
+ return None
566
+ year, month, day, has_year = date
567
+ if has_year:
568
+ return _freeze_date(zone, year, month, day, hour, minute, second)
569
+ return _freeze_closest_year(now, zone, month, day, hour, minute, second)
570
+
571
+
572
+ def freeze():
573
+ """The instant to pin the clock to, or None to run live.
574
+
575
+ CLOCK_FREEZE holds the clock at a fixed instant: the hands never move, and
576
+ space has nothing to hold back. Redirected it draws that one frame and
577
+ exits, which is what lets the Go and Python renders be diffed byte for
578
+ byte; on a terminal it stays up, with h and q still live, so the frame can
579
+ be looked at and photographed. Dev hook, not in --help.
580
+ """
581
+ value = os.environ.get("CLOCK_FREEZE", "")
582
+ if not value:
583
+ return None
584
+ frozen = _parse_freeze_instant(value)
585
+ if frozen is None:
586
+ # A trailing word that reads as an attempted zone, and fails to
587
+ # resolve as one, is worth resolve_zone's own reason rather than the
588
+ # generic message below: _parse_freeze_clock_zone only raises once a
589
+ # date and a clock have already parsed, so the word really was meant
590
+ # as a zone.
591
+ try:
592
+ frozen = _parse_freeze_clock_zone(value, datetime.now(timezone.utc))
593
+ except ClockError as zerr:
594
+ raise ClockError(f"CLOCK_FREEZE: {zerr}") from None
595
+ # Year 0 is a spelling rather than a range: Go's time has one and
596
+ # datetime does not, so datetime cannot construct it at all -- caught
597
+ # already, inside _parse_freeze_instant's try/except -- and this is the
598
+ # message it gets.
599
+ if frozen is None:
600
+ raise ClockError(
601
+ "CLOCK_FREEZE wants an instant like 2026-07-15T09:53:07.123456Z "
602
+ "(the fraction and its digit count are optional, down to none), "
603
+ "a clock time like 15:30 UTC or 15:30 PT (nearest day filled in), or a "
604
+ "dated one like 2026-07-22 15:30 UTC or 7/22 15:30 PT (nearest year "
605
+ f'filled in when it\'s left out), got "{value}"'
606
+ ) from None
607
+ if not FREEZE_FIRST <= frozen <= FREEZE_LAST:
608
+ raise ClockError(
609
+ "CLOCK_FREEZE wants an instant from 0001-01-02 to 9999-12-30, "
610
+ f'got "{value}"'
611
+ )
612
+ return frozen
613
+
614
+
615
+ def env_whole(name, lowest, default):
616
+ """One whole number out of the environment, or the default if unset.
617
+
618
+ Unlike CLOCK_CELL_RATIO, a bad value here is a hard error rather than
619
+ something to shrug off: these are the diff harness's knobs, and a typo that
620
+ quietly fell back to the default would leave a test claiming to cover a
621
+ sequence it never drew. Nine digits at most, so that Python's unbounded int
622
+ and Go's Atoi accept exactly the same strings.
623
+ """
624
+ value = os.environ.get(name, "")
625
+ if not value:
626
+ return default
627
+ if value.isascii() and value.isdigit() and len(value) <= 9 and int(value) >= lowest:
628
+ return int(value)
629
+ raise ClockError(
630
+ f"{name} wants a whole number of {lowest} or more, at most nine digits, "
631
+ f'got "{value}"'
632
+ )
633
+
634
+
635
+ def sequence(frozen):
636
+ """How many frames a pinned clock draws, and how far the instant moves
637
+ between them.
638
+
639
+ CLOCK_FRAMES draws that many instead of one, stepping the pinned instant by
640
+ CLOCK_STEP milliseconds each time -- one tick by default, so the sequence
641
+ advances exactly as a live clock would. It is what lets the harness compare
642
+ what a single frame cannot show: the second hand sweeping, the rewind that
643
+ repaints over the frame before it, and the faces regrouping as a zone
644
+ crosses a daylight-saving boundary.
645
+
646
+ Only where a pinned clock already draws and exits, which is redirected; on
647
+ a terminal one frame still stays up, so this cannot animate what is meant
648
+ to hold still. Dev hook, not in --help.
649
+ """
650
+ frames = env_whole("CLOCK_FRAMES", 1, 1)
651
+ step = env_whole("CLOCK_STEP", 0, round(TICK * 1000))
652
+ if frozen is None and (frames != 1 or os.environ.get("CLOCK_STEP", "")):
653
+ raise ClockError(
654
+ "CLOCK_FRAMES and CLOCK_STEP need CLOCK_FREEZE, the instant they step from"
655
+ )
656
+ return frames, timedelta(milliseconds=step)
657
+
658
+
659
+ # Hour numerals, every one two characters wide. A cell spans 2 dots, so an
660
+ # even-width string centres on a cell boundary while an odd-width one centres
661
+ # half a cell off it: "12" stacks exactly over "06", but never over "6".
662
+ MARKERS = ((0, "12"), (3, "03"), (6, "06"), (9, "09"))
663
+ MARKER_R = 0.70 # numeral distance from the centre, as a fraction of the radius
664
+ HAND_TAPER = 0.15 # fraction of a thick hand's length that narrows to a point at the tip
665
+
666
+ # Which hand a cell belongs to, and so which colour it takes. Higher is on
667
+ # top: the hands stack shortest-first, the reverse of the order they are drawn
668
+ # in, because a longer hand covers a shorter one along its whole length while
669
+ # the short one can only ever hide a slice. Left the other way round, the hour
670
+ # hand -- the one you most want to find -- vanishes under the minute hand for
671
+ # minutes at a time.
672
+ LAYER_NONE, LAYER_SECOND, LAYER_MINUTE, LAYER_HOUR = 0, 1, 2, 3
673
+
674
+ # Foreground SGR code per layer: red second hand as on a real dial, then cyan
675
+ # and yellow, which stay legible on a light and a dark terminal alike. Plain
676
+ # 8-colour codes, so they follow whatever palette the terminal is themed with.
677
+ HAND_SGR = ("", "\x1b[31m", "\x1b[36m", "\x1b[33m")
678
+ DEFAULT_FG = "\x1b[39m" # foreground back to the terminal's default, nothing else
679
+
680
+ # (length as a fraction of the radius, two dots thick?, layer), drawn
681
+ # shortest-first -- which is not the order they stack in; see LAYER_* above
682
+ HANDS = (
683
+ (0.50, True, LAYER_HOUR),
684
+ (0.75, True, LAYER_MINUTE),
685
+ (0.88, False, LAYER_SECOND),
686
+ )
687
+
688
+ # What --color and --day accept; auto reads the situation, the other two do not.
689
+ WHENS = ("always", "auto", "never")
690
+ # --color takes "off" too, alongside "never": both disable colour, but "off"
691
+ # is the more obvious word for it.
692
+ COLOR_WHENS = ("always", "auto", "never", "off")
693
+
694
+ # braille dot bit for (x % 2, y % 4); the block starts at U+2800
695
+ DOT_BITS = ((0x01, 0x02, 0x04, 0x40), (0x08, 0x10, 0x20, 0x80))
696
+
697
+
698
+ def ascii_lower(s):
699
+ """Lowercase A-Z and leave everything else exactly as it is.
700
+
701
+ Zone names, country codes and the abbreviations are all ASCII, so none of
702
+ the matching here wants Unicode's rules -- which is as well, since the two
703
+ languages do not have the same ones. Python's str.lower() applies the full
704
+ mappings, where Go's applies the simple ones, and they part company on
705
+ U+0130, the Turkish dotted capital I: Python gives it an i and a combining
706
+ dot, Go a plain i. So "Istanbul" spelt with one drew a clock under Go and
707
+ was refused as an unknown zone under Python.
708
+ """
709
+ return "".join(chr(ord(c) + 32) if "A" <= c <= "Z" else c for c in s)
710
+
711
+
712
+ def ascii_upper(s):
713
+ """Uppercase a-z and leave everything else exactly as it is; see ascii_lower."""
714
+ return "".join(chr(ord(c) - 32) if "a" <= c <= "z" else c for c in s)
715
+
716
+
717
+ def snap(v):
718
+ """Quantise a dot coordinate to 1e-9 before anything rounds it to a grid.
719
+
720
+ Python calls the platform libm for sin/cos while the Go port computes them
721
+ in software; the two agree to well under an ulp but not bit for bit, e.g.
722
+ cos(5.562579797474062) is ...96494540 here and ...96505642 there. Unsnapped,
723
+ a dot whose true position sits within 1e-16 of a half-dot boundary rounds
724
+ into different cells in the two renders — which it does, on the rim, every
725
+ single frame. 1e-9 swallows that disagreement and is still far finer than
726
+ the quarter-cell grid it feeds.
727
+ """
728
+ return math.floor(v * 1e9 + 0.5) / 1e9
729
+
730
+
731
+ class Canvas:
732
+ """A dot canvas that renders to braille cells, 2 dots wide by 4 tall each.
733
+
734
+ Alongside the dots each cell keeps the topmost layer that dotted it, which
735
+ is what the colouring reads. Colour is per cell and dots are not: a cell
736
+ holds up to eight of them, so where two hands share a cell the cell takes
737
+ the upper hand's colour and a few of the lower hand's dots come along.
738
+ """
739
+
740
+ def __init__(self, w, h):
741
+ self.w, self.h = w, h
742
+ self.cols = (w + 1) // 2
743
+ self.cells = [[0] * self.cols for _ in range((h + 3) // 4)]
744
+ self.layers = [[LAYER_NONE] * self.cols for _ in range((h + 3) // 4)]
745
+
746
+ def set(self, x, y, layer):
747
+ # floor(v + 0.5), not round(): round() is half-to-even here but
748
+ # half-away-from-zero in the Go port, which would split the renders
749
+ x, y = math.floor(snap(x) + 0.5), math.floor(snap(y) + 0.5)
750
+ if 0 <= x < self.w and 0 <= y < self.h:
751
+ self.cells[y // 4][x // 2] |= DOT_BITS[x % 2][y % 4]
752
+ if layer > self.layers[y // 4][x // 2]:
753
+ self.layers[y // 4][x // 2] = layer
754
+
755
+ def line(self, x0, y0, x1, y1, layer):
756
+ # snap the endpoints too: steps comes off a rounded difference, and a
757
+ # one-ulp wobble there changes the whole dot sequence, not just one dot
758
+ x0, y0, x1, y1 = snap(x0), snap(y0), snap(x1), snap(y1)
759
+ steps = max(1, math.floor(max(abs(x1 - x0), abs(y1 - y0)) + 0.5))
760
+ for i in range(steps + 1):
761
+ t = i / steps
762
+ self.set(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, layer)
763
+
764
+ def rows(self):
765
+ return ["".join(chr(0x2800 + bits) for bits in row) for row in self.cells]
766
+
767
+
768
+ def colorize(row, layers):
769
+ """Wrap each run of same-layer cells in that hand's colour.
770
+
771
+ Runs rather than cells: a hand lies along a dozen cells at a stretch, and
772
+ one escape per cell would multiply what a frame writes for no visible
773
+ difference. Rows end back on the default foreground, so the gutter between
774
+ two faces, and whatever the terminal paints past the end of the line, stay
775
+ the colour they were.
776
+ """
777
+ out = []
778
+ current = LAYER_NONE
779
+ for cell, layer in zip(row, layers):
780
+ if layer != current:
781
+ out.append(DEFAULT_FG if layer == LAYER_NONE else HAND_SGR[layer])
782
+ current = layer
783
+ out.append(cell)
784
+ if current != LAYER_NONE:
785
+ out.append(DEFAULT_FG)
786
+ return "".join(out)
787
+
788
+
789
+ def face(now, color):
790
+ """Render one analog face for `now`, returning a list of cell rows."""
791
+ rx, ry = COLS, 2 * ROWS
792
+ # Horizontally the centre sits on a cell boundary, vertically in the middle
793
+ # of a row. The dot grid mirrors about both, which is what makes 09 and 03
794
+ # land the same distance from the rim.
795
+ cx, cy = rx - 0.5, ry - 0.5
796
+ canvas = Canvas(2 * COLS, 4 * ROWS)
797
+
798
+ def spoke(angle, r0, r1, thick, point, layer):
799
+ """Radial segment from r0 to r1, as fractions of the radius.
800
+
801
+ A thick spoke drawn with point set narrows over its last HAND_TAPER
802
+ share to a single dot at r1, instead of ending in a flat,
803
+ two-dot-wide butt -- that is a hand. A thick spoke without point is a
804
+ plain parallel-sided band the same width all the way to r1 -- that is
805
+ always one of the four major hour ticks (h = 0, 3, 6, 9), always
806
+ exactly axis-aligned, always beside a numeral.
807
+ """
808
+ sin_a, cos_a = math.sin(angle), math.cos(angle)
809
+ x0, y0 = cx + rx * r0 * sin_a, cy - ry * r0 * cos_a
810
+ x1, y1 = cx + rx * r1 * sin_a, cy - ry * r1 * cos_a
811
+
812
+ if thick and point:
813
+ tip = r1 - (r1 - r0) * HAND_TAPER
814
+ tx, ty = cx + rx * tip * sin_a, cy - ry * tip * cos_a
815
+ for off in (-0.5, 0.5):
816
+ dx, dy = off * cos_a, off * sin_a
817
+ # the offset shrinks to nothing at the tip, not the base:
818
+ # that is what tapers the two edges together into a point
819
+ canvas.line(x0 + dx, y0 + dy, tx, ty, layer)
820
+ canvas.line(tx, ty, x1, y1, layer)
821
+ return
822
+ if not thick:
823
+ canvas.line(x0, y0, x1, y1, layer)
824
+ return
825
+
826
+ # A symmetric +-0.5 offset here would straddle a character cell
827
+ # boundary about half the time -- whichever side of a 2-or-4-dot cell
828
+ # the true centre's neighbouring dot falls on -- splitting the
829
+ # tick's two lines into different rows or columns and making it look
830
+ # disjointed from the numeral beside it. Landing both dots in the
831
+ # same cell as the numeral's own dot instead costs at most half a dot
832
+ # of true centring, invisible, for a tick that always reads as
833
+ # attached to its numeral, which is not.
834
+ horizontal = abs(cos_a) < 0.5
835
+ cell_size, center = (4, cy) if horizontal else (2, cx)
836
+ step = 1.0 if math.floor(center + 0.5) % cell_size == 0 else -1.0
837
+ for s in (0.0, step):
838
+ if horizontal:
839
+ canvas.line(x0, y0 + s, x1, y1 + s, layer)
840
+ else:
841
+ canvas.line(x0 + s, y0, x1 + s, y1, layer)
842
+
843
+ # rim: sample densely enough that adjacent dots touch
844
+ steps = math.floor(4 * math.pi * max(rx, ry) + 0.5)
845
+ for i in range(steps):
846
+ a = 2 * math.pi * i / steps
847
+ canvas.set(cx + rx * math.sin(a), cy - ry * math.cos(a), LAYER_NONE)
848
+
849
+ # hour ticks, the quarters longer and thicker so they sit on the axes
850
+ for h in range(12):
851
+ major = h % 3 == 0
852
+ spoke(2 * math.pi * h / 12, 0.80 if major else 0.90, 1.0, major, False, LAYER_NONE)
853
+
854
+ # hands: fractional seconds drive the sweep
855
+ frac = now.microsecond / 1e6
856
+ turns = (
857
+ (now.hour % 12 + now.minute / 60 + now.second / 3600) / 12,
858
+ (now.minute + (now.second + frac) / 60) / 60,
859
+ (now.second + frac) / 60,
860
+ )
861
+ for (length, thick, layer), turn in zip(HANDS, turns):
862
+ spoke(2 * math.pi * turn, 0, length, thick, True, layer)
863
+
864
+ rows = canvas.rows()
865
+
866
+ # hour numerals, overlaid as real characters: a cell holds braille or text
867
+ # but never both, so a numeral hides whatever dots share its cell
868
+ for hour, text in MARKERS:
869
+ a = 2 * math.pi * hour / 12
870
+ x = snap(cx + rx * MARKER_R * math.sin(a))
871
+ y = snap(cy - ry * MARKER_R * math.cos(a))
872
+ # centre an n-char string on x: it spans 2n dots, so its left edge
873
+ # wants to sit at x - n, snapped to the nearest cell boundary
874
+ col = math.floor((x - len(text) + 0.5) / 2 + 0.5)
875
+ row = math.floor(y + 0.5) // 4
876
+ if 0 <= row < len(rows) and 0 <= col <= canvas.cols - len(text):
877
+ rows[row] = rows[row][:col] + text + rows[row][col + len(text) :]
878
+ # the numeral took the cell's dots with it, so drop their colour
879
+ for i in range(col, col + len(text)):
880
+ canvas.layers[row][i] = LAYER_NONE
881
+
882
+ if color:
883
+ rows = [colorize(r, l) for r, l in zip(rows, canvas.layers)]
884
+ return rows
885
+
886
+
887
+ class HelpRequested(Exception):
888
+ """-h or --help: print the usage text and stop, successfully."""
889
+
890
+
891
+ class VersionRequested(Exception):
892
+ """--version: print the version and stop, successfully."""
893
+
894
+
895
+ def parse_count(s, what, limit):
896
+ """Read a positive whole number, strictly.
897
+
898
+ Not int(), which also takes surrounding space, underscores, a leading sign
899
+ and non-ASCII digits; the Go port's parser has to accept exactly the same
900
+ strings, and it hand-scans ASCII digits.
901
+ """
902
+ bad = ClockError(f'{what} wants a whole number from 1 to {limit}, got "{s}"')
903
+ if not s or len(s) > len(str(limit)):
904
+ raise bad
905
+ n = 0
906
+ for c in s:
907
+ if not ("0" <= c <= "9"):
908
+ raise bad
909
+ n = n * 10 + (ord(c) - ord("0"))
910
+ if not 1 <= n <= limit:
911
+ raise bad
912
+ return n
913
+
914
+
915
+ # What each of the four layout flags suggests when it is handed no value.
916
+ NEEDS = {
917
+ "halign": "--halign center",
918
+ "valign": "--valign center",
919
+ "hpad": "--hpad 10%",
920
+ "vpad": "--vpad 5%",
921
+ "cell-ratio": "--cell-ratio 2.6",
922
+ "scale": "--scale 1.5",
923
+ }
924
+
925
+
926
+ def parse_choice(flag, val, choices):
927
+ """Read one of a short list of words, or reject it by name.
928
+
929
+ The message is built from the list, so a flag cannot come to accept a word
930
+ its own error text does not offer.
931
+ """
932
+ if val in choices:
933
+ return val
934
+ names = ", ".join(choices[:-1]) + " or " + choices[-1]
935
+ raise ClockError(f'--{flag} wants {names}, got "{val}"')
936
+
937
+
938
+ def parse_pad(flag, val):
939
+ """Read a padding: None for the even fill, or a percentage 0-100.
940
+
941
+ Takes "10" as readily as "10%", and nothing else -- no sign, no decimal
942
+ point, no space, since the Go port hand-scans the same digits.
943
+ """
944
+ if val == "even":
945
+ return None
946
+ bad = ClockError(f'--{flag} wants even or a share like 10%, got "{val}"')
947
+ digits = val[:-1] if val.endswith("%") else val
948
+ if not digits or len(digits) > 3:
949
+ raise bad
950
+ n = 0
951
+ for c in digits:
952
+ if not ("0" <= c <= "9"):
953
+ raise bad
954
+ n = n * 10 + (ord(c) - ord("0"))
955
+ if n > 100:
956
+ raise bad
957
+ return n
958
+
959
+
960
+ def parse_args(argv):
961
+ """Read the command line: one optional zone list, and the flags anywhere.
962
+
963
+ Hand-rolled rather than argparse, which prints its own usage block, exits
964
+ with status 2, and abbreviates long options -- none of which the Go port
965
+ can reproduce. Both implementations run this algorithm verbatim.
966
+ """
967
+ per_row = DEFAULT_PER_ROW # only used when an explicit -n/--per-row overrides per_row_auto below
968
+ color_when = "auto"
969
+ day_when = "" # unset: run() picks it, since a pinned clock differs
970
+ halign, valign = "center", "center"
971
+ hpad, vpad = None, None # None is the even fill
972
+ quiet = False
973
+ cell_ratio_flag = None # None: run() falls back to CLOCK_CELL_RATIO, then the default
974
+ scale_flag = None # unset unless a specific --scale overrides scale_auto below
975
+ scale_auto = True # the default: run() re-solves ROWS every frame to fill the window
976
+ per_row_auto = True # the default: run() also searches per-row counts, to maximise ROWS
977
+ positional = []
978
+ end_of_flags = False
979
+
980
+ i = 0
981
+ while i < len(argv):
982
+ a = argv[i]
983
+ if end_of_flags:
984
+ positional.append(a)
985
+ elif a == "--":
986
+ end_of_flags = True
987
+ elif a in ("-h", "--help"):
988
+ raise HelpRequested
989
+ elif a.startswith("--"):
990
+ name, sep, val = a[2:].partition("=")
991
+ if name == "per-row":
992
+ if not sep:
993
+ i += 1
994
+ if i >= len(argv):
995
+ raise ClockError("--per-row needs a number, e.g. --per-row 2")
996
+ val = argv[i]
997
+ if val == "auto":
998
+ per_row_auto = True
999
+ else:
1000
+ per_row = parse_count(val, "--per-row", MAX_PER_ROW)
1001
+ per_row_auto = False
1002
+ elif name == "color":
1003
+ # Bare --color means always, and takes no separate argument:
1004
+ # "clock --color ET" names a zone list, exactly as ls and git
1005
+ # read the same flag. The value only ever follows an "=".
1006
+ color_when = parse_choice("color", val, COLOR_WHENS) if sep else "always"
1007
+ elif name == "no-color":
1008
+ if sep:
1009
+ raise ClockError("--no-color takes no value")
1010
+ color_when = "never"
1011
+ elif name == "version":
1012
+ # Read where it is found, like --help: everything before it on
1013
+ # the command line still has to parse, everything after it is
1014
+ # never looked at.
1015
+ if sep:
1016
+ raise ClockError("--version takes no value")
1017
+ raise VersionRequested
1018
+ elif name == "day":
1019
+ day_when = parse_choice("day", val, WHENS) if sep else "always"
1020
+ elif name == "no-day":
1021
+ if sep:
1022
+ raise ClockError("--no-day takes no value")
1023
+ day_when = "never"
1024
+ elif name == "quiet":
1025
+ if sep:
1026
+ raise ClockError("--quiet takes no value")
1027
+ quiet = True
1028
+ elif name in ("halign", "valign", "hpad", "vpad", "cell-ratio", "scale"):
1029
+ # These six want a value, and take it either way round, as
1030
+ # --per-row does: there is no bare form to be ambiguous with.
1031
+ if not sep:
1032
+ i += 1
1033
+ if i >= len(argv):
1034
+ raise ClockError(f"--{name} needs a value, e.g. {NEEDS[name]}")
1035
+ val = argv[i]
1036
+ if name == "halign":
1037
+ halign = parse_choice(name, val, HALIGNS)
1038
+ elif name == "valign":
1039
+ valign = parse_choice(name, val, VALIGNS)
1040
+ elif name == "hpad":
1041
+ hpad = parse_pad(name, val)
1042
+ elif name == "vpad":
1043
+ vpad = parse_pad(name, val)
1044
+ elif name == "cell-ratio":
1045
+ cell_ratio_flag = parse_ratio(val)
1046
+ elif val == "auto":
1047
+ scale_auto = True
1048
+ else:
1049
+ scale_flag = parse_scale(val)
1050
+ scale_auto = False
1051
+ else:
1052
+ raise ClockError(f"unknown option: --{name}")
1053
+ elif a == "-q":
1054
+ quiet = True
1055
+ elif len(a) > 1 and a.startswith("-"):
1056
+ if a[1] != "n":
1057
+ raise ClockError(f"unknown option: {a}")
1058
+ rest = a[2:]
1059
+ if rest == "":
1060
+ i += 1
1061
+ if i >= len(argv):
1062
+ raise ClockError("-n needs a number, e.g. -n 2")
1063
+ rest = argv[i]
1064
+ elif rest[0] == "=":
1065
+ raise ClockError('-n takes its value as "-n N" or "-nN", not "-n=N"')
1066
+ if rest == "auto":
1067
+ per_row_auto = True
1068
+ else:
1069
+ per_row = parse_count(rest, "-n", MAX_PER_ROW)
1070
+ per_row_auto = False
1071
+ else:
1072
+ positional.append(a)
1073
+ i += 1
1074
+
1075
+ if len(positional) > 1:
1076
+ raise ClockError(
1077
+ f"expected one comma-separated zone list, got {len(positional)}: "
1078
+ + " ".join(positional)
1079
+ )
1080
+ return (
1081
+ per_row,
1082
+ positional[0] if positional else "",
1083
+ color_when,
1084
+ day_when,
1085
+ (halign, valign, hpad, vpad),
1086
+ quiet,
1087
+ cell_ratio_flag,
1088
+ scale_flag,
1089
+ scale_auto,
1090
+ per_row_auto,
1091
+ )
1092
+
1093
+
1094
+ # Fills the gaps the tz database leaves, and only those gaps. EST, MST, HST,
1095
+ # GMT, CET and EET are real zones with fixed, DST-free meanings, so they are
1096
+ # looked up verbatim instead: aliasing GMT to Europe/London would make it read
1097
+ # BST every July, which is simply wrong. Sorted, same order as clock.go's table.
1098
+ ZONE_ALIASES = (
1099
+ ("ACT", "Australia/Adelaide"),
1100
+ ("AET", "Australia/Sydney"),
1101
+ ("AKT", "America/Anchorage"),
1102
+ ("AWT", "Australia/Perth"),
1103
+ ("BST", "Europe/London"),
1104
+ ("CT", "America/Chicago"),
1105
+ ("ET", "America/New_York"),
1106
+ ("HKT", "Asia/Hong_Kong"),
1107
+ ("HT", "Pacific/Honolulu"),
1108
+ ("IST", "Asia/Kolkata"),
1109
+ ("JST", "Asia/Tokyo"),
1110
+ ("KST", "Asia/Seoul"),
1111
+ ("MT", "America/Denver"),
1112
+ ("NZT", "Pacific/Auckland"),
1113
+ ("PT", "America/Los_Angeles"),
1114
+ ("SGT", "Asia/Singapore"),
1115
+ ("UK", "Europe/London"),
1116
+ )
1117
+
1118
+ # The half of each daylight-saving pair that names an offset rather than a
1119
+ # place: nowhere is on PDT in January, so these cannot be looked up in the tz
1120
+ # database. Each becomes a fixed-offset clock that never shifts, which is
1121
+ # precisely what the name means -- PST is Los Angeles in winter, and stays
1122
+ # there in July while PT moves to PDT. EST, MST and HST are absent because the
1123
+ # tz database already carries them as fixed zones, and CST is the US reading;
1124
+ # China is CN or Asia/Shanghai. Sorted, same order as clock.go's table.
1125
+ ZONE_FIXED = (
1126
+ ("AKDT", -8 * 3600),
1127
+ ("AKST", -9 * 3600),
1128
+ ("CDT", -5 * 3600),
1129
+ ("CST", -6 * 3600),
1130
+ ("EDT", -4 * 3600),
1131
+ ("HDT", -9 * 3600),
1132
+ ("MDT", -6 * 3600),
1133
+ ("PDT", -7 * 3600),
1134
+ ("PST", -8 * 3600),
1135
+ )
1136
+
1137
+
1138
+ def alias_names():
1139
+ return " ".join(name for name, _ in ZONE_ALIASES)
1140
+
1141
+
1142
+ def zip_zone(token):
1143
+ """The zone one ZIP code names, via the ziptz module.
1144
+
1145
+ Its error text is written to be printed as-is, so it passes straight
1146
+ through; clock.go prints the same lines from the Go package beside it.
1147
+ """
1148
+ if ziptz is None:
1149
+ raise ClockError(
1150
+ f'"{token}" is a ZIP code, and resolving one needs the ziptz'
1151
+ ' module: "pip install ziptz-us", or copy ziptz.py next to this'
1152
+ " file. Every other kind of zone works without it"
1153
+ )
1154
+ try:
1155
+ return ziptz.location(token)
1156
+ except ziptz.ZipError as exc:
1157
+ raise ClockError(str(exc)) from None
1158
+
1159
+
1160
+ def zone_tab():
1161
+ """The tz database's country table, and whether it was found at all.
1162
+
1163
+ Absent on stripped-down systems, so never fatal.
1164
+ """
1165
+ for directory in (
1166
+ os.environ.get("TZDIR", ""),
1167
+ "/usr/share/zoneinfo",
1168
+ "/usr/share/lib/zoneinfo",
1169
+ "/usr/lib/locale/TZ",
1170
+ ):
1171
+ if not directory:
1172
+ continue
1173
+ try:
1174
+ with open(directory + "/zone.tab", encoding="utf-8") as handle:
1175
+ return handle.read(), True
1176
+ except OSError:
1177
+ continue
1178
+ return "", False
1179
+
1180
+
1181
+ def country_zones(cc):
1182
+ """A country's zones in file order, which is the tz database's own idea of
1183
+ most-populous-first rather than anything alphabetical."""
1184
+ data, found = zone_tab()
1185
+ if not found:
1186
+ return [], False
1187
+ out = []
1188
+ for line in data.split("\n"):
1189
+ if not line or line.startswith("#"):
1190
+ continue
1191
+ fields = line.split("\t")
1192
+ if len(fields) >= 3 and fields[0] == cc:
1193
+ out.append(fields[2])
1194
+ return out, True
1195
+
1196
+
1197
+ def country_zone(cc, at):
1198
+ """Resolve a 2-letter country code, collapsing zones that agree.
1199
+
1200
+ Germany lists Europe/Berlin and Europe/Busingen, an enclave that has kept
1201
+ the same time since 1970, so DE is not genuinely ambiguous; the US is.
1202
+ """
1203
+ names, found = country_zones(cc)
1204
+ if not found:
1205
+ raise ClockError(
1206
+ f'cannot resolve the country code "{cc}": '
1207
+ "no zone.tab under /usr/share/zoneinfo"
1208
+ )
1209
+ zones, kept, seen = [], [], set()
1210
+ for name in names:
1211
+ try:
1212
+ zi = ZoneInfo(name)
1213
+ except Exception:
1214
+ continue
1215
+ here = at.astimezone(zi)
1216
+ key = f"{here:%Z}|{int(here.utcoffset().total_seconds())}"
1217
+ if key not in seen:
1218
+ seen.add(key)
1219
+ zones.append(zi)
1220
+ kept.append(name)
1221
+ if not zones:
1222
+ return None # not a country code we know; caller falls through
1223
+ if len(zones) == 1:
1224
+ return zones[0]
1225
+ shown, tail = kept, ""
1226
+ if len(shown) > 8:
1227
+ tail = f" (and {len(shown) - 8} more)"
1228
+ shown = shown[:8]
1229
+ raise ClockError(
1230
+ f"{cc} spans {len(kept)} time zones; name one: " + ", ".join(shown) + tail
1231
+ )
1232
+
1233
+
1234
+ def suffix_zones(token):
1235
+ """The zones whose name ends with the token as a whole path segment.
1236
+
1237
+ Europe/Berlin for "Berlin", and America/Indiana/Indianapolis for either
1238
+ "Indianapolis" or "Indiana/Indianapolis". Whole segments only, so "Berl"
1239
+ finds nothing and "York" does not answer for "New_York".
1240
+
1241
+ Read out of zone.tab, the same file the country codes come from, which
1242
+ lists the canonical zones and leaves out the backward-compatibility links
1243
+ -- so "Eastern" is not a name here, and US/Eastern still resolves the
1244
+ ordinary way, in full.
1245
+ """
1246
+ data, found = zone_tab()
1247
+ if not found:
1248
+ return [], False
1249
+ want = "/" + ascii_lower(token)
1250
+ out = []
1251
+ for line in data.split("\n"):
1252
+ if not line or line.startswith("#"):
1253
+ continue
1254
+ fields = line.split("\t")
1255
+ if len(fields) >= 3 and ascii_lower(fields[2]).endswith(want):
1256
+ out.append(fields[2])
1257
+ return out, True
1258
+
1259
+
1260
+ def suffix_zone(token):
1261
+ """One zone named by its tail alone, or None when nothing matches.
1262
+
1263
+ Ambiguity is refused rather than guessed at. Every city in the tz database
1264
+ is unique today, but nothing promises it stays that way, and two clocks an
1265
+ ocean apart is not a choice to make on the reader's behalf.
1266
+ """
1267
+ names, found = suffix_zones(token)
1268
+ if not found or not names:
1269
+ return None
1270
+ if len(names) > 1:
1271
+ shown, tail = names, ""
1272
+ if len(shown) > 8:
1273
+ tail = f" (and {len(shown) - 8} more)"
1274
+ shown = shown[:8]
1275
+ raise ClockError(
1276
+ f"{token} names {len(names)} zones; name one in full: "
1277
+ + ", ".join(shown)
1278
+ + tail
1279
+ )
1280
+ try:
1281
+ return ZoneInfo(names[0])
1282
+ except Exception:
1283
+ return None
1284
+
1285
+
1286
+ def unknown_zone(token):
1287
+ return ClockError(
1288
+ f'unknown zone "{token}"; use an IANA name (Europe/Berlin), a city '
1289
+ f"off the end of one (Berlin, Jakarta), an abbreviation "
1290
+ f"({alias_names()}), a 2-letter country code (JP), or a US ZIP code"
1291
+ )
1292
+
1293
+
1294
+ def local_zone():
1295
+ """None, meaning the platform's local zone -- once TZ is one both ports read.
1296
+
1297
+ Go asks the tz database for whatever TZ names and falls back to UTC when it
1298
+ has no such file; Python leaves the question to the C library, which also
1299
+ reads the POSIX rule form -- "PST8PDT,M3.2.0,M11.1.0", "<+07>-7", "GMT+5".
1300
+ So a POSIX rule makes one clock read Pacific and the other UTC, seven hours
1301
+ apart, both of them sure. There is no fixing that from here without writing
1302
+ a tzset the Go standard library does not export, so say so instead: this is
1303
+ the Windows message's argument, one environment variable down.
1304
+
1305
+ Only a TZ that has to be *looked up* is checked. Unset, empty (which POSIX
1306
+ reads as UTC) and an absolute path all mean the same thing to both.
1307
+ """
1308
+ tz = os.environ.get("TZ")
1309
+ if tz is None:
1310
+ return None
1311
+ if tz.startswith(":"):
1312
+ tz = tz[1:]
1313
+ if tz == "" or tz.startswith("/"):
1314
+ return None
1315
+ try:
1316
+ ZoneInfo(tz)
1317
+ except Exception:
1318
+ raise ClockError(
1319
+ f'TZ="{tz}" is not a zone name, and a POSIX TZ rule is not something '
1320
+ f"both clocks read alike; name a zone as an argument instead"
1321
+ ) from None
1322
+ return None
1323
+
1324
+
1325
+ def resolve_zone(token, at):
1326
+ """Turn one token into a tzinfo, or None meaning the system's local zone.
1327
+
1328
+ Order matters: the alias table is consulted before the tz database only for
1329
+ names the database lacks, the fixed-offset table only after it so that real
1330
+ zones win, and "local" and "" are intercepted because Python
1331
+ and Go disagree about both -- ZoneInfo("Local") raises where
1332
+ LoadLocation("Local") works, and ZoneInfo("") raises where LoadLocation("")
1333
+ quietly returns UTC.
1334
+ """
1335
+ if token.startswith("/") or ".." in token:
1336
+ raise ClockError(f'"{token}" is not a zone name')
1337
+ if ascii_lower(token) == "local":
1338
+ return local_zone()
1339
+ if token.isascii() and token.isdigit():
1340
+ return zip_zone(token)
1341
+ up = ascii_upper(token)
1342
+ for name, target in ZONE_ALIASES:
1343
+ if name == up:
1344
+ try:
1345
+ return ZoneInfo(target)
1346
+ except Exception:
1347
+ raise ClockError(
1348
+ f"{up} means {target}, which this system's time zone database lacks"
1349
+ ) from None
1350
+ try:
1351
+ return ZoneInfo(token)
1352
+ except Exception:
1353
+ pass
1354
+ for name, offset in ZONE_FIXED:
1355
+ if name == up:
1356
+ return timezone(timedelta(seconds=offset), name)
1357
+ if len(up) == 2 and "A" <= up[0] <= "Z" and "A" <= up[1] <= "Z":
1358
+ found = country_zone(up, at)
1359
+ if found is not None:
1360
+ return found
1361
+ # Last, so a city can never shadow a name the database itself answers to.
1362
+ named = suffix_zone(token)
1363
+ if named is not None:
1364
+ return named
1365
+ raise unknown_zone(token)
1366
+
1367
+
1368
+ def resolve_zones(zone_list, at):
1369
+ """Turn the comma-separated list into (token, zone) pairs, left to right.
1370
+
1371
+ The token is carried along because merge_zones labels a face with the
1372
+ spellings that asked for it, not just the zone it landed on.
1373
+ """
1374
+ if not zone_list:
1375
+ return [("", local_zone())]
1376
+ out = []
1377
+ for token in zone_list.split(","):
1378
+ token = token.strip(" \t")
1379
+ if not token:
1380
+ raise ClockError(f'empty zone in "{zone_list}"')
1381
+ out.append((token, resolve_zone(token, at)))
1382
+ return out
1383
+
1384
+
1385
+ def zone_label(abbr, tokens):
1386
+ """The name written over one face.
1387
+
1388
+ Just the abbreviation, unless more than one spelling collapsed onto this
1389
+ face -- then each spelling that reads differently is named too, because
1390
+ that is the only place the ambiguity is visible. PDT,PDT asked the same
1391
+ question twice and gets one plain answer.
1392
+ """
1393
+ if len(tokens) < 2:
1394
+ return abbr
1395
+ return "/".join([abbr] + [t for t in tokens if ascii_upper(t) != ascii_upper(abbr)])
1396
+
1397
+
1398
+ def merge_zones(zones, now):
1399
+ """Collapse zones that show the same wall clock at `now` into one face.
1400
+
1401
+ Keyed on abbreviation and offset, the same test country_zone uses: however
1402
+ two tokens were spelled, and whether or not one resolves onto the other,
1403
+ they are one clock if they read alike. That is a property of the instant,
1404
+ not of the zones -- PDT and PT are one clock in July and two in January --
1405
+ so this regroups as the clock runs rather than once at startup, and a grid
1406
+ crossing a daylight-saving boundary splits itself as it happens. The loop
1407
+ calls it once a second, which is as often as its answer can change.
1408
+ """
1409
+ out, index = [], {}
1410
+ for token, zone in zones:
1411
+ t = in_zone(now, zone)
1412
+ key = (f"{t:%Z}", int(t.utcoffset().total_seconds()))
1413
+ if key not in index:
1414
+ index[key] = len(out)
1415
+ out.append((key[0], [], zone))
1416
+ _, tokens, _ = out[index[key]]
1417
+ if token and not any(ascii_upper(token) == ascii_upper(seen) for seen in tokens):
1418
+ tokens.append(token)
1419
+ return [(zone_label(abbr, tokens), zone) for abbr, tokens, zone in out]
1420
+
1421
+
1422
+ def in_zone(t, zone):
1423
+ """The instant t as seen in `zone`; None means the system's local zone."""
1424
+ return t.astimezone() if zone is None else t.astimezone(zone)
1425
+
1426
+
1427
+ def utc_offset(t, zone):
1428
+ """How far `zone` sits from UTC at t, in seconds east."""
1429
+ return int(in_zone(t, zone).utcoffset().total_seconds())
1430
+
1431
+
1432
+ def order_faces(faces, now):
1433
+ """Faces in the order their clocks read, earliest first: left to right,
1434
+ then top to bottom.
1435
+
1436
+ Sorted on the offset, which is the same thing: every face renders one
1437
+ instant, so the time one reads is that instant plus its offset, and the
1438
+ westernmost zone is the one furthest behind. Faces that share an offset
1439
+ keep the order they were typed in -- the sort is stable in both ports for
1440
+ exactly that reason -- which is how UTC and GMT, two faces because they
1441
+ are labelled differently, stay where you put them.
1442
+
1443
+ Redone as the clock runs, like the merging: an offset is a property of the
1444
+ instant, so a zone entering daylight saving slides a place along. Sorting
1445
+ on the offset rather than on the time each face reads is what keeps this
1446
+ from also changing at every midnight.
1447
+ """
1448
+ return sorted(faces, key=lambda face: utc_offset(now, face[1]))
1449
+
1450
+
1451
+ def center(s, w, extra_left):
1452
+ """Centre s in w columns, the odd column going left or right as told.
1453
+
1454
+ Not "{:^w}", which always leans right: the frame decides, so that a face
1455
+ centred inside a grid that has already leaned right leans left here and
1456
+ the two cancel. Nothing wider than w is padded, and nothing is cut.
1457
+ """
1458
+ pad = max(0, w - len(s))
1459
+ left = (pad + 1) // 2 if extra_left else pad // 2
1460
+ return " " * left + s + " " * (pad - left)
1461
+
1462
+
1463
+ def truncate(s, n):
1464
+ """Cut s to n characters."""
1465
+ n = max(0, n)
1466
+ return s if len(s) <= n else s[:n]
1467
+
1468
+
1469
+ # Weekday names, Sunday first to match Go's time.Weekday. A table rather than
1470
+ # strftime("%a"), which follows the locale -- "lun." in a French shell -- where
1471
+ # Go's Format is fixed English. Hard-coding it keeps the two renders identical
1472
+ # on every machine.
1473
+ DAY_NAMES = ("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat")
1474
+
1475
+ # The width of "Mon 05:02:41.901", against the bare readout's 12. A face
1476
+ # narrower than this would shove the columns to its right out of true, so a
1477
+ # very low CLOCK_CELL_RATIO loses the weekday rather than the alignment.
1478
+ DAY_COLS = 16
1479
+
1480
+
1481
+ def show_weekday(faces, now, when):
1482
+ """Whether the readouts carry a weekday.
1483
+
1484
+ Under auto, only when the faces on screen disagree about the date: a
1485
+ weekday under every clock is noise when they all fall on the same one.
1486
+ Nothing off screen is consulted, so the clock never asserts a date for a
1487
+ zone it is not drawing -- name `local` in the list to compare against your
1488
+ own day.
1489
+
1490
+ Up to three dates can be on screen at once, since UTC-12 to UTC+14 spans
1491
+ 26 hours and so crosses two midnights.
1492
+
1493
+ A face too narrow to hold the weekday loses it whatever the setting says,
1494
+ since the alternative is a grid out of true.
1495
+ """
1496
+ if when == "never" or COLS < DAY_COLS or not faces:
1497
+ return False
1498
+ if when == "always":
1499
+ return True
1500
+ first = in_zone(now, faces[0][1]).date()
1501
+ return any(in_zone(now, zone).date() != first for _, zone in faces)
1502
+
1503
+
1504
+ def digital(t, weekday):
1505
+ """The clock under one face: 12 characters, or 16 with a weekday on it."""
1506
+ clock = f"{t:%H:%M:%S}.{t.microsecond // 1000:03d}"
1507
+ if not weekday:
1508
+ return clock
1509
+ # isoweekday is Mon=1..Sun=7, and % 7 turns that into Go's Sun=0
1510
+ return f"{DAY_NAMES[t.isoweekday() % 7]} {clock}"
1511
+
1512
+
1513
+ def chunk_count(n, per_row):
1514
+ """How many rows of faces per_row produces."""
1515
+ return -(-n // per_row)
1516
+
1517
+
1518
+ def frame_height(chunks, vgap):
1519
+ """The rows a grid occupies: each chunk is a face, its zone name and its
1520
+ digital line, with vgap blank rows between chunks."""
1521
+ return chunks * (ROWS + 2) + vgap * (chunks - 1)
1522
+
1523
+
1524
+ def gap_floor(span, percent, least):
1525
+ """The gap a layout will not go below.
1526
+
1527
+ An even fill starts from the least the grid can be packed to and grows;
1528
+ a percentage is that share of the whole span, and is the whole answer. A
1529
+ span of 0 is a window that could not be measured, where a percentage of
1530
+ nothing is nothing useful, so the least stands.
1531
+ """
1532
+ if percent is None or span <= 0:
1533
+ return least
1534
+ return span * percent // 100
1535
+
1536
+
1537
+ def spread(count, size, span, least, percent, align):
1538
+ """Lay count blocks of size across span: the gap between two of them, and
1539
+ the margin in front of the first.
1540
+
1541
+ An even fill counts the margins as gaps too -- count blocks make count + 1
1542
+ spaces, two of them against the edges -- and gives each an equal share of
1543
+ what the blocks leave. Sharing between the blocks alone would hand every
1544
+ spare column to the gutters and press the outer clocks flat against the
1545
+ borders, which is the one arrangement nobody wants.
1546
+
1547
+ The share stops at the size of a block: past that the clocks read as
1548
+ scattered rather than as a group, so on a wide window the extra goes to
1549
+ the margins and the clocks stay a cluster in the middle. It never drops
1550
+ below `least` either, so a window just big enough for the grid gets the
1551
+ packed layout rather than a squeeze.
1552
+
1553
+ A percentage fixes the gap outright and leaves everything else to the
1554
+ margin, so the alignment has something to work with. An unmeasurable span
1555
+ keeps the packed layout this clock had before any of it was adjustable:
1556
+ least gap, no margin.
1557
+
1558
+ Comes back as (gap, extra, margin, leaned). A centred layout cannot halve
1559
+ an odd slack into two margins, but a gutter can swallow the odd column
1560
+ instead: `extra` widens one gutter by one, and the margins come out equal.
1561
+ That only works where there is a gutter, so a single clock still has to
1562
+ lean, and `leaned` says it did -- the caller's cue to lean the other way
1563
+ on the next rounding, so the two cancel rather than adding up.
1564
+ """
1565
+ gap = gap_floor(span, percent, least)
1566
+ if span <= 0:
1567
+ return gap, 0, 0, False
1568
+ if percent is None:
1569
+ share = max(0, span - count * size) // (count + 1)
1570
+ gap = min(max(least, share), size)
1571
+ slack = max(0, span - count * size - gap * (count - 1))
1572
+ extra = 0
1573
+ if align == "center" and percent is None and count > 1 and slack % 2 == 1:
1574
+ # A padding asked for by name is left exactly as asked for; only the
1575
+ # even fill, which chose this gap itself, may nudge one gutter.
1576
+ extra, slack = 1, slack - 1
1577
+ if align == "center":
1578
+ return gap, extra, slack // 2, slack % 2 == 1
1579
+ return gap, 0, (slack if align in ("right", "bottom") else 0), False
1580
+
1581
+
1582
+ def help_rows():
1583
+ """The key list, one row per key."""
1584
+ return [f"{key:<{HOTKEY_COL}}{what}" for key, what in HOTKEYS]
1585
+
1586
+
1587
+ # One frame's spacing, both axes: the blank columns between faces and rows
1588
+ # between rows of faces, the one gutter each axis widens to swallow an odd
1589
+ # column, the margins before the first of each, and which way to lean a label
1590
+ # that will not centre exactly.
1591
+ Layout = collections.namedtuple(
1592
+ "Layout", "gap extra left vgap vextra top extra_left"
1593
+ )
1594
+
1595
+
1596
+ def frame(faces, now, per_row, color, day_when, lay):
1597
+ """Draw the whole grid: faces left to right, wrapping every per_row.
1598
+
1599
+ A short last row keeps the gutters and margin of a full one, so the
1600
+ columns stay lined up -- including the widened gutter, which sits at a
1601
+ fixed place in the row rather than at whatever the last one happens to be.
1602
+ """
1603
+ indent = " " * lay.left
1604
+ # The face is COLS wide and its cell may be wider, so the face rows are
1605
+ # padded into it. Plain spaces on either side of already-coloured rows,
1606
+ # rather than centring them: centring counts characters, and a coloured row
1607
+ # is mostly escape bytes.
1608
+ cell = cell_cols()
1609
+ pad_left = " " * ((cell - COLS) // 2)
1610
+ pad_right = " " * (cell - COLS - len(pad_left))
1611
+
1612
+ def gutter(i):
1613
+ """The i'th gap of a row: the widened one is always the last of a full
1614
+ row, so a short row's gutters still line up with the row above."""
1615
+ return " " * (lay.gap + (1 if lay.extra and i == per_row - 2 else 0))
1616
+
1617
+ def row(parts):
1618
+ out = indent + parts[0]
1619
+ for i, part in enumerate(parts[1:]):
1620
+ out += gutter(i) + part
1621
+ return out
1622
+ weekday = show_weekday(faces, now, day_when)
1623
+ chunks = chunk_count(len(faces), per_row)
1624
+ rows = [""] * lay.top
1625
+ for ci in range(chunks):
1626
+ chunk = faces[ci * per_row : (ci + 1) * per_row]
1627
+ if ci > 0:
1628
+ wide = 1 if lay.vextra and ci == chunks - 1 else 0
1629
+ rows.extend([""] * (lay.vgap + wide))
1630
+ times = [in_zone(now, z) for _, z in chunk]
1631
+ drawn = [face(t, color) for t in times]
1632
+ rows.extend(
1633
+ row([pad_left + part + pad_right for part in line]) for line in zip(*drawn)
1634
+ )
1635
+ rows.append(
1636
+ row([center(truncate(label, cell), cell, lay.extra_left) for label, _ in chunk])
1637
+ )
1638
+ rows.append(
1639
+ row([center(digital(t, weekday), cell, lay.extra_left) for t in times])
1640
+ )
1641
+ return rows
1642
+
1643
+
1644
+ def modal_box(content):
1645
+ """Draw content inside a one-line border.
1646
+
1647
+ Used for both the key list and the startup quit hint so the two read as
1648
+ the same kind of thing: a modal overlaid on the clocks, not part of the
1649
+ grid underneath it.
1650
+ """
1651
+ width = max(len(c) for c in content)
1652
+ box = [f"┌{'─' * (width + 2)}┐"]
1653
+ box.extend(f"│ {c:<{width}} │" for c in content)
1654
+ box.append(f"└{'─' * (width + 2)}┘")
1655
+ return box
1656
+
1657
+
1658
+ def center_modal(box, term_cols, term_rows):
1659
+ """Where to place a modal in the middle of a term_cols x term_rows window.
1660
+
1661
+ ok is False when it does not fit, the same trade the key list already made
1662
+ against a narrow window: no modal beats a wrapped or clipped one. A window
1663
+ that cannot be measured has nowhere settled to put one, so that is also a
1664
+ no.
1665
+ """
1666
+ width, height = len(box[0]), len(box)
1667
+ if term_cols <= 0 or term_rows <= 0 or width > term_cols or height > term_rows:
1668
+ return 0, 0, False
1669
+ return (term_rows - height) // 2, (term_cols - width) // 2, True
1670
+
1671
+
1672
+ def overlay_modal(rows, box, top, left):
1673
+ """Stamp box onto rows at (top, left).
1674
+
1675
+ Extends rows with blank lines so the box always lands intact regardless
1676
+ of what the grid drew there.
1677
+ """
1678
+ out = list(rows) + [""] * (top + len(box) - len(rows))
1679
+ for i, line in enumerate(box):
1680
+ r = top + i
1681
+ out[r] = splice_row(out[r], left, len(line), line)
1682
+ return out
1683
+
1684
+
1685
+ def splice_row(row, col, width, insert):
1686
+ """Overwrite the visible columns [col, col + width) of row with insert.
1687
+
1688
+ insert is plain text, never coloured itself, while whatever ANSI colour
1689
+ row carried outside that span is preserved and correctly resumed on the
1690
+ far side. row may already be full of colour escapes (a face's hand can
1691
+ pass under where a modal lands) or may have none at all (--color=never,
1692
+ or a redirected frame); either way nothing outside [col, col + width)
1693
+ changes.
1694
+ """
1695
+ before, after = [], []
1696
+ active = "" # the last SGR escape seen so far, "" meaning none yet
1697
+ start_active = end_active = ""
1698
+ start_captured = end_captured = False
1699
+ visible = 0
1700
+ i, n = 0, len(row)
1701
+ while i < n:
1702
+ if row[i] == "\x1b":
1703
+ j = row.index("m", i) + 1
1704
+ seq = row[i:j]
1705
+ active = seq
1706
+ if visible < col:
1707
+ before.append(seq)
1708
+ elif visible >= col + width:
1709
+ after.append(seq)
1710
+ i = j
1711
+ continue
1712
+ if not start_captured and visible >= col:
1713
+ start_captured, start_active = True, active
1714
+ if not end_captured and visible >= col + width:
1715
+ end_captured, end_active = True, active
1716
+ if visible < col:
1717
+ before.append(row[i])
1718
+ elif visible >= col + width:
1719
+ after.append(row[i])
1720
+ visible += 1
1721
+ i += 1
1722
+ if not start_captured:
1723
+ start_active = active
1724
+ if not end_captured:
1725
+ end_active = active
1726
+ if visible < col:
1727
+ before.append(" " * (col - visible))
1728
+ result = "".join(before)
1729
+ if start_active and start_active != DEFAULT_FG:
1730
+ result += DEFAULT_FG
1731
+ result += insert
1732
+ if after:
1733
+ if end_active:
1734
+ result += end_active
1735
+ result += "".join(after)
1736
+ return result
1737
+
1738
+
1739
+ def fold(text, width):
1740
+ """Break text onto lines of at most width, on spaces where it can be.
1741
+
1742
+ A word with nowhere to break -- a window narrower than "--per-row" -- is
1743
+ cut instead, since the alternative is a line that wraps itself and scrolls
1744
+ the screen out from under the next repaint.
1745
+ """
1746
+ rows, line = [], ""
1747
+ for word in text.split(" "):
1748
+ while width > 0 and len(word) > width:
1749
+ if line:
1750
+ rows.append(line)
1751
+ line = ""
1752
+ rows.append(word[:width])
1753
+ word = word[width:]
1754
+ if not line:
1755
+ line = word
1756
+ elif len(line) + 1 + len(word) <= width:
1757
+ line += " " + word
1758
+ else:
1759
+ rows.append(line)
1760
+ line = word
1761
+ if line:
1762
+ rows.append(line)
1763
+ return rows
1764
+
1765
+
1766
+ def complaint(text, term_cols, term_rows, halign):
1767
+ """The frame that says why there are no clocks, when the window is too
1768
+ small to hold them.
1769
+
1770
+ Folded to the window and cut to it, and placed the way the quit hint is:
1771
+ centred with the clocks, hard left under any other alignment.
1772
+ """
1773
+ width = term_cols if term_cols > 0 else len(text)
1774
+ rows = fold(text, width)
1775
+ if term_rows > 0:
1776
+ del rows[term_rows:]
1777
+ if halign == "center":
1778
+ rows = [" " * ((width - len(r)) // 2) + r for r in rows]
1779
+ if term_rows > 0:
1780
+ rows = [""] * ((term_rows - len(rows)) // 2) + rows
1781
+ return rows
1782
+
1783
+
1784
+ def use_color(when):
1785
+ """Whether to colour the hands.
1786
+
1787
+ auto colours a terminal and leaves a pipe or a file plain, so a redirected
1788
+ frame stays the plain text the difftest compares. NO_COLOR is the
1789
+ cross-tool convention for "never, from the environment"; an explicit
1790
+ --color=always overrules it, since that is the point of saying always.
1791
+ """
1792
+ if when != "auto":
1793
+ return when == "always"
1794
+ return sys.stdout.isatty() and os.environ.get("NO_COLOR", "") == ""
1795
+
1796
+
1797
+ def env_count(name):
1798
+ """A positive count from the environment, 0 when unset or junk."""
1799
+ try:
1800
+ return parse_count(os.environ.get(name, ""), name, 100000)
1801
+ except ClockError:
1802
+ return 0
1803
+
1804
+
1805
+ def term_size():
1806
+ """The terminal as (columns, rows); 0 means "could not tell".
1807
+
1808
+ COLUMNS and LINES win when set, both because that is the shell convention
1809
+ and because it gives the diff harness a way to pin the layout. Deliberately
1810
+ not shutil.get_terminal_size, which invents 80x24 when it cannot tell -- a
1811
+ pipe has to stay distinguishable from an 80-column window.
1812
+ """
1813
+ cols, rows = env_count("COLUMNS"), env_count("LINES")
1814
+ if not cols or not rows:
1815
+ try:
1816
+ size = os.get_terminal_size(sys.stdout.fileno())
1817
+ except (OSError, ValueError, AttributeError):
1818
+ return cols, rows
1819
+ cols = cols or size.columns
1820
+ rows = rows or size.lines
1821
+ return cols, rows
1822
+
1823
+
1824
+ def cell_cols():
1825
+ """How wide one face's column is: the face, or its readout if that is wider."""
1826
+ return max(COLS, READOUT_COLS)
1827
+
1828
+
1829
+ def fit_per_row(want, n, term_cols, gap):
1830
+ """Reduce the requested faces-per-row to what the window can hold.
1831
+
1832
+ Wrapping is what actually breaks the display: a wrapped line desynchronises
1833
+ the cursor rewind and the frame smears.
1834
+ """
1835
+ want = min(want, n)
1836
+ if term_cols <= 0:
1837
+ return want # not a terminal: honour what was asked for
1838
+ cell = cell_cols()
1839
+ max_fit = (term_cols + gap) // (cell + gap)
1840
+ if max_fit < 1:
1841
+ raise ClockError(
1842
+ f"terminal is {term_cols} columns wide and one clock face needs "
1843
+ f"{cell}; widen the window, or lower --cell-ratio"
1844
+ )
1845
+ return min(want, max_fit)
1846
+
1847
+
1848
+ def fit_height(chunks, term_rows, vgap):
1849
+ """Reject a grid taller than the window.
1850
+
1851
+ Too tall scrolls, and scrolling desynchronises the rewind exactly as
1852
+ wrapping does -- but here the fix is to raise --per-row, not lower it.
1853
+ """
1854
+ height = frame_height(chunks, vgap)
1855
+ if term_rows > 0 and height > term_rows:
1856
+ raise ClockError(
1857
+ f"{chunks} rows of clocks need {height} lines and this terminal "
1858
+ f"has {term_rows}; raise --per-row, or name fewer zones"
1859
+ )
1860
+
1861
+
1862
+ def auto_scale(want_per_row, num_faces, cols, lines, hpad, vpad):
1863
+ """What --scale auto resolves to every frame.
1864
+
1865
+ The largest ROWS (and its matching COLS) that lets num_faces fit
1866
+ cols x lines at no more than want_per_row per row, using no more than
1867
+ hpad/vpad's own minimum gap on each axis -- the same floor fit_per_row
1868
+ and fit_height already enforce, so a maximised face never asks for less
1869
+ room than an explicit --hpad would once drawn. Larger ROWS can only ever
1870
+ need as much or more space (a wider face fits no more per row, and a
1871
+ taller one needs no fewer lines), so the first size that fits, searched
1872
+ from the top down, is the largest one that does.
1873
+
1874
+ -n auto passes num_faces itself as want_per_row -- no cap at all, in
1875
+ effect, since fit_per_row already clamps want to num_faces on its own --
1876
+ rather than searching per-row counts separately. fit_per_row always uses
1877
+ the most faces a row can hold up to the cap, which is also the fewest
1878
+ chunks (and so the least height) any per-row choice at that ROWS could
1879
+ need, so an uncapped want already finds whichever per-row count each
1880
+ candidate ROWS fits best through, without a second search: capping lower
1881
+ could only ever force more chunks than that ROWS needed, never fewer.
1882
+
1883
+ Sets the module-level ROWS and COLS to the winner; if nothing in range
1884
+ fits, it leaves them at MIN_ROWS_N so the fit_per_row/fit_height call
1885
+ right after this one reports why.
1886
+
1887
+ An odd ROWS (or COLS) is preferred within SYMMETRY_WINDOW of the largest
1888
+ fit: an odd count centres the face's true axis exactly in the middle of a
1889
+ character cell, while an even one centres it exactly on the boundary
1890
+ between two cells, where no placement of a major tick's two dots can be
1891
+ symmetric -- see the axis-safe tick comment on spoke(), and
1892
+ ARCHITECTURE.md, for why that is otherwise unavoidable. Every smaller
1893
+ candidate already fits, by the same monotonicity argument above, so
1894
+ trading a handful of rows for one with both counts odd costs nothing but
1895
+ those few rows -- capped at SYMMETRY_WINDOW, so a face that never finds
1896
+ one does not shrink indefinitely looking.
1897
+ """
1898
+ global ROWS, COLS
1899
+ best = 0
1900
+ for n in range(MAX_ROWS_N, MIN_ROWS_N - 1, -1):
1901
+ ROWS = n
1902
+ COLS = math.floor(n * CELL_RATIO + 0.5)
1903
+ try:
1904
+ per_row = fit_per_row(want_per_row, num_faces, cols, gap_floor(cols, hpad, GAP))
1905
+ except ClockError:
1906
+ continue
1907
+ try:
1908
+ fit_height(chunk_count(num_faces, per_row), lines, gap_floor(lines, vpad, VGAP))
1909
+ except ClockError:
1910
+ continue
1911
+ best = n
1912
+ break
1913
+ if best == 0:
1914
+ ROWS = MIN_ROWS_N
1915
+ COLS = math.floor(MIN_ROWS_N * CELL_RATIO + 0.5)
1916
+ return
1917
+
1918
+ row_fallback, col_fallback = -1, -1
1919
+ for n in range(best, max(best - SYMMETRY_WINDOW, MIN_ROWS_N - 1), -1):
1920
+ c = math.floor(n * CELL_RATIO + 0.5)
1921
+ if n % 2 == 1 and c % 2 == 1:
1922
+ ROWS, COLS = n, c
1923
+ return
1924
+ if n % 2 == 1 and row_fallback < 0:
1925
+ row_fallback = n
1926
+ if c % 2 == 1 and col_fallback < 0:
1927
+ col_fallback = n
1928
+ # No candidate had both odd: an odd ROWS keeps the 3/9 o'clock ticks
1929
+ # symmetric, which is the more noticeable pair, so it wins over an odd
1930
+ # COLS alone.
1931
+ if row_fallback >= 0:
1932
+ ROWS = row_fallback
1933
+ elif col_fallback >= 0:
1934
+ ROWS = col_fallback
1935
+ else:
1936
+ ROWS = best
1937
+ COLS = math.floor(ROWS * CELL_RATIO + 0.5)
1938
+
1939
+
1940
+ @contextlib.contextmanager
1941
+ def quiet_terminal():
1942
+ """Swallow keystrokes so they can't scroll the frame out from under us.
1943
+
1944
+ Clears ECHO/ECHONL/ICANON but leaves ISIG set, so Ctrl+C still raises
1945
+ KeyboardInterrupt. Restoring with TCSAFLUSH discards whatever was typed
1946
+ during the run, so stray keys can't land in the shell afterwards.
1947
+
1948
+ Yields (interactive, restore, requiet): whether stdin is a terminal, i.e.
1949
+ whether keys can be read at all, and the two moves the clock can make with
1950
+ it -- restore puts back what the shell handed over, requiet takes it again.
1951
+ Quitting needs the first, Ctrl+Z needs both, either side of the stop.
1952
+ """
1953
+
1954
+ def nothing():
1955
+ pass
1956
+
1957
+ try:
1958
+ fd = sys.stdin.fileno()
1959
+ saved = termios.tcgetattr(fd)
1960
+ except (AttributeError, ValueError, termios.error):
1961
+ # not a terminal (piped or redirected): nothing to quieten
1962
+ yield False, nothing, nothing
1963
+ return
1964
+
1965
+ quiet = list(saved)
1966
+ quiet[3] &= ~(termios.ECHO | termios.ECHONL | termios.ICANON) # lflag
1967
+ try:
1968
+ termios.tcsetattr(fd, termios.TCSANOW, quiet)
1969
+ yield (
1970
+ True,
1971
+ lambda: termios.tcsetattr(fd, termios.TCSAFLUSH, saved),
1972
+ lambda: termios.tcsetattr(fd, termios.TCSANOW, quiet),
1973
+ )
1974
+ finally:
1975
+ termios.tcsetattr(fd, termios.TCSAFLUSH, saved)
1976
+
1977
+
1978
+ def pending_keys():
1979
+ """Whatever is waiting on stdin, or b"" if nothing is.
1980
+
1981
+ Never blocks, and assumes cbreak mode, where a key arrives without a
1982
+ Return behind it. Handed back one byte at a time rather than tested for a
1983
+ q, because two spaces in one read have to toggle the hold twice, exactly
1984
+ as the Go port's one-byte-per-channel-send loop does.
1985
+ """
1986
+ if not select.select([sys.stdin], [], [], 0)[0]:
1987
+ return b""
1988
+ return os.read(sys.stdin.fileno(), 64)
1989
+
1990
+
1991
+ def _terminate(_signum, _frame):
1992
+ """Turn SIGTERM into an ordinary unwind, so the cursor comes back.
1993
+
1994
+ Left to its default, SIGTERM kills the process outright: the finally below
1995
+ never runs, and the caller is handed a terminal with no cursor and echo
1996
+ still off. Go's port already selects on SIGTERM for the same reason.
1997
+ """
1998
+ raise SystemExit(0)
1999
+
2000
+
2001
+ # Raised by the Ctrl+Z handler, read and cleared by the frame loop. The handler
2002
+ # does none of the work itself: Python runs handlers between bytecodes, and the
2003
+ # bytecode it interrupts can be one in the middle of a sys.stdout.write --
2004
+ # where writing to the same buffer again is a reentrant call the io module
2005
+ # refuses outright. So the loop does it, at a point where nothing is
2006
+ # half-written, which is also where clock.go does it: its handler is a channel
2007
+ # send and the select at the end of the loop is what reads it.
2008
+ _suspend_asked = False
2009
+
2010
+
2011
+ def _suspend(_signum, _frame):
2012
+ global _suspend_asked
2013
+ _suspend_asked = True
2014
+
2015
+
2016
+ def suspend(full_screen, restore, requiet):
2017
+ """Ctrl+Z: give the terminal back, stop for real, and take it again after.
2018
+
2019
+ kill(2) delivers before it returns, so everything after it runs on resume
2020
+ -- cbreak again, the alternate screen again, and a repaint, since what
2021
+ SIGCONT comes back to is the screen the shell left rather than the one the
2022
+ clock was drawing on.
2023
+
2024
+ The stop is SIGSTOP rather than the usual move, which is to put SIGTSTP's
2025
+ default disposition back and raise that at yourself. That move works here
2026
+ and does not in clock.go, whose runtime keeps its own SIGTSTP handler
2027
+ installed through the reset and then swallows the signal -- see the longer
2028
+ note there. Two ports that stopped by different signals would not stop
2029
+ alike: what a shell prints for a job differs between the two, "Stopped"
2030
+ against bash's "Stopped(SIGSTOP)". The one thing lost is that SIGTSTP is
2031
+ discarded when the process group is orphaned, where SIGSTOP is not: a clock
2032
+ sent `kill -TSTP` from outside such a group stops where it would once have
2033
+ been left running, and wants a `kill -CONT` to come back. Ctrl+Z cannot
2034
+ reach it there in the first place -- the terminal driver discards
2035
+ job-control signals for an orphaned group too, before any of this is
2036
+ reached.
2037
+ """
2038
+ global _suspend_asked
2039
+ _suspend_asked = False
2040
+
2041
+ sys.stdout.write(SHOW_CURSOR + (LEAVE_ALT if full_screen else ""))
2042
+ sys.stdout.flush()
2043
+ restore()
2044
+
2045
+ os.kill(os.getpid(), signal.SIGSTOP)
2046
+ # Raising a stop is not the same as having stopped, and nothing here can
2047
+ # ask whether it has: the answer is only ever observed by running again.
2048
+ # So the wait is a tick, which cannot finish early and which a clock that
2049
+ # really stopped is not running for. clock.go needs it -- a Go process has
2050
+ # threads, and the one that takes the signal need not be the one that
2051
+ # raised it, which on Linux left it taking the terminal back before the
2052
+ # stop landed -- and this one keeps it so the two resume alike.
2053
+ time.sleep(TICK)
2054
+
2055
+ requiet()
2056
+ sys.stdout.write((ENTER_ALT if full_screen else "") + HIDE_CURSOR)
2057
+ sys.stdout.flush()
2058
+
2059
+
2060
+ def run(argv):
2061
+ """Everything that can fail happens before the terminal is touched."""
2062
+ global ROWS, CELL_RATIO, COLS
2063
+ frozen = freeze()
2064
+ (
2065
+ want_per_row,
2066
+ zone_list,
2067
+ color_when,
2068
+ day_when,
2069
+ geometry,
2070
+ quiet,
2071
+ cell_ratio_flag,
2072
+ scale_flag,
2073
+ scale_auto,
2074
+ per_row_auto,
2075
+ ) = parse_args(argv)
2076
+ halign, valign, hpad, vpad = geometry
2077
+ zones = resolve_zones(zone_list, frozen or datetime.now(timezone.utc))
2078
+
2079
+ color = use_color(color_when)
2080
+
2081
+ # --cell-ratio wins over CLOCK_CELL_RATIO, which wins over the default.
2082
+ CELL_RATIO = cell_ratio_flag if cell_ratio_flag is not None else env_cell_ratio()
2083
+
2084
+ # --scale resizes the whole face, keeping the same shape: ROWS moves and
2085
+ # COLS follows it, through the cell-ratio arithmetic above. --scale auto
2086
+ # instead re-solves both every frame, in the main loop, against whatever
2087
+ # the terminal measures to.
2088
+ if not scale_auto:
2089
+ scale = scale_flag if scale_flag is not None else 1.0
2090
+ ROWS = math.floor(DEFAULT_ROWS_N * scale + 0.5)
2091
+ if ROWS < MIN_ROWS_N or ROWS > MAX_ROWS_N:
2092
+ raise ClockError(
2093
+ f"--scale {scale:g} makes each face {ROWS} rows tall; want "
2094
+ f"{MIN_ROWS_N} to {MAX_ROWS_N} rows, roughly --scale "
2095
+ f"{MIN_ROWS_N / DEFAULT_ROWS_N:.2f} to --scale {MAX_ROWS_N / DEFAULT_ROWS_N:.2f}"
2096
+ )
2097
+ COLS = math.floor(ROWS * CELL_RATIO + 0.5)
2098
+
2099
+ # -n auto's whole point is choosing whatever per-row count lets --scale
2100
+ # auto grow the face furthest; with a fixed --scale there is no face size
2101
+ # left for it to affect, so it falls back to the plain default cap.
2102
+ if per_row_auto and not scale_auto:
2103
+ want_per_row = DEFAULT_PER_ROW
2104
+ per_row_auto = False
2105
+
2106
+ # A pinned clock is a still of one instant, and an undated still records
2107
+ # half of it, so the weekday goes under every face unless --day says
2108
+ # otherwise. Live, auto keeps it for the clocks that actually disagree.
2109
+ if not day_when:
2110
+ day_when = "always" if frozen is not None else "auto"
2111
+ # The instant the display is holding, or None when it runs live. Holding
2112
+ # repaints as usual rather than idling, so a resize still reflows the grid
2113
+ # -- it is the clock that stops, not the drawing.
2114
+ held = None
2115
+ help_on = False
2116
+ # Off the wall clock, not the frame's: a clock pinned with CLOCK_FREEZE
2117
+ # never advances, and the hint still has to give up after three seconds.
2118
+ flash_until = time.monotonic() + FLASH_SECONDS
2119
+
2120
+ signal.signal(signal.SIGTERM, _terminate)
2121
+ signal.signal(signal.SIGTSTP, _suspend)
2122
+
2123
+ # On a terminal, take the alternate screen and paint from its top corner.
2124
+ # Relative rewind cannot survive a resize: the terminal rewraps the frame
2125
+ # already on screen, so rows that were one physical line become two, the
2126
+ # ESC[nA lands inside the old frame, and its upper half is left behind --
2127
+ # CLEAR_BELOW only ever clears downwards. Homing to a screen we own makes
2128
+ # the frame's position independent of what happened to the last one. Piped
2129
+ # output keeps the rewind, which costs nothing there and keeps the byte
2130
+ # stream the difftest compares unchanged.
2131
+ full_screen = sys.stdout.isatty()
2132
+
2133
+ # A pinned clock redirected to a file is the diff harness: one frame and
2134
+ # out. On a terminal there is someone watching, so it stays up instead --
2135
+ # quitting would restore the screen and take the frame with it.
2136
+ one_shot = frozen is not None and not full_screen
2137
+ # A pinned clock draws one frame unless CLOCK_FRAMES asks for a sequence;
2138
+ # `drawn` is which frame of it this is, and so how far the instant has
2139
+ # moved from the pinned one.
2140
+ frames, step = sequence(frozen)
2141
+ drawn = 0
2142
+
2143
+ sys.stdout.write((ENTER_ALT if full_screen else "") + HIDE_CURSOR)
2144
+ height = 0
2145
+ # Which faces there are, and in what order, changes only when some zone's
2146
+ # offset changes -- and a tz transition always lands on a whole second, so
2147
+ # recomputing once a second cannot miss one. See the loop below.
2148
+ face_second, faces = None, None
2149
+ try:
2150
+ with quiet_terminal() as (interactive, restore, requiet):
2151
+ while True:
2152
+ now = frozen if frozen is not None else datetime.now(timezone.utc)
2153
+ if one_shot:
2154
+ now += step * drawn
2155
+ if held is not None:
2156
+ now = held
2157
+
2158
+ # re-measure every frame rather than trapping SIGWINCH: one
2159
+ # ioctl per 19ms is nothing beside redrawing the faces, it also
2160
+ # picks up a changed COLUMNS, and under PEP 475 a signal
2161
+ # handler would interact with the sleep below and drift out of
2162
+ # step with the Go port's loop.
2163
+ cols, lines = term_size()
2164
+
2165
+ # Unlike the size, this is not re-measured every frame. Merging
2166
+ # and ordering both turn on the zones' offsets at `now`, which
2167
+ # move only at a tz transition, and a transition happens on a
2168
+ # whole second -- so a second is the coarsest interval that
2169
+ # cannot skip one, and at 19ms frames that is ~50x less work.
2170
+ # Floored, not truncated: Go's Unix() floors, where int()
2171
+ # would truncate, so before 1970 the two would hold different
2172
+ # numbers here. Nothing drawn would differ -- the key decides
2173
+ # only when the recompute lands, and the one second the two
2174
+ # would disagree about, the one straddling the epoch, has no
2175
+ # zone changing offset inside it -- but a key that is the same
2176
+ # number in both needs no such argument to be trusted.
2177
+ second = math.floor(now.timestamp())
2178
+ if second != face_second:
2179
+ face_second = second
2180
+ faces = order_faces(merge_zones(zones, now), now)
2181
+
2182
+ if scale_auto:
2183
+ if per_row_auto:
2184
+ want_per_row = DEFAULT_PER_ROW # overridden below whenever there is a window to measure
2185
+ if cols > 0 and lines > 0:
2186
+ if per_row_auto:
2187
+ want_per_row = len(faces) # no real cap: see auto_scale's own comment
2188
+ auto_scale(want_per_row, len(faces), cols, lines, hpad, vpad)
2189
+ else:
2190
+ # Nothing measurable to fill, so there is nothing to
2191
+ # solve -- same as any other window that cannot be
2192
+ # measured.
2193
+ ROWS = DEFAULT_ROWS_N
2194
+ COLS = math.floor(DEFAULT_ROWS_N * CELL_RATIO + 0.5)
2195
+
2196
+ # The key list and the startup hint are the same kind of
2197
+ # thing -- a modal laid over the clocks -- so only one shows
2198
+ # at a time, and the key list, being asked for, wins over a
2199
+ # hint that is already redundant with the "q" line in it.
2200
+ fits = False
2201
+ try:
2202
+ per_row = fit_per_row(
2203
+ want_per_row, len(faces), cols, gap_floor(cols, hpad, GAP)
2204
+ )
2205
+ chunks = chunk_count(len(faces), per_row)
2206
+ fit_height(chunks, lines, gap_floor(lines, vpad, VGAP))
2207
+ except ClockError as exc:
2208
+ # A window dragged smaller than the clocks need is
2209
+ # something the reader can undo, so say what is wrong and
2210
+ # keep measuring: the next frame that fits draws itself.
2211
+ # Redirected output has no window to resize and still
2212
+ # fails outright, which is what the diff harness compares.
2213
+ if not full_screen:
2214
+ raise
2215
+ rows = complaint(str(exc), cols, lines, halign)
2216
+ else:
2217
+ fits = True
2218
+
2219
+ content = None
2220
+ if fits and full_screen and help_on:
2221
+ content = help_rows()
2222
+ elif not quiet and full_screen and time.monotonic() < flash_until:
2223
+ content = [FLASH]
2224
+ show_modal = False
2225
+ if content is not None:
2226
+ modal = modal_box(content)
2227
+ modal_top, modal_left, show_modal = center_modal(modal, cols, lines)
2228
+
2229
+ if fits:
2230
+ # Any lean left over from the grid is answered by the
2231
+ # labels leaning the other way, so the frame comes out no
2232
+ # more than a column off centre -- and with a gutter to
2233
+ # swallow the odd column, dead centre.
2234
+ gap_n, extra, left, leaned = spread(
2235
+ per_row, cell_cols(), cols, GAP, hpad, halign
2236
+ )
2237
+ vgap, vextra, top, _ = spread(
2238
+ chunks, ROWS + 2, lines, VGAP, vpad, valign
2239
+ )
2240
+ lay = Layout(gap_n, extra, left, vgap, vextra, top, leaned)
2241
+ rows = frame(faces, now, per_row, color, day_when, lay)
2242
+ if show_modal:
2243
+ rows = overlay_modal(rows, modal, modal_top, modal_left)
2244
+
2245
+ # Repaint in one write. CLEAR_EOL wipes a longer previous
2246
+ # line, CLEAR_BELOW a taller previous frame, so the grid
2247
+ # reshapes itself when the window changes.
2248
+ if full_screen:
2249
+ # no trailing newline: a frame exactly as tall as the
2250
+ # window would otherwise scroll itself off by one line
2251
+ body = "\n".join(r + CLEAR_EOL for r in rows)
2252
+ sys.stdout.write(HOME + body + CLEAR_BELOW)
2253
+ else:
2254
+ rewind = f"\x1b[{height}A" if height else ""
2255
+ sys.stdout.write(
2256
+ rewind
2257
+ + "".join(r + CLEAR_EOL + "\n" for r in rows)
2258
+ + CLEAR_BELOW
2259
+ )
2260
+ sys.stdout.flush()
2261
+ height = len(rows)
2262
+
2263
+ if one_shot:
2264
+ drawn += 1
2265
+ if drawn >= frames:
2266
+ break
2267
+ continue
2268
+ if interactive:
2269
+ quitting = False
2270
+ for key in pending_keys():
2271
+ if key in b"qQ":
2272
+ quitting = True
2273
+ elif key == b" "[0]:
2274
+ held = None if held is not None else now
2275
+ elif key in b"hH?":
2276
+ help_on = not help_on
2277
+ if quitting:
2278
+ break
2279
+ if _suspend_asked:
2280
+ suspend(full_screen, restore, requiet)
2281
+ # Don't wait out a tick that was interrupted by a stop of
2282
+ # unknown length: the screen resumes blank, and the reader
2283
+ # should not have to watch it stay that way.
2284
+ continue
2285
+ time.sleep(TICK - (time.monotonic() % TICK))
2286
+ except KeyboardInterrupt:
2287
+ pass
2288
+ finally:
2289
+ # The reader may already be gone, in which case these have nowhere to
2290
+ # go and it does not matter: what has to happen on the way out is
2291
+ # quiet_terminal's restore, which is an ioctl on stdin and has
2292
+ # happened by now. See main() for the rest of that path.
2293
+ with contextlib.suppress(BrokenPipeError):
2294
+ sys.stdout.write(SHOW_CURSOR + (LEAVE_ALT if full_screen else ""))
2295
+ sys.stdout.flush()
2296
+
2297
+
2298
+ def main():
2299
+ try:
2300
+ run(sys.argv[1:])
2301
+ except HelpRequested:
2302
+ sys.stdout.write(USAGE)
2303
+ except VersionRequested:
2304
+ sys.stdout.write(f"clock {VERSION}\n")
2305
+ except ClockError as exc:
2306
+ sys.stderr.write(f"clock: {exc}\n")
2307
+ raise SystemExit(1)
2308
+ except BrokenPipeError:
2309
+ # `clock | head`: the reader went away. Not news, and not a traceback
2310
+ # -- which is what Python prints for it by default, twenty-odd lines
2311
+ # about a normal way for a filter to end, after the terminal has
2312
+ # already been given back.
2313
+ #
2314
+ # stdout is pointed at /dev/null first because the interpreter flushes
2315
+ # it once more on the way out, where the same error would surface
2316
+ # again as "Exception ignored" and turn the status into 120.
2317
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
2318
+ raise SystemExit(PIPE_STATUS)
2319
+
2320
+
2321
+ if __name__ == "__main__":
2322
+ main()