term-clock-app 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
term_clock/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """A terminal digital clock that scales to fill the window."""
2
+
3
+ __version__ = "1.0.0"
term_clock/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main(sys.argv[1:]))
term_clock/cli.py ADDED
@@ -0,0 +1,203 @@
1
+ """Terminal runtime: paints the clock and stays current until CTRL+C."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import shutil
7
+ import sys
8
+ import time
9
+ from typing import Callable, Sequence, TextIO
10
+
11
+ from . import core
12
+
13
+ _ALT_ON = "\x1b[?1049h"
14
+ _ALT_OFF = "\x1b[?1049l"
15
+ _CURSOR_HIDE = "\x1b[?25l"
16
+ _CURSOR_SHOW = "\x1b[?25h"
17
+ _WRAP_OFF = "\x1b[?7l"
18
+ _WRAP_ON = "\x1b[?7h"
19
+ # Home, erase the visible screen, erase the scrollback. A full-width last
20
+ # line would otherwise wrap and push every frame into the history.
21
+ _CLEAR = "\x1b[H\x1b[2J\x1b[3J"
22
+
23
+
24
+ def _nonneg(value: str) -> int:
25
+ n = int(value)
26
+ if n < 0:
27
+ raise argparse.ArgumentTypeError("must be >= 0")
28
+ return n
29
+
30
+
31
+ _TOLERANCE_MS = 2.0
32
+ _LEAD_MAX_MS = 100.0
33
+
34
+
35
+ def next_second_ms(now_ms: float) -> float:
36
+ """Wall-clock millisecond of the next whole second after ``now_ms``."""
37
+ return (int(now_ms) // 1000 + 1) * 1000.0
38
+
39
+
40
+ def sleep_ms(now_ms: float, target_ms: float, lead_ms: float) -> float:
41
+ """How long to sleep now so we arrive at ``target_ms``, given ``lead_ms``.
42
+
43
+ ``lead_ms`` is the estimated sleep overshoot, subtracted from the wait.
44
+ It is never allowed to skip past the boundary.
45
+ """
46
+ remain = target_ms - now_ms
47
+ if remain <= 0:
48
+ return 0.0
49
+ wait = remain - lead_ms
50
+ if wait <= 0:
51
+ wait = remain
52
+ return wait
53
+
54
+
55
+ def adjust_lead_ms(lead_ms: float, error_ms: float) -> float:
56
+ """If we missed the second by more than 2 ms, shift the next sleep."""
57
+ if abs(error_ms) <= _TOLERANCE_MS:
58
+ return lead_ms
59
+ return min(_LEAD_MAX_MS, max(0.0, lead_ms + error_ms))
60
+
61
+
62
+ def _wall_ms() -> float:
63
+ return time.time_ns() / 1_000_000.0
64
+
65
+
66
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
67
+ p = argparse.ArgumentParser(
68
+ prog="term-clock",
69
+ description="A terminal digital clock that scales to fill the window.",
70
+ )
71
+ p.add_argument(
72
+ "--padding",
73
+ type=_nonneg,
74
+ default=1,
75
+ metavar="N",
76
+ help="blank rows and columns around the clock (default: 1)",
77
+ )
78
+ p.add_argument(
79
+ "--spacing",
80
+ type=_nonneg,
81
+ default=2,
82
+ metavar="N",
83
+ help="blank columns between digits (default: 2)",
84
+ )
85
+ p.add_argument(
86
+ "--hour-format",
87
+ choices=("12", "24"),
88
+ default=None,
89
+ dest="hour_format",
90
+ help="12 or 24 (default: follow the system clock, or 24 if unknown)",
91
+ )
92
+ return p.parse_args(argv)
93
+
94
+
95
+ def frame_for(
96
+ t: time.struct_time,
97
+ cols: int,
98
+ rows: int,
99
+ style: core.Style | None = None,
100
+ ) -> list[str]:
101
+ """Build the screen buffer for time ``t`` at the given terminal size."""
102
+ style = style or core.Style()
103
+ time_str = core.format_time(
104
+ t.tm_hour, t.tm_min, t.tm_sec, hour_format=style.hour_format
105
+ )
106
+ suffix = core.hour_period(t.tm_hour) if style.hour_format == "12" else ""
107
+ return core.render(time_str, rows=rows, cols=cols, style=style, suffix=suffix)
108
+
109
+
110
+ def _write_diff(out: TextIO, old: Sequence[str], new: Sequence[str]) -> None:
111
+ """Emit cursor-addressed runs for cells that differ."""
112
+ for y, (a, b) in enumerate(zip(old, new)):
113
+ if a == b:
114
+ continue
115
+ width = max(len(a), len(b))
116
+ a = a.ljust(width)
117
+ b = b.ljust(width)
118
+ x = 0
119
+ while x < width:
120
+ if a[x] == b[x]:
121
+ x += 1
122
+ continue
123
+ start = x
124
+ while x < width and a[x] != b[x]:
125
+ x += 1
126
+ out.write(f"\x1b[{y + 1};{start + 1}H{b[start:x]}")
127
+
128
+
129
+ class Painter:
130
+ """Off-screen current/next buffers; only changed cells are written."""
131
+
132
+ def __init__(self, out: TextIO) -> None:
133
+ self._out = out
134
+ self._last: list[str] | None = None
135
+
136
+ def paint(self, frame: Sequence[str]) -> None:
137
+ frame = list(frame)
138
+ if frame == self._last:
139
+ return
140
+ if self._last is None or len(self._last) != len(frame):
141
+ self._out.write(_CLEAR + "\n".join(frame))
142
+ else:
143
+ _write_diff(self._out, self._last, frame)
144
+ self._last = frame
145
+ self._out.flush()
146
+
147
+ def invalidate(self) -> None:
148
+ self._last = None
149
+
150
+
151
+ def run(
152
+ out: TextIO | None = None,
153
+ get_size: Callable[[], tuple[int, int]] | None = None,
154
+ get_time: Callable[[], time.struct_time] | None = None,
155
+ sleep: Callable[[float], None] = time.sleep,
156
+ style: core.Style | None = None,
157
+ now_ms: Callable[[], float] | None = None,
158
+ ) -> int:
159
+ out = out if out is not None else sys.stdout
160
+ get_size = get_size or (lambda: tuple(shutil.get_terminal_size((80, 24))))
161
+ get_time = get_time or time.localtime
162
+ now_ms = now_ms or _wall_ms
163
+
164
+ out.write(_ALT_ON + _CURSOR_HIDE + _WRAP_OFF)
165
+ out.flush()
166
+ painter = Painter(out)
167
+ last_size: tuple[int, int] | None = None
168
+ lead_ms = 0.0
169
+
170
+ def paint() -> None:
171
+ nonlocal last_size
172
+ cols, rows = get_size()
173
+ if (cols, rows) != last_size:
174
+ painter.invalidate()
175
+ last_size = (cols, rows)
176
+ painter.paint(frame_for(get_time(), cols=cols, rows=rows, style=style))
177
+
178
+ try:
179
+ while True:
180
+ paint()
181
+ target = next_second_ms(now_ms())
182
+ now = now_ms()
183
+ wait = sleep_ms(now, target, lead_ms)
184
+ if wait > 0:
185
+ sleep(wait / 1000.0)
186
+ lead_ms = adjust_lead_ms(lead_ms, now_ms() - target)
187
+ except KeyboardInterrupt:
188
+ return 0
189
+ finally:
190
+ out.write(_WRAP_ON + _CURSOR_SHOW + _ALT_OFF)
191
+ out.flush()
192
+
193
+
194
+ def main(argv: list[str] | None = None) -> int:
195
+ ns = parse_args(argv)
196
+ hour_format = ns.hour_format or core.system_hour_format()
197
+ return run(
198
+ style=core.Style(
199
+ padding=ns.padding,
200
+ spacing=ns.spacing,
201
+ hour_format=hour_format,
202
+ ),
203
+ )
term_clock/core.py ADDED
@@ -0,0 +1,345 @@
1
+ """Pure, side-effect-free clock rendering.
2
+
3
+ Everything here is deterministic and unit-tested. The runtime shell in
4
+ ``cli.py`` supplies the current time and terminal size and paints the result.
5
+
6
+ Big digits
7
+ ----------
8
+ Seven-segment bars drawn with full blocks ``█``. Horizontal bars run across,
9
+ vertical bars run down. Convex corners are cut with a 1:1 45-degree stair of
10
+ ``◤ ◥ ◣ ◢`` -- one column per row, no sampling, no off-angle hypotenuses.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import locale
16
+ from dataclasses import dataclass
17
+
18
+ BLOCK = "█"
19
+
20
+ _DIGIT_SEGMENTS = {
21
+ 0: "ABCDEF", 1: "BC", 2: "ABGED", 3: "ABGCD", 4: "FGBC",
22
+ 5: "AFGCD", 6: "AFGCDE", 7: "ABC", 8: "ABCDEFG", 9: "ABCDFG",
23
+ }
24
+
25
+ # 45° cut: (step-x, step-y, glyph on the diagonal)
26
+ _CUT = {
27
+ "UL": (1, 1, "◢"),
28
+ "UR": (-1, 1, "◣"),
29
+ "DL": (1, -1, "◥"),
30
+ "DR": (-1, -1, "◤"),
31
+ }
32
+
33
+
34
+ def format_time(h: int, m: int, s: int, hour_format: str = "24") -> str:
35
+ """Return ``hh:mm:ss`` (zero-padded). ``hour_format`` is ``"24"`` or ``"12"``."""
36
+ if not (0 <= h < 24 and 0 <= m < 60 and 0 <= s < 60):
37
+ raise ValueError(f"time out of range: {h}:{m}:{s}")
38
+ if hour_format == "12":
39
+ h = h % 12 or 12
40
+ elif hour_format != "24":
41
+ raise ValueError(f"unknown hour format: {hour_format}")
42
+ return f"{h:02d}:{m:02d}:{s:02d}"
43
+
44
+
45
+ def hour_period(h: int) -> str:
46
+ """``AM`` before noon, ``PM`` from noon inclusive."""
47
+ if not (0 <= h < 24):
48
+ raise ValueError(f"hour out of range: {h}")
49
+ return "AM" if h < 12 else "PM"
50
+
51
+
52
+ def system_hour_format(t_fmt: str | None = None) -> str:
53
+ """``12`` or ``24`` from the environment's time format; ``24`` if unknown."""
54
+ if t_fmt is None:
55
+ t_fmt = _locale_t_fmt()
56
+ if not t_fmt:
57
+ return "24"
58
+ if "%I" in t_fmt or "%p" in t_fmt or "%r" in t_fmt:
59
+ return "12"
60
+ return "24"
61
+
62
+
63
+ def _locale_t_fmt() -> str | None:
64
+ try:
65
+ locale.setlocale(locale.LC_TIME, "")
66
+ except locale.Error:
67
+ pass
68
+ try:
69
+ return locale.nl_langinfo(locale.T_FMT)
70
+ except (AttributeError, ValueError, locale.Error):
71
+ return None
72
+
73
+
74
+ TEXT_LINE_LIMIT = 7
75
+
76
+
77
+ def choose_mode(rows: int) -> str:
78
+ """``"art"`` when there is real headroom (>= 8 lines), else ``"text"``."""
79
+ return "art" if rows > TEXT_LINE_LIMIT else "text"
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class Style:
84
+ """User-facing layout knobs."""
85
+
86
+ padding: int = 1
87
+ spacing: int = 2
88
+ hour_format: str = "24"
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class Layout:
93
+ """Stroke thickness and the two inner spans of a seven-segment cell."""
94
+
95
+ t: int
96
+ hw: int
97
+ vh: int
98
+ gap: int
99
+ colon_w: int
100
+
101
+ @property
102
+ def digit_w(self) -> int:
103
+ return 2 * self.t + self.hw
104
+
105
+ @property
106
+ def digit_h(self) -> int:
107
+ return 3 * self.t + 2 * self.vh
108
+
109
+
110
+ def colon_metrics(digit_h: int) -> tuple[int, int, int]:
111
+ """``(dot_w, dot_h, gap)`` for two axis-aligned colon dots.
112
+
113
+ The pair spans at most one third of ``digit_h`` (floored at 3 rows so a
114
+ tiny digit still gets two dots with a hole between them).
115
+ """
116
+ cap = digit_h // 3
117
+ span = cap if cap >= 3 else min(3, max(digit_h, 0))
118
+ if span < 3:
119
+ return max(1, span), max(1, span), 0
120
+ gap = 1 if span < 6 else max(1, span // 4)
121
+ dot_h = max(1, (span - gap) // 2)
122
+ gap = span - 2 * dot_h
123
+ if gap < 1:
124
+ dot_h = max(1, (span - 1) // 2)
125
+ gap = span - 2 * dot_h
126
+ # 1-row dots are two cells wide so they stay visible; larger dots stay square.
127
+ dot_w = 2 if dot_h == 1 else dot_h
128
+ return dot_w, dot_h, gap
129
+
130
+
131
+ def colon_width(digit_h: int) -> int:
132
+ return colon_metrics(digit_h)[0]
133
+
134
+
135
+ def _segment_rects(lay: Layout):
136
+ t, w, h = lay.t, lay.digit_w, lay.digit_h
137
+ hg = t + lay.vh
138
+ return {
139
+ "A": (0, w, 0, t),
140
+ "D": (0, w, h - t, h),
141
+ "G": (0, w, hg, hg + t),
142
+ "F": (0, t, 0, hg + t),
143
+ "B": (w - t, w, 0, hg + t),
144
+ "E": (0, t, hg, h),
145
+ "C": (w - t, w, hg, h),
146
+ }
147
+
148
+
149
+ def _fill_rects(ink, rects, names):
150
+ for name in names:
151
+ x0, x1, y0, y1 = rects[name]
152
+ for y in range(y0, y1):
153
+ for x in range(x0, x1):
154
+ ink[y][x] = True
155
+
156
+
157
+ def _convex_corners(ink):
158
+ h, w = len(ink), len(ink[0])
159
+
160
+ def on(x, y):
161
+ return 0 <= x < w and 0 <= y < h and ink[y][x]
162
+
163
+ out = []
164
+ for y in range(h):
165
+ for x in range(w):
166
+ if not ink[y][x]:
167
+ continue
168
+ up, dn = not on(x, y - 1), not on(x, y + 1)
169
+ lf, rt = not on(x - 1, y), not on(x + 1, y)
170
+ if up and rt and not on(x + 1, y - 1) and on(x, y + 1) and on(x - 1, y):
171
+ out.append((x, y, "UR"))
172
+ elif up and lf and not on(x - 1, y - 1) and on(x, y + 1) and on(x + 1, y):
173
+ out.append((x, y, "UL"))
174
+ elif dn and rt and not on(x + 1, y + 1) and on(x, y - 1) and on(x - 1, y):
175
+ out.append((x, y, "DR"))
176
+ elif dn and lf and not on(x - 1, y + 1) and on(x, y - 1) and on(x + 1, y):
177
+ out.append((x, y, "DL"))
178
+ return out
179
+
180
+
181
+ def _cut_corner(grid, cx, cy, n, kind):
182
+ """1:1 45° stair of size ``n``: put the triangle on the diagonal, clear outside."""
183
+ sx, sy, glyph = _CUT[kind]
184
+ h, w = len(grid), len(grid[0])
185
+ for k in range(n):
186
+ x_diag, y = cx + sx * (n - 1 - k), cy + sy * k
187
+ if 0 <= x_diag < w and 0 <= y < h:
188
+ grid[y][x_diag] = glyph
189
+ for j in range(n - 1 - k):
190
+ x = cx + sx * j
191
+ if 0 <= x < w and 0 <= y < h:
192
+ grid[y][x] = " "
193
+
194
+
195
+ def _raster(ink, t: int) -> list[str]:
196
+ grid = [["█" if cell else " " for cell in row] for row in ink]
197
+ n = max(1, t // 2)
198
+ for cx, cy, kind in _convex_corners(ink):
199
+ _cut_corner(grid, cx, cy, n, kind)
200
+ return ["".join(row) for row in grid]
201
+
202
+
203
+ def paint(ch: str, lay: Layout) -> list[str]:
204
+ """One glyph (``0``–``9`` or ``:``) as ``digit_h`` rows of ``digit_w`` / ``colon_w``."""
205
+ h = lay.digit_h
206
+ if ch == ":":
207
+ w = max(1, lay.colon_w)
208
+ ink = [[" "] * w for _ in range(h)]
209
+ dw, dh, dgap = colon_metrics(h)
210
+ dw = min(dw, w)
211
+ span = 2 * dh + dgap
212
+ y0 = max(0, (h - span) // 2)
213
+ x0 = max(0, (w - dw) // 2)
214
+ for y in range(y0, y0 + dh):
215
+ for x in range(x0, x0 + dw):
216
+ ink[y][x] = "█"
217
+ y1 = y0 + dh + dgap
218
+ for y in range(y1, y1 + dh):
219
+ for x in range(x0, x0 + dw):
220
+ if 0 <= y < h:
221
+ ink[y][x] = "█"
222
+ return ["".join(row) for row in ink]
223
+
224
+ w = lay.digit_w
225
+ ink = [[False] * w for _ in range(h)]
226
+ _fill_rects(ink, _segment_rects(lay), _DIGIT_SEGMENTS[int(ch)])
227
+ return _raster(ink, lay.t)
228
+
229
+
230
+ def _clock_width(lay: Layout, time_str: str) -> int:
231
+ w = 0
232
+ for i, ch in enumerate(time_str):
233
+ if i:
234
+ w += lay.gap
235
+ w += lay.colon_w if ch == ":" else lay.digit_w
236
+ return w
237
+
238
+
239
+ def _suffix_span(suffix: str, gap: int) -> int:
240
+ return (gap + len(suffix)) if suffix else 0
241
+
242
+
243
+ def _label(time_str: str, suffix: str) -> str:
244
+ return f"{time_str} {suffix}" if suffix else time_str
245
+
246
+
247
+ MAX_ASPECT = 1.5
248
+
249
+
250
+ def fit(
251
+ rows: int,
252
+ cols: int,
253
+ time_str: str = "12:34:56",
254
+ style: Style | None = None,
255
+ suffix: str = "",
256
+ ) -> Layout | None:
257
+ """Largest layout that fits the inner (padded) frame.
258
+
259
+ Default cell is 5t×5t; each axis may stretch by at most ``MAX_ASPECT``.
260
+ """
261
+ style = style or Style()
262
+ pad = max(0, style.padding)
263
+ gap = max(0, style.spacing)
264
+ rows = max(0, rows - 2 * pad)
265
+ cols = max(0, cols - 2 * pad - _suffix_span(suffix, gap))
266
+ n_d = sum(c != ":" for c in time_str) or 1
267
+ n_c = time_str.count(":")
268
+ n_g = max(0, len(time_str) - 1)
269
+ best: Layout | None = None
270
+ for t in range(1, rows + 1):
271
+ if 5 * t > rows:
272
+ break
273
+ max_h = min(rows, int(5 * t * MAX_ASPECT))
274
+ colon_w = colon_width(max_h)
275
+ lo = Layout(t, 3 * t, t, gap, colon_w)
276
+ if lo.digit_h > rows or _clock_width(lo, time_str) > cols:
277
+ continue
278
+ vh = (max_h - 3 * t) // 2
279
+ if vh < t:
280
+ continue
281
+ max_w = min(
282
+ (cols - n_c * colon_w - n_g * gap) // n_d,
283
+ int(5 * t * MAX_ASPECT),
284
+ )
285
+ hw = max_w - 2 * t
286
+ if hw < 3 * t:
287
+ hw = 3 * t
288
+ lay = Layout(t, hw, vh, gap, colon_width(3 * t + 2 * vh))
289
+ if lay.digit_h > rows or _clock_width(lay, time_str) > cols:
290
+ continue
291
+ best = lay
292
+ return best
293
+
294
+
295
+ def render_art(
296
+ time_str: str,
297
+ rows: int,
298
+ cols: int,
299
+ lay: Layout,
300
+ suffix: str = "",
301
+ ) -> list[str]:
302
+ blocks = [paint(ch, lay) for ch in time_str]
303
+ gap = " " * lay.gap
304
+ body = [gap.join(parts) for parts in zip(*blocks)]
305
+ if suffix and body:
306
+ extra = gap + suffix
307
+ mid = len(body) // 2
308
+ body = [
309
+ line + (extra if i == mid else " " * len(extra))
310
+ for i, line in enumerate(body)
311
+ ]
312
+ bw = len(body[0]) if body else 0
313
+ left = max(0, (cols - bw) // 2)
314
+ top = max(0, (rows - len(body)) // 2)
315
+
316
+ grid = [" " * cols for _ in range(rows)]
317
+ for i, line in enumerate(body):
318
+ if 0 <= top + i < rows:
319
+ grid[top + i] = (" " * left + line)[:cols].ljust(cols)
320
+ return grid
321
+
322
+
323
+ def _render_text(time_str: str, rows: int, cols: int) -> list[str]:
324
+ grid = [" " * cols for _ in range(rows)]
325
+ row = (rows - 1) // 2 if rows else 0
326
+ if 0 <= row < rows:
327
+ grid[row] = time_str.center(cols)[:cols].ljust(cols)
328
+ return grid
329
+
330
+
331
+ def render(
332
+ time_str: str,
333
+ rows: int,
334
+ cols: int,
335
+ style: Style | None = None,
336
+ suffix: str = "",
337
+ ) -> list[str]:
338
+ """Return exactly ``rows`` lines of exactly ``cols`` chars."""
339
+ style = style or Style()
340
+ rows, cols = max(rows, 0), max(cols, 0)
341
+ if choose_mode(rows) == "art":
342
+ lay = fit(rows, cols, time_str, style=style, suffix=suffix)
343
+ if lay is not None:
344
+ return render_art(time_str, rows, cols, lay, suffix=suffix)
345
+ return _render_text(_label(time_str, suffix), rows, cols)
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: term-clock-app
3
+ Version: 1.0.0
4
+ Summary: A terminal digital clock that scales to fill the window.
5
+ Author: Lorenzo Wood
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Lorenzo Wood
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT ANY WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/lorenzowood/term-clock
29
+ Project-URL: Repository, https://github.com/lorenzowood/term-clock
30
+ Project-URL: Issues, https://github.com/lorenzowood/term-clock/issues
31
+ Keywords: clock,terminal,tui,ssh
32
+ Classifier: Development Status :: 5 - Production/Stable
33
+ Classifier: Environment :: Console
34
+ Classifier: Intended Audience :: End Users/Desktop
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Operating System :: POSIX
37
+ Classifier: Programming Language :: Python :: 3
38
+ Classifier: Programming Language :: Python :: 3 :: Only
39
+ Classifier: Programming Language :: Python :: 3.9
40
+ Classifier: Programming Language :: Python :: 3.10
41
+ Classifier: Programming Language :: Python :: 3.11
42
+ Classifier: Programming Language :: Python :: 3.12
43
+ Classifier: Programming Language :: Python :: 3.13
44
+ Classifier: Topic :: Terminals
45
+ Classifier: Topic :: Utilities
46
+ Requires-Python: >=3.9
47
+ Description-Content-Type: text/markdown
48
+ License-File: LICENSE
49
+ Provides-Extra: dev
50
+ Requires-Dist: pytest; extra == "dev"
51
+ Dynamic: license-file
52
+
53
+ # term-clock
54
+
55
+ A digital clock for the terminal. It shows `hh:mm:ss`, stays on the wall-clock
56
+ second, and grows to fill the window.
57
+
58
+ Works over SSH the same way it works locally: alternate screen, no flicker,
59
+ CTRL+C returns you to the prompt.
60
+
61
+ ## Install
62
+
63
+ ```sh
64
+ pip install term-clock-app
65
+ ```
66
+
67
+ The PyPI name is `term-clock-app` because `term-clock` / `termclock` is already taken. The command you run is still `term-clock`.
68
+
69
+ From a clone:
70
+
71
+ ```sh
72
+ pip install -e .
73
+ ```
74
+
75
+ Python 3.9 or newer. No runtime dependencies.
76
+
77
+ ## Run
78
+
79
+ ```sh
80
+ term-clock
81
+ # or
82
+ python -m term_clock
83
+ ```
84
+
85
+ ```sh
86
+ term-clock --padding 1 --spacing 2 # defaults
87
+ term-clock --padding 2 --spacing 4
88
+ term-clock --hour-format 12
89
+ term-clock --hour-format 24
90
+ ```
91
+
92
+ Press **CTRL+C** to quit.
93
+
94
+ ## Options
95
+
96
+ | Flag | Default | Meaning |
97
+ | --- | --- | --- |
98
+ | `--padding N` | `1` | Blank rows and columns on every side |
99
+ | `--spacing N` | `2` | Blank columns between digits |
100
+ | `--hour-format {12,24}` | system clock, or `24` | 12-hour with AM/PM, or 24-hour |
101
+
102
+ The default hour format follows the environment's time locale
103
+ (`LC_TIME` / `T_FMT`) when that can be read. If it cannot, the clock uses 24-hour.
104
+
105
+ ## Display
106
+
107
+ - **7 or fewer lines**: a plain text clock, centred.
108
+ - **8+ lines**: large seven-segment digits drawn with full blocks `█` for
109
+ horizontal and vertical bars, and the four triangles `◤ ◥ ◣ ◢` for 45°
110
+ corner cuts (one column per row — never sampled, never an off-angle
111
+ diagonal). They grow to fill the window (each axis stretched by at most
112
+ 1.5× before the rest becomes centring margin). If the window is too small
113
+ for a readable clock, it falls back to the text version.
114
+ - **Colons**: two small axis-aligned blocks (no diagonals), centred, spanning
115
+ at most one third of the digit height.
116
+ - **12-hour**: AM or PM sits to the right of the digits.
117
+ - **Timing**: after each flip the process sleeps until the next wall-clock
118
+ second, then measures how late or early it woke (target: within 2 ms) and
119
+ shortens or lengthens the following sleep. A resize is picked up on the
120
+ next second. Unchanged cells are not rewritten.
121
+
122
+ ## Develop
123
+
124
+ ```sh
125
+ pip install -e ".[dev]"
126
+ pytest
127
+ ```
128
+
129
+ Pure rendering lives in `term_clock/core.py` (unit-tested). The terminal loop
130
+ is in `term_clock/cli.py`.
131
+
132
+ See `DESIGN.md` for the design and the TDD log.
133
+
134
+ ## License
135
+
136
+ MIT. See `LICENSE`.
@@ -0,0 +1,10 @@
1
+ term_clock/__init__.py,sha256=FCWCxOtWNxoFYGnWNaY0QpBCUSM6p4dAUs3xWModr_w,86
2
+ term_clock/__main__.py,sha256=U5jx9QzjN2RqqGk0yeidxMg7M9UjpfmLguJCGcYl-jg,95
3
+ term_clock/cli.py,sha256=bmAF_NgjmjjBBzmMnjWP9MPEbFoZ1pGeya-ZVuiAn6g,5870
4
+ term_clock/core.py,sha256=OHgHkAOXblanrEJ6lpyiHlAjhh_yi96QBxuzb_aCQe8,10146
5
+ term_clock_app-1.0.0.dist-info/licenses/LICENSE,sha256=Z4k4VhFP4iaRvZllMQ8fgXvNkMT7RN0jXeLm1ZDMkP8,1073
6
+ term_clock_app-1.0.0.dist-info/METADATA,sha256=m2Ai1QxaOnQ0s7VEQ_xnT79holRXrWMIfUPRFJO_mXs,4811
7
+ term_clock_app-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ term_clock_app-1.0.0.dist-info/entry_points.txt,sha256=r__jaa5HuQ9_J0lFs2YV7ImP93t2-MSvIGP1tX4sEKQ,51
9
+ term_clock_app-1.0.0.dist-info/top_level.txt,sha256=RpaMim6Fw_aR_gwWWycwZwjjzo3cQ2BFcHImV0lXUA8,11
10
+ term_clock_app-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ term-clock = term_clock.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lorenzo Wood
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT ANY WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ term_clock