tclock 0.1.2__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.
- tclock/__init__.py +10 -0
- tclock/app.tcss +55 -0
- tclock/cli.py +215 -0
- tclock/config.py +119 -0
- tclock/font.py +76 -0
- tclock/modes/__init__.py +20 -0
- tclock/modes/base.py +75 -0
- tclock/modes/clock.py +39 -0
- tclock/modes/countdown.py +41 -0
- tclock/modes/stopwatch.py +33 -0
- tclock/modes/timer.py +99 -0
- tclock/parsing.py +94 -0
- tclock/py.typed +0 -0
- tclock/resolve.py +163 -0
- tclock/timefmt.py +37 -0
- tclock/ui.py +230 -0
- tclock-0.1.2.dist-info/METADATA +194 -0
- tclock-0.1.2.dist-info/RECORD +21 -0
- tclock-0.1.2.dist-info/WHEEL +4 -0
- tclock-0.1.2.dist-info/entry_points.txt +3 -0
- tclock-0.1.2.dist-info/licenses/LICENSE +24 -0
tclock/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""tclock: a clock, timer, stopwatch and countdown for the terminal."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("tclock")
|
|
7
|
+
except PackageNotFoundError: # running from a checkout that is not installed
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
tclock/app.tcss
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Screen {
|
|
2
|
+
align: center middle;
|
|
3
|
+
overflow: hidden hidden;
|
|
4
|
+
layers: base keybar;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
Screen.-flash {
|
|
8
|
+
background: ansi_green;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
Screen.-flash Label {
|
|
12
|
+
color: ansi_black;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
#body {
|
|
16
|
+
height: auto;
|
|
17
|
+
overflow: hidden hidden;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#header, #footer {
|
|
21
|
+
width: 100%;
|
|
22
|
+
text-align: center;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#header {
|
|
26
|
+
margin-bottom: 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#footer {
|
|
30
|
+
margin-top: 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* The key bar sits on its own layer so showing it never moves the digits. */
|
|
34
|
+
#keybar {
|
|
35
|
+
layer: keybar;
|
|
36
|
+
dock: bottom;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
HelpScreen {
|
|
40
|
+
align: center middle;
|
|
41
|
+
background: $background 60%;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
HelpScreen > #help {
|
|
45
|
+
width: auto;
|
|
46
|
+
height: auto;
|
|
47
|
+
padding: 1 2;
|
|
48
|
+
border: round $primary;
|
|
49
|
+
border-title-align: center;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
HelpScreen #help-keys {
|
|
53
|
+
width: auto;
|
|
54
|
+
height: auto;
|
|
55
|
+
}
|
tclock/cli.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Command-line interface. Parses arguments into :class:`Options` and starts the TUI."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from tclock import __version__, ui
|
|
8
|
+
from tclock.config import load_config
|
|
9
|
+
from tclock.modes import Stopwatch
|
|
10
|
+
from tclock.parsing import ParseError, parse_color, parse_datetime, parse_duration, parse_timezone
|
|
11
|
+
from tclock.resolve import Options, ResolveError, resolve
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
name="tclock",
|
|
15
|
+
help="A clock, timer, stopwatch and countdown in your terminal. Press q to quit.",
|
|
16
|
+
invoke_without_command=True,
|
|
17
|
+
add_completion=False,
|
|
18
|
+
no_args_is_help=False,
|
|
19
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _validate_color(value: str | None) -> str | None:
|
|
24
|
+
if value is not None:
|
|
25
|
+
try:
|
|
26
|
+
parse_color(value)
|
|
27
|
+
except ParseError as exc:
|
|
28
|
+
raise typer.BadParameter(str(exc)) from None
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _validate_durations(values: list[str] | None) -> list[str] | None:
|
|
33
|
+
for value in values or []:
|
|
34
|
+
try:
|
|
35
|
+
parse_duration(value)
|
|
36
|
+
except ParseError as exc:
|
|
37
|
+
raise typer.BadParameter(str(exc)) from None
|
|
38
|
+
return values
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _validate_timezone(value: str | None) -> str | None:
|
|
42
|
+
if value is not None:
|
|
43
|
+
try:
|
|
44
|
+
parse_timezone(value)
|
|
45
|
+
except ParseError as exc:
|
|
46
|
+
raise typer.BadParameter(str(exc)) from None
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _validate_datetime(value: str | None) -> str | None:
|
|
51
|
+
if value is not None:
|
|
52
|
+
try:
|
|
53
|
+
parse_datetime(value)
|
|
54
|
+
except ParseError as exc:
|
|
55
|
+
raise typer.BadParameter(str(exc)) from None
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _version(value: bool) -> None:
|
|
60
|
+
if value:
|
|
61
|
+
typer.echo(f"tclock {__version__}")
|
|
62
|
+
raise typer.Exit()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _options(ctx: typer.Context) -> Options:
|
|
66
|
+
options = ctx.find_root().obj
|
|
67
|
+
assert isinstance(options, Options)
|
|
68
|
+
return options
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.callback()
|
|
72
|
+
def root(
|
|
73
|
+
ctx: typer.Context,
|
|
74
|
+
color: Annotated[
|
|
75
|
+
str | None,
|
|
76
|
+
typer.Option(
|
|
77
|
+
"--color",
|
|
78
|
+
"-c",
|
|
79
|
+
callback=_validate_color,
|
|
80
|
+
help="Digit color: black, red, green, yellow, blue, magenta, cyan, gray, darkgray, "
|
|
81
|
+
"lightred, lightgreen, lightyellow, lightblue, lightmagenta, lightcyan, white, "
|
|
82
|
+
"or #rrggbb.",
|
|
83
|
+
),
|
|
84
|
+
] = None,
|
|
85
|
+
size: Annotated[
|
|
86
|
+
int | None, typer.Option("--size", "-s", min=1, help="Digit size, a positive integer.")
|
|
87
|
+
] = None,
|
|
88
|
+
version: Annotated[
|
|
89
|
+
bool, typer.Option("--version", callback=_version, is_eager=True, help="Show version.")
|
|
90
|
+
] = False,
|
|
91
|
+
) -> None:
|
|
92
|
+
ctx.obj = Options(color=color, size=size)
|
|
93
|
+
if ctx.invoked_subcommand is None:
|
|
94
|
+
_launch(ctx.obj)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command()
|
|
98
|
+
def clock(
|
|
99
|
+
ctx: typer.Context,
|
|
100
|
+
timezone: Annotated[
|
|
101
|
+
str | None,
|
|
102
|
+
typer.Option(
|
|
103
|
+
"--timezone", "-z", callback=_validate_timezone, help='IANA zone, e.g. "Europe/Oslo".'
|
|
104
|
+
),
|
|
105
|
+
] = None,
|
|
106
|
+
no_date: Annotated[bool, typer.Option("--no-date", "-D", help="Hide the date.")] = False,
|
|
107
|
+
no_seconds: Annotated[bool, typer.Option("--no-seconds", "-S", help="Hide seconds.")] = False,
|
|
108
|
+
millis: Annotated[
|
|
109
|
+
bool, typer.Option("--millis", "-m", help="Show tenths of a second.")
|
|
110
|
+
] = False,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Show the current time (the default mode)."""
|
|
113
|
+
options = _options(ctx)
|
|
114
|
+
options.mode = "clock"
|
|
115
|
+
options.timezone = timezone
|
|
116
|
+
options.no_date = no_date
|
|
117
|
+
options.no_seconds = no_seconds
|
|
118
|
+
options.millis = millis
|
|
119
|
+
_launch(options)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command()
|
|
123
|
+
def timer(
|
|
124
|
+
ctx: typer.Context,
|
|
125
|
+
durations: Annotated[
|
|
126
|
+
list[str] | None,
|
|
127
|
+
typer.Option(
|
|
128
|
+
"--duration",
|
|
129
|
+
"-d",
|
|
130
|
+
callback=_validate_durations,
|
|
131
|
+
help="Duration like 30s, 5m, 1h, 2d. Repeat the flag to run several in sequence.",
|
|
132
|
+
),
|
|
133
|
+
] = None,
|
|
134
|
+
titles: Annotated[
|
|
135
|
+
list[str] | None,
|
|
136
|
+
typer.Option("--title", "-t", help="Title per duration. Repeat the flag for several."),
|
|
137
|
+
] = None,
|
|
138
|
+
repeat: Annotated[bool, typer.Option("--repeat", "-r", help="Restart when finished.")] = False,
|
|
139
|
+
no_millis: Annotated[bool, typer.Option("--no-millis", "-M", help="Hide tenths.")] = False,
|
|
140
|
+
paused: Annotated[bool, typer.Option("--paused", "-P", help="Start paused.")] = False,
|
|
141
|
+
auto_quit: Annotated[bool, typer.Option("--quit", "-Q", help="Exit when time is up.")] = False,
|
|
142
|
+
execute: Annotated[
|
|
143
|
+
str | None,
|
|
144
|
+
typer.Option("--execute", "-e", help="Shell command to run when time is up."),
|
|
145
|
+
] = None,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Count down one or more durations. Space pauses and resumes."""
|
|
148
|
+
options = _options(ctx)
|
|
149
|
+
options.mode = "timer"
|
|
150
|
+
options.durations = durations or []
|
|
151
|
+
options.titles = titles or []
|
|
152
|
+
options.repeat = repeat
|
|
153
|
+
options.no_millis = no_millis
|
|
154
|
+
options.paused = paused
|
|
155
|
+
options.auto_quit = auto_quit
|
|
156
|
+
options.execute = execute
|
|
157
|
+
_launch(options)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command()
|
|
161
|
+
def stopwatch(ctx: typer.Context) -> None:
|
|
162
|
+
"""Count up from zero. Space pauses and resumes; the time is printed on exit."""
|
|
163
|
+
options = _options(ctx)
|
|
164
|
+
options.mode = "stopwatch"
|
|
165
|
+
_launch(options)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command()
|
|
169
|
+
def countdown(
|
|
170
|
+
ctx: typer.Context,
|
|
171
|
+
time: Annotated[
|
|
172
|
+
str | None,
|
|
173
|
+
typer.Option(
|
|
174
|
+
"--time",
|
|
175
|
+
"-t",
|
|
176
|
+
callback=_validate_datetime,
|
|
177
|
+
help='Target: "2027-01-01", "20:00", "2026-12-25 20:00:00" or RFC 3339.',
|
|
178
|
+
),
|
|
179
|
+
] = None,
|
|
180
|
+
title: Annotated[str | None, typer.Option("--title", "-T", help="Header text.")] = None,
|
|
181
|
+
continue_on_zero: Annotated[
|
|
182
|
+
bool, typer.Option("--continue", "-c", help="Keep counting past the target.")
|
|
183
|
+
] = False,
|
|
184
|
+
reverse: Annotated[
|
|
185
|
+
bool, typer.Option("--reverse", "-r", help="Count up since the target instead.")
|
|
186
|
+
] = False,
|
|
187
|
+
millis: Annotated[
|
|
188
|
+
bool, typer.Option("--millis", "-m", help="Show tenths of a second.")
|
|
189
|
+
] = False,
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Show the time until (or since) a specific moment."""
|
|
192
|
+
options = _options(ctx)
|
|
193
|
+
options.mode = "countdown"
|
|
194
|
+
options.time = time
|
|
195
|
+
options.title = title
|
|
196
|
+
options.continue_on_zero = continue_on_zero
|
|
197
|
+
options.reverse = reverse
|
|
198
|
+
options.millis = millis
|
|
199
|
+
_launch(options)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _launch(options: Options) -> None:
|
|
203
|
+
config = load_config()
|
|
204
|
+
try:
|
|
205
|
+
resolved = resolve(options, config)
|
|
206
|
+
except ResolveError as exc:
|
|
207
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
208
|
+
raise typer.Exit(code=2) from None
|
|
209
|
+
final = ui.run(resolved.engine, color=resolved.color, size=resolved.size, config=config)
|
|
210
|
+
if isinstance(final, Stopwatch):
|
|
211
|
+
typer.echo(f"Stopwatch time: {final.display_time()}")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def main() -> None:
|
|
215
|
+
app()
|
tclock/config.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""The optional TOML config file.
|
|
2
|
+
|
|
3
|
+
Location: ``platformdirs.user_config_path("tclock") / "config.toml"``, i.e.
|
|
4
|
+
``~/.config/tclock/config.toml`` on Linux, ``~/Library/Application Support/tclock/config.toml``
|
|
5
|
+
on macOS and ``%APPDATA%\\tclock\\config.toml`` on Windows. The schema is the one used by
|
|
6
|
+
the Rust clock-tui. Anything wrong in the file produces a warning and a default, never an
|
|
7
|
+
error.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
import tomllib
|
|
12
|
+
import types
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass, field, fields
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, cast, get_args, get_origin
|
|
17
|
+
|
|
18
|
+
import platformdirs
|
|
19
|
+
|
|
20
|
+
APP_NAME = "tclock"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class DefaultConfig:
|
|
25
|
+
mode: str = "clock"
|
|
26
|
+
color: str = "green"
|
|
27
|
+
size: int = 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class ClockConfig:
|
|
32
|
+
show_date: bool = True
|
|
33
|
+
show_seconds: bool = True
|
|
34
|
+
show_millis: bool = False
|
|
35
|
+
timezone: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class TimerConfig:
|
|
40
|
+
durations: list[str] = field(default_factory=lambda: ["25m", "5m"])
|
|
41
|
+
titles: list[str] = field(default_factory=list)
|
|
42
|
+
repeat: bool = False
|
|
43
|
+
show_millis: bool = True
|
|
44
|
+
start_paused: bool = False
|
|
45
|
+
auto_quit: bool = False
|
|
46
|
+
execute: list[str] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class CountdownConfig:
|
|
51
|
+
time: str | None = None
|
|
52
|
+
title: str | None = None
|
|
53
|
+
show_millis: bool = False
|
|
54
|
+
continue_on_zero: bool = False
|
|
55
|
+
reverse: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(slots=True)
|
|
59
|
+
class Config:
|
|
60
|
+
default: DefaultConfig = field(default_factory=DefaultConfig)
|
|
61
|
+
clock: ClockConfig = field(default_factory=ClockConfig)
|
|
62
|
+
timer: TimerConfig = field(default_factory=TimerConfig)
|
|
63
|
+
countdown: CountdownConfig = field(default_factory=CountdownConfig)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def warn_stderr(message: str) -> None:
|
|
67
|
+
print(message, file=sys.stderr)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def config_path() -> Path:
|
|
71
|
+
return platformdirs.user_config_path(APP_NAME) / "config.toml"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_config(path: Path | None = None, *, warn: Callable[[str], None] = warn_stderr) -> Config:
|
|
75
|
+
"""Read the config file, falling back to defaults for anything missing or wrong."""
|
|
76
|
+
path = path if path is not None else config_path()
|
|
77
|
+
if not path.is_file():
|
|
78
|
+
return Config()
|
|
79
|
+
try:
|
|
80
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
81
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
82
|
+
warn(f"tclock: ignoring {path}: {exc}")
|
|
83
|
+
return Config()
|
|
84
|
+
return Config(
|
|
85
|
+
default=_section(DefaultConfig, data, "default", warn),
|
|
86
|
+
clock=_section(ClockConfig, data, "clock", warn),
|
|
87
|
+
timer=_section(TimerConfig, data, "timer", warn),
|
|
88
|
+
countdown=_section(CountdownConfig, data, "countdown", warn),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _section[T](cls: type[T], data: dict[str, Any], name: str, warn: Callable[[str], None]) -> T:
|
|
93
|
+
raw = data.get(name, {})
|
|
94
|
+
result = cls()
|
|
95
|
+
if not isinstance(raw, dict):
|
|
96
|
+
warn(f"tclock: config section [{name}] must be a table; using defaults")
|
|
97
|
+
return result
|
|
98
|
+
for f in fields(cast(Any, cls)):
|
|
99
|
+
if f.name not in raw:
|
|
100
|
+
continue
|
|
101
|
+
value = raw[f.name]
|
|
102
|
+
if _matches(value, f.type):
|
|
103
|
+
setattr(result, f.name, value)
|
|
104
|
+
else:
|
|
105
|
+
warn(f"tclock: config {name}.{f.name} has the wrong type; using the default")
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _matches(value: object, expected: Any) -> bool:
|
|
110
|
+
if isinstance(expected, types.UnionType):
|
|
111
|
+
return any(_matches(value, arg) for arg in get_args(expected))
|
|
112
|
+
if expected is type(None):
|
|
113
|
+
return value is None
|
|
114
|
+
if get_origin(expected) is list:
|
|
115
|
+
(item_type,) = get_args(expected)
|
|
116
|
+
return isinstance(value, list) and all(_matches(item, item_type) for item in value)
|
|
117
|
+
if expected is int:
|
|
118
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
119
|
+
return isinstance(value, expected)
|
tclock/font.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The "bricks" block font used for the big digits.
|
|
2
|
+
|
|
3
|
+
Each glyph is 6 columns by 5 rows at size 1. A row is described as run lengths that
|
|
4
|
+
alternate off/on starting with "off": ``(0, 6)`` is ``██████``, ``(2, 2)`` is `` ██``,
|
|
5
|
+
``(0, 2, 2, 2)`` is ``██ ██``. Glyph table copied from clock-tui's BricksFont.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
GLYPH_WIDTH = 6
|
|
9
|
+
GLYPH_HEIGHT = 5
|
|
10
|
+
SPACING = 2
|
|
11
|
+
BLOCK = "█"
|
|
12
|
+
|
|
13
|
+
Runs = tuple[int, ...]
|
|
14
|
+
|
|
15
|
+
GLYPHS: dict[str, tuple[Runs, Runs, Runs, Runs, Runs]] = {
|
|
16
|
+
"0": ((0, 6), (0, 2, 2, 2), (0, 2, 2, 2), (0, 2, 2, 2), (0, 6)),
|
|
17
|
+
"1": ((0, 4), (2, 2), (2, 2), (2, 2), (0, 6)),
|
|
18
|
+
"2": ((0, 6), (4, 2), (0, 6), (0, 2), (0, 6)),
|
|
19
|
+
"3": ((0, 6), (4, 2), (0, 6), (4, 2), (0, 6)),
|
|
20
|
+
"4": ((0, 2, 2, 2), (0, 2, 2, 2), (0, 6), (4, 2), (4, 2)),
|
|
21
|
+
"5": ((0, 6), (0, 2), (0, 6), (4, 2), (0, 6)),
|
|
22
|
+
"6": ((0, 6), (0, 2), (0, 6), (0, 2, 2, 2), (0, 6)),
|
|
23
|
+
"7": ((0, 6), (4, 2), (4, 2), (4, 2), (4, 2)),
|
|
24
|
+
"8": ((0, 6), (0, 2, 2, 2), (0, 6), (0, 2, 2, 2), (0, 6)),
|
|
25
|
+
"9": ((0, 6), (0, 2, 2, 2), (0, 6), (4, 2), (0, 6)),
|
|
26
|
+
":": ((), (2, 2), (), (2, 2), ()),
|
|
27
|
+
".": ((), (), (), (), (2, 2)),
|
|
28
|
+
"-": ((), (), (0, 6), (), ()),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _row(runs: Runs, size: int) -> str:
|
|
33
|
+
cells: list[str] = []
|
|
34
|
+
on = False
|
|
35
|
+
for length in runs:
|
|
36
|
+
cells.append((BLOCK if on else " ") * (length * size))
|
|
37
|
+
on = not on
|
|
38
|
+
return "".join(cells).ljust(GLYPH_WIDTH * size)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _glyph(ch: str, size: int) -> list[str]:
|
|
42
|
+
runs = GLYPHS.get(ch)
|
|
43
|
+
if runs is None:
|
|
44
|
+
return [" " * (GLYPH_WIDTH * size)] * (GLYPH_HEIGHT * size)
|
|
45
|
+
rows: list[str] = []
|
|
46
|
+
for row_runs in runs:
|
|
47
|
+
rows.extend([_row(row_runs, size)] * size)
|
|
48
|
+
return rows
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def text_width(text: str, size: int) -> int:
|
|
52
|
+
"""Total columns ``render(text, size)`` occupies."""
|
|
53
|
+
if not text:
|
|
54
|
+
return 0
|
|
55
|
+
return len(text) * (GLYPH_WIDTH * size + SPACING) - SPACING
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def text_width_rows(rows: list[str]) -> int:
|
|
59
|
+
"""Width of already rendered rows (all rows of a glyph string are equally wide)."""
|
|
60
|
+
return len(rows[0]) if rows else 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def render(text: str, size: int = 1) -> list[str]:
|
|
64
|
+
"""Render ``text`` as ``GLYPH_HEIGHT * size`` rows of block characters.
|
|
65
|
+
|
|
66
|
+
Glyphs are separated by ``SPACING`` blank columns (not scaled). Characters without
|
|
67
|
+
a glyph render as blank space of glyph width, so layout stays stable.
|
|
68
|
+
"""
|
|
69
|
+
if size < 1:
|
|
70
|
+
raise ValueError(f"size must be >= 1, got {size}")
|
|
71
|
+
height = GLYPH_HEIGHT * size
|
|
72
|
+
if not text:
|
|
73
|
+
return [""] * height
|
|
74
|
+
glyphs = [_glyph(ch, size) for ch in text]
|
|
75
|
+
gap = " " * SPACING
|
|
76
|
+
return [gap.join(glyph[i] for glyph in glyphs) for i in range(height)]
|
tclock/modes/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Mode engines: clock, timer, stopwatch and countdown."""
|
|
2
|
+
|
|
3
|
+
from tclock.modes.base import PAUSED_FOOTER, ElapsedClock, Frame, Mode, Pausable, wall_clock_ms
|
|
4
|
+
from tclock.modes.clock import Clock
|
|
5
|
+
from tclock.modes.countdown import Countdown
|
|
6
|
+
from tclock.modes.stopwatch import Stopwatch
|
|
7
|
+
from tclock.modes.timer import Timer
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"PAUSED_FOOTER",
|
|
11
|
+
"Clock",
|
|
12
|
+
"Countdown",
|
|
13
|
+
"ElapsedClock",
|
|
14
|
+
"Frame",
|
|
15
|
+
"Mode",
|
|
16
|
+
"Pausable",
|
|
17
|
+
"Stopwatch",
|
|
18
|
+
"Timer",
|
|
19
|
+
"wall_clock_ms",
|
|
20
|
+
]
|
tclock/modes/base.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Shared types for mode engines.
|
|
2
|
+
|
|
3
|
+
Engines are plain Python: they read an injected millisecond clock and return a
|
|
4
|
+
:class:`Frame` describing what the UI should show. They never import Textual.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
PAUSED_FOOTER = "PAUSED (press <SPACE> to resume)"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def wall_clock_ms() -> int:
|
|
16
|
+
"""Current wall-clock time in whole milliseconds."""
|
|
17
|
+
return time.time_ns() // 1_000_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Frame:
|
|
22
|
+
"""One tick's worth of display state."""
|
|
23
|
+
|
|
24
|
+
text: str | None
|
|
25
|
+
"""Digits to draw, or ``None`` to leave the digit area blank (blink-off phase)."""
|
|
26
|
+
header: str | None = None
|
|
27
|
+
footer: str | None = None
|
|
28
|
+
flash: bool = False
|
|
29
|
+
"""Invert colors (green background) - used when a timer has run out."""
|
|
30
|
+
finished: bool = False
|
|
31
|
+
"""The app should exit."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@runtime_checkable
|
|
35
|
+
class Mode(Protocol):
|
|
36
|
+
def snapshot(self) -> Frame: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@runtime_checkable
|
|
40
|
+
class Pausable(Protocol):
|
|
41
|
+
def is_paused(self) -> bool: ...
|
|
42
|
+
|
|
43
|
+
def toggle_paused(self) -> None: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ElapsedClock:
|
|
47
|
+
"""Accumulates elapsed milliseconds with pause/resume."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, now_ms: Callable[[], int] = wall_clock_ms, *, running: bool = True) -> None:
|
|
50
|
+
self._now = now_ms
|
|
51
|
+
self._accumulated_ms = 0
|
|
52
|
+
self._started_at_ms: int | None = now_ms() if running else None
|
|
53
|
+
|
|
54
|
+
def elapsed_ms(self) -> int:
|
|
55
|
+
if self._started_at_ms is None:
|
|
56
|
+
return self._accumulated_ms
|
|
57
|
+
return self._accumulated_ms + (self._now() - self._started_at_ms)
|
|
58
|
+
|
|
59
|
+
def is_paused(self) -> bool:
|
|
60
|
+
return self._started_at_ms is None
|
|
61
|
+
|
|
62
|
+
def pause(self) -> None:
|
|
63
|
+
if self._started_at_ms is not None:
|
|
64
|
+
self._accumulated_ms += self._now() - self._started_at_ms
|
|
65
|
+
self._started_at_ms = None
|
|
66
|
+
|
|
67
|
+
def resume(self) -> None:
|
|
68
|
+
if self._started_at_ms is None:
|
|
69
|
+
self._started_at_ms = self._now()
|
|
70
|
+
|
|
71
|
+
def toggle_paused(self) -> None:
|
|
72
|
+
if self.is_paused():
|
|
73
|
+
self.resume()
|
|
74
|
+
else:
|
|
75
|
+
self.pause()
|
tclock/modes/clock.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Clock mode: the current time, optionally in another timezone."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from datetime import datetime, tzinfo
|
|
5
|
+
from zoneinfo import ZoneInfo
|
|
6
|
+
|
|
7
|
+
from tclock.modes.base import Frame
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Clock:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
*,
|
|
14
|
+
show_date: bool = True,
|
|
15
|
+
show_secs: bool = True,
|
|
16
|
+
show_millis: bool = False,
|
|
17
|
+
tz: ZoneInfo | None = None,
|
|
18
|
+
now: Callable[[tzinfo | None], datetime] = datetime.now,
|
|
19
|
+
) -> None:
|
|
20
|
+
self.show_date = show_date
|
|
21
|
+
self.show_secs = show_secs
|
|
22
|
+
self.show_millis = show_millis
|
|
23
|
+
self.tz = tz
|
|
24
|
+
self._now = now
|
|
25
|
+
|
|
26
|
+
def snapshot(self) -> Frame:
|
|
27
|
+
now = self._now(self.tz)
|
|
28
|
+
if self.show_millis:
|
|
29
|
+
text = f"{now:%H:%M:%S}.{now.microsecond // 100_000}"
|
|
30
|
+
elif self.show_secs:
|
|
31
|
+
text = f"{now:%H:%M:%S}"
|
|
32
|
+
else:
|
|
33
|
+
text = f"{now:%H:%M}"
|
|
34
|
+
header = None
|
|
35
|
+
if self.show_date:
|
|
36
|
+
header = f"{now:%Y-%m-%d}"
|
|
37
|
+
if self.tz is not None:
|
|
38
|
+
header += f" {self.tz.key}"
|
|
39
|
+
return Frame(text=text, header=header)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Countdown mode: time until (or, reversed, since) a fixed moment."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from tclock.modes.base import Frame, wall_clock_ms
|
|
7
|
+
from tclock.timefmt import DurationFormat, format_duration
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Countdown:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
target: datetime,
|
|
14
|
+
*,
|
|
15
|
+
title: str | None = None,
|
|
16
|
+
continue_on_zero: bool = False,
|
|
17
|
+
reverse: bool = False,
|
|
18
|
+
fmt: DurationFormat = DurationFormat.HOUR_MIN_SEC,
|
|
19
|
+
now_ms: Callable[[], int] = wall_clock_ms,
|
|
20
|
+
) -> None:
|
|
21
|
+
if target.tzinfo is None:
|
|
22
|
+
target = target.astimezone()
|
|
23
|
+
self.target_ms = int(target.timestamp() * 1000)
|
|
24
|
+
self.title = title
|
|
25
|
+
self.continue_on_zero = continue_on_zero
|
|
26
|
+
self.reverse = reverse
|
|
27
|
+
self.fmt = fmt
|
|
28
|
+
self._now = now_ms
|
|
29
|
+
|
|
30
|
+
def remaining_ms(self) -> int:
|
|
31
|
+
remaining = self.target_ms - self._now()
|
|
32
|
+
return -remaining if self.reverse else remaining
|
|
33
|
+
|
|
34
|
+
def snapshot(self) -> Frame:
|
|
35
|
+
remaining = self.remaining_ms()
|
|
36
|
+
if remaining < 0 and not self.continue_on_zero:
|
|
37
|
+
# Blink "0:00" at 1 Hz once the moment has passed.
|
|
38
|
+
if (-remaining) % 1000 < 500:
|
|
39
|
+
return Frame(text=None, header=self.title)
|
|
40
|
+
return Frame(text=format_duration(0, self.fmt), header=self.title)
|
|
41
|
+
return Frame(text=format_duration(remaining, self.fmt), header=self.title)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Stopwatch mode: counts up from zero, pausable with Space."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
from tclock.modes.base import PAUSED_FOOTER, ElapsedClock, Frame, wall_clock_ms
|
|
6
|
+
from tclock.timefmt import DurationFormat, format_duration
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Stopwatch:
|
|
10
|
+
def __init__(self, *, now_ms: Callable[[], int] = wall_clock_ms) -> None:
|
|
11
|
+
self._clock = ElapsedClock(now_ms)
|
|
12
|
+
|
|
13
|
+
def elapsed_ms(self) -> int:
|
|
14
|
+
return self._clock.elapsed_ms()
|
|
15
|
+
|
|
16
|
+
def display_time(self) -> str:
|
|
17
|
+
return format_duration(self.elapsed_ms(), DurationFormat.HOUR_MIN_SEC_DECI)
|
|
18
|
+
|
|
19
|
+
def is_paused(self) -> bool:
|
|
20
|
+
return self._clock.is_paused()
|
|
21
|
+
|
|
22
|
+
def pause(self) -> None:
|
|
23
|
+
self._clock.pause()
|
|
24
|
+
|
|
25
|
+
def resume(self) -> None:
|
|
26
|
+
self._clock.resume()
|
|
27
|
+
|
|
28
|
+
def toggle_paused(self) -> None:
|
|
29
|
+
self._clock.toggle_paused()
|
|
30
|
+
|
|
31
|
+
def snapshot(self) -> Frame:
|
|
32
|
+
footer = PAUSED_FOOTER if self.is_paused() else None
|
|
33
|
+
return Frame(text=self.display_time(), footer=footer)
|