term-clock-app 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,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,84 @@
1
+ # term-clock
2
+
3
+ A digital clock for the terminal. It shows `hh:mm:ss`, stays on the wall-clock
4
+ second, and grows to fill the window.
5
+
6
+ Works over SSH the same way it works locally: alternate screen, no flicker,
7
+ CTRL+C returns you to the prompt.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pip install term-clock-app
13
+ ```
14
+
15
+ The PyPI name is `term-clock-app` because `term-clock` / `termclock` is already taken. The command you run is still `term-clock`.
16
+
17
+ From a clone:
18
+
19
+ ```sh
20
+ pip install -e .
21
+ ```
22
+
23
+ Python 3.9 or newer. No runtime dependencies.
24
+
25
+ ## Run
26
+
27
+ ```sh
28
+ term-clock
29
+ # or
30
+ python -m term_clock
31
+ ```
32
+
33
+ ```sh
34
+ term-clock --padding 1 --spacing 2 # defaults
35
+ term-clock --padding 2 --spacing 4
36
+ term-clock --hour-format 12
37
+ term-clock --hour-format 24
38
+ ```
39
+
40
+ Press **CTRL+C** to quit.
41
+
42
+ ## Options
43
+
44
+ | Flag | Default | Meaning |
45
+ | --- | --- | --- |
46
+ | `--padding N` | `1` | Blank rows and columns on every side |
47
+ | `--spacing N` | `2` | Blank columns between digits |
48
+ | `--hour-format {12,24}` | system clock, or `24` | 12-hour with AM/PM, or 24-hour |
49
+
50
+ The default hour format follows the environment's time locale
51
+ (`LC_TIME` / `T_FMT`) when that can be read. If it cannot, the clock uses 24-hour.
52
+
53
+ ## Display
54
+
55
+ - **7 or fewer lines**: a plain text clock, centred.
56
+ - **8+ lines**: large seven-segment digits drawn with full blocks `█` for
57
+ horizontal and vertical bars, and the four triangles `◤ ◥ ◣ ◢` for 45°
58
+ corner cuts (one column per row — never sampled, never an off-angle
59
+ diagonal). They grow to fill the window (each axis stretched by at most
60
+ 1.5× before the rest becomes centring margin). If the window is too small
61
+ for a readable clock, it falls back to the text version.
62
+ - **Colons**: two small axis-aligned blocks (no diagonals), centred, spanning
63
+ at most one third of the digit height.
64
+ - **12-hour**: AM or PM sits to the right of the digits.
65
+ - **Timing**: after each flip the process sleeps until the next wall-clock
66
+ second, then measures how late or early it woke (target: within 2 ms) and
67
+ shortens or lengthens the following sleep. A resize is picked up on the
68
+ next second. Unchanged cells are not rewritten.
69
+
70
+ ## Develop
71
+
72
+ ```sh
73
+ pip install -e ".[dev]"
74
+ pytest
75
+ ```
76
+
77
+ Pure rendering lives in `term_clock/core.py` (unit-tested). The terminal loop
78
+ is in `term_clock/cli.py`.
79
+
80
+ See `DESIGN.md` for the design and the TDD log.
81
+
82
+ ## License
83
+
84
+ MIT. See `LICENSE`.
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "term-clock-app"
7
+ version = "1.0.0"
8
+ description = "A terminal digital clock that scales to fill the window."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "Lorenzo Wood" }]
13
+ keywords = ["clock", "terminal", "tui", "ssh"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Environment :: Console",
17
+ "Intended Audience :: End Users/Desktop",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: POSIX",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Terminals",
28
+ "Topic :: Utilities",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/lorenzowood/term-clock"
33
+ Repository = "https://github.com/lorenzowood/term-clock"
34
+ Issues = "https://github.com/lorenzowood/term-clock/issues"
35
+
36
+ [project.scripts]
37
+ term-clock = "term_clock.cli:main"
38
+
39
+ [project.optional-dependencies]
40
+ dev = ["pytest"]
41
+
42
+ [tool.setuptools]
43
+ packages = ["term_clock"]
44
+
45
+ [tool.pytest.ini_options]
46
+ pythonpath = ["."]
47
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """A terminal digital clock that scales to fill the window."""
2
+
3
+ __version__ = "1.0.0"
@@ -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:]))
@@ -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
+ )