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 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)]
@@ -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)