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 +2322 -0
- terminal_clock-0.2.1.dist-info/METADATA +894 -0
- terminal_clock-0.2.1.dist-info/RECORD +8 -0
- terminal_clock-0.2.1.dist-info/WHEEL +5 -0
- terminal_clock-0.2.1.dist-info/entry_points.txt +2 -0
- terminal_clock-0.2.1.dist-info/licenses/LICENSE +21 -0
- terminal_clock-0.2.1.dist-info/licenses/NOTICE +57 -0
- terminal_clock-0.2.1.dist-info/top_level.txt +1 -0
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()
|