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 +3 -0
- term_clock/__main__.py +6 -0
- term_clock/cli.py +203 -0
- term_clock/core.py +345 -0
- term_clock_app-1.0.0.dist-info/METADATA +136 -0
- term_clock_app-1.0.0.dist-info/RECORD +10 -0
- term_clock_app-1.0.0.dist-info/WHEEL +5 -0
- term_clock_app-1.0.0.dist-info/entry_points.txt +2 -0
- term_clock_app-1.0.0.dist-info/licenses/LICENSE +21 -0
- term_clock_app-1.0.0.dist-info/top_level.txt +1 -0
term_clock/__init__.py
ADDED
term_clock/__main__.py
ADDED
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,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
|