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/modes/timer.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Timer mode: counts down one or more durations, flashes when done."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Sequence
|
|
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 Timer:
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
durations_ms: Sequence[int],
|
|
13
|
+
*,
|
|
14
|
+
titles: Sequence[str] = (),
|
|
15
|
+
repeat: bool = False,
|
|
16
|
+
fmt: DurationFormat = DurationFormat.HOUR_MIN_SEC_DECI,
|
|
17
|
+
paused: bool = False,
|
|
18
|
+
auto_quit: bool = False,
|
|
19
|
+
execute: str | None = None,
|
|
20
|
+
now_ms: Callable[[], int] = wall_clock_ms,
|
|
21
|
+
) -> None:
|
|
22
|
+
if not durations_ms:
|
|
23
|
+
raise ValueError("timer needs at least one duration")
|
|
24
|
+
if any(d <= 0 for d in durations_ms):
|
|
25
|
+
raise ValueError("timer durations must be positive")
|
|
26
|
+
self.durations_ms = list(durations_ms)
|
|
27
|
+
self.titles = list(titles)
|
|
28
|
+
self.repeat = repeat
|
|
29
|
+
self.fmt = fmt
|
|
30
|
+
self.auto_quit = auto_quit
|
|
31
|
+
self.execute = execute or None
|
|
32
|
+
self.execute_pending = False
|
|
33
|
+
"""The UI should run :attr:`execute` and call :meth:`set_execute_result`."""
|
|
34
|
+
self.execute_result: str | None = None
|
|
35
|
+
self._clock = ElapsedClock(now_ms, running=not paused)
|
|
36
|
+
|
|
37
|
+
def remaining(self) -> tuple[int, int]:
|
|
38
|
+
"""(milliseconds left in the current duration, index of that duration).
|
|
39
|
+
|
|
40
|
+
After the last duration (without ``repeat``) the remaining value goes negative
|
|
41
|
+
and keeps counting down, so callers can show how long ago the timer ended.
|
|
42
|
+
"""
|
|
43
|
+
passed = self._clock.elapsed_ms()
|
|
44
|
+
if self.repeat:
|
|
45
|
+
passed %= sum(self.durations_ms)
|
|
46
|
+
idx = 0
|
|
47
|
+
checkpoint = self.durations_ms[0]
|
|
48
|
+
while checkpoint < passed and idx < len(self.durations_ms) - 1:
|
|
49
|
+
idx += 1
|
|
50
|
+
checkpoint += self.durations_ms[idx]
|
|
51
|
+
return checkpoint - passed, idx
|
|
52
|
+
|
|
53
|
+
def title(self, idx: int) -> str | None:
|
|
54
|
+
if not self.titles:
|
|
55
|
+
return None
|
|
56
|
+
return self.titles[min(idx, len(self.titles) - 1)]
|
|
57
|
+
|
|
58
|
+
def set_execute_result(self, result: str) -> None:
|
|
59
|
+
self.execute_result = result
|
|
60
|
+
self.execute_pending = False
|
|
61
|
+
|
|
62
|
+
def is_finished(self) -> bool:
|
|
63
|
+
return self.auto_quit and self.execute_result is not None
|
|
64
|
+
|
|
65
|
+
def is_paused(self) -> bool:
|
|
66
|
+
return self._clock.is_paused()
|
|
67
|
+
|
|
68
|
+
def pause(self) -> None:
|
|
69
|
+
self._clock.pause()
|
|
70
|
+
|
|
71
|
+
def resume(self) -> None:
|
|
72
|
+
self._clock.resume()
|
|
73
|
+
|
|
74
|
+
def toggle_paused(self) -> None:
|
|
75
|
+
self._clock.toggle_paused()
|
|
76
|
+
|
|
77
|
+
def snapshot(self) -> Frame:
|
|
78
|
+
remaining, idx = self.remaining()
|
|
79
|
+
header = self.title(idx)
|
|
80
|
+
footer = PAUSED_FOOTER if self.is_paused() else self.execute_result
|
|
81
|
+
if remaining >= 0:
|
|
82
|
+
return Frame(text=format_duration(remaining, self.fmt), header=header, footer=footer)
|
|
83
|
+
|
|
84
|
+
# The timer has run out.
|
|
85
|
+
if self.execute_result is None and not self.execute_pending:
|
|
86
|
+
if self.execute:
|
|
87
|
+
self.execute_pending = True
|
|
88
|
+
else:
|
|
89
|
+
self.execute_result = ""
|
|
90
|
+
footer = PAUSED_FOOTER if self.is_paused() else self.execute_result
|
|
91
|
+
overrun = -remaining
|
|
92
|
+
flash = overrun % 1000 < 500
|
|
93
|
+
return Frame(
|
|
94
|
+
text=format_duration(overrun, self.fmt) if flash else None,
|
|
95
|
+
header=header,
|
|
96
|
+
footer=footer,
|
|
97
|
+
flash=flash,
|
|
98
|
+
finished=self.is_finished(),
|
|
99
|
+
)
|
tclock/parsing.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Parsers for CLI and config values. All failures raise :class:`ParseError`."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ParseError(ValueError):
|
|
9
|
+
"""A user-supplied value could not be interpreted."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_DURATION_RE = re.compile(r"^(\d+)([smhd])$", re.IGNORECASE)
|
|
13
|
+
_UNIT_MS = {"s": 1_000, "m": 60_000, "h": 3_600_000, "d": 86_400_000}
|
|
14
|
+
|
|
15
|
+
_HEX_RE = re.compile(r"^#[0-9a-f]{6}$")
|
|
16
|
+
|
|
17
|
+
# clock-tui color names -> Textual color strings (ratatui's 16 ANSI colors).
|
|
18
|
+
COLOR_NAMES: dict[str, str] = {
|
|
19
|
+
"black": "ansi_black",
|
|
20
|
+
"red": "ansi_red",
|
|
21
|
+
"green": "ansi_green",
|
|
22
|
+
"yellow": "ansi_yellow",
|
|
23
|
+
"blue": "ansi_blue",
|
|
24
|
+
"magenta": "ansi_magenta",
|
|
25
|
+
"cyan": "ansi_cyan",
|
|
26
|
+
"gray": "ansi_white",
|
|
27
|
+
"darkgray": "ansi_bright_black",
|
|
28
|
+
"lightred": "ansi_bright_red",
|
|
29
|
+
"lightgreen": "ansi_bright_green",
|
|
30
|
+
"lightyellow": "ansi_bright_yellow",
|
|
31
|
+
"lightblue": "ansi_bright_blue",
|
|
32
|
+
"lightmagenta": "ansi_bright_magenta",
|
|
33
|
+
"lightcyan": "ansi_bright_cyan",
|
|
34
|
+
"white": "ansi_bright_white",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_NAIVE_FORMATS = ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S")
|
|
38
|
+
_TIME_FORMATS = ("%H:%M", "%H:%M:%S")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_duration(text: str) -> int:
|
|
42
|
+
"""``10s``, ``5m``, ``1h``, ``2d`` (case-insensitive) -> milliseconds."""
|
|
43
|
+
match = _DURATION_RE.match(text)
|
|
44
|
+
if match is None:
|
|
45
|
+
raise ParseError(f"{text!r} is not a valid duration (examples: 30s, 5m, 1h, 2d)")
|
|
46
|
+
number, unit = match.groups()
|
|
47
|
+
return int(number) * _UNIT_MS[unit.lower()]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_color(text: str) -> str:
|
|
51
|
+
"""A clock-tui color name or ``#rrggbb`` -> Textual color string."""
|
|
52
|
+
lowered = text.strip().lower()
|
|
53
|
+
if lowered in COLOR_NAMES:
|
|
54
|
+
return COLOR_NAMES[lowered]
|
|
55
|
+
if _HEX_RE.match(lowered):
|
|
56
|
+
return lowered
|
|
57
|
+
names = ", ".join(COLOR_NAMES)
|
|
58
|
+
raise ParseError(f"{text!r} is not a valid color; use #rrggbb or one of: {names}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_datetime(text: str, *, today: date | None = None) -> datetime:
|
|
62
|
+
"""Parse ``HH:MM``, ``HH:MM:SS``, ``YYYY-MM-DD``, ``YYYY-MM-DD HH:MM:SS`` or RFC 3339.
|
|
63
|
+
|
|
64
|
+
Naive forms are interpreted in the local timezone. The result is always aware.
|
|
65
|
+
"""
|
|
66
|
+
text = text.strip()
|
|
67
|
+
today = today or datetime.now().date()
|
|
68
|
+
for fmt in _TIME_FORMATS:
|
|
69
|
+
try:
|
|
70
|
+
parsed_time = datetime.strptime(text, fmt).time()
|
|
71
|
+
except ValueError:
|
|
72
|
+
continue
|
|
73
|
+
return datetime.combine(today, parsed_time).astimezone()
|
|
74
|
+
for fmt in _NAIVE_FORMATS:
|
|
75
|
+
try:
|
|
76
|
+
return datetime.strptime(text, fmt).astimezone()
|
|
77
|
+
except ValueError:
|
|
78
|
+
continue
|
|
79
|
+
try:
|
|
80
|
+
parsed = datetime.fromisoformat(text)
|
|
81
|
+
except ValueError:
|
|
82
|
+
raise ParseError(
|
|
83
|
+
f"{text!r} is not a valid time; use HH:MM, YYYY-MM-DD, "
|
|
84
|
+
"'YYYY-MM-DD HH:MM:SS' or RFC 3339"
|
|
85
|
+
) from None
|
|
86
|
+
return parsed.astimezone()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_timezone(text: str) -> ZoneInfo:
|
|
90
|
+
"""An IANA zone key such as ``Europe/Oslo``."""
|
|
91
|
+
try:
|
|
92
|
+
return ZoneInfo(text.strip())
|
|
93
|
+
except ZoneInfoNotFoundError, ValueError:
|
|
94
|
+
raise ParseError(f"{text!r} is not a known timezone (example: Europe/Oslo)") from None
|
tclock/py.typed
ADDED
|
File without changes
|
tclock/resolve.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Merge CLI options with the config file and build the mode engine.
|
|
2
|
+
|
|
3
|
+
Precedence: CLI flag > config file > built-in default. CLI booleans are "set" flags, so
|
|
4
|
+
they can only turn a behaviour on (``flag or config_value``), matching clock-tui.
|
|
5
|
+
CLI values arrive already validated by the CLI layer; config values are validated here
|
|
6
|
+
with a warning and a fallback for each bad key.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from zoneinfo import ZoneInfo
|
|
12
|
+
|
|
13
|
+
from tclock.config import Config, warn_stderr
|
|
14
|
+
from tclock.modes import Clock, Countdown, Mode, Stopwatch, Timer
|
|
15
|
+
from tclock.parsing import ParseError, parse_color, parse_datetime, parse_duration, parse_timezone
|
|
16
|
+
from tclock.timefmt import DurationFormat
|
|
17
|
+
|
|
18
|
+
MODES = ("clock", "timer", "stopwatch", "countdown")
|
|
19
|
+
|
|
20
|
+
Warn = Callable[[str], None]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ResolveError(Exception):
|
|
24
|
+
"""The combination of CLI options and config cannot produce a runnable mode."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True, kw_only=True)
|
|
28
|
+
class Options:
|
|
29
|
+
"""What the user asked for on the command line. ``None``/``False``/``[]`` = not given."""
|
|
30
|
+
|
|
31
|
+
mode: str | None = None
|
|
32
|
+
color: str | None = None
|
|
33
|
+
size: int | None = None
|
|
34
|
+
# clock
|
|
35
|
+
timezone: str | None = None
|
|
36
|
+
no_date: bool = False
|
|
37
|
+
no_seconds: bool = False
|
|
38
|
+
millis: bool = False
|
|
39
|
+
# timer
|
|
40
|
+
durations: list[str] = field(default_factory=list)
|
|
41
|
+
titles: list[str] = field(default_factory=list)
|
|
42
|
+
repeat: bool = False
|
|
43
|
+
no_millis: bool = False
|
|
44
|
+
paused: bool = False
|
|
45
|
+
auto_quit: bool = False
|
|
46
|
+
execute: str | None = None
|
|
47
|
+
# countdown
|
|
48
|
+
time: str | None = None
|
|
49
|
+
title: str | None = None
|
|
50
|
+
continue_on_zero: bool = False
|
|
51
|
+
reverse: bool = False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True)
|
|
55
|
+
class Resolved:
|
|
56
|
+
engine: Mode
|
|
57
|
+
color: str
|
|
58
|
+
size: int
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve(options: Options, config: Config, *, warn: Warn = warn_stderr) -> Resolved:
|
|
62
|
+
mode = options.mode or config.default.mode
|
|
63
|
+
if mode not in MODES:
|
|
64
|
+
warn(f"tclock: unknown mode {mode!r} in config; using clock")
|
|
65
|
+
mode = "clock"
|
|
66
|
+
|
|
67
|
+
engine: Mode
|
|
68
|
+
if mode == "timer":
|
|
69
|
+
engine = build_timer(options, config, warn=warn)
|
|
70
|
+
elif mode == "stopwatch":
|
|
71
|
+
engine = build_stopwatch()
|
|
72
|
+
elif mode == "countdown":
|
|
73
|
+
engine = build_countdown(options, config, warn=warn)
|
|
74
|
+
else:
|
|
75
|
+
engine = build_clock(options, config, warn=warn)
|
|
76
|
+
|
|
77
|
+
return Resolved(
|
|
78
|
+
engine=engine, color=_color(options, config, warn), size=_size(options, config, warn)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _color(options: Options, config: Config, warn: Warn) -> str:
|
|
83
|
+
if options.color is not None:
|
|
84
|
+
return parse_color(options.color)
|
|
85
|
+
try:
|
|
86
|
+
return parse_color(config.default.color)
|
|
87
|
+
except ParseError as exc:
|
|
88
|
+
warn(f"tclock: config default.color: {exc}; using green")
|
|
89
|
+
return parse_color("green")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _size(options: Options, config: Config, warn: Warn) -> int:
|
|
93
|
+
if options.size is not None:
|
|
94
|
+
return options.size
|
|
95
|
+
if config.default.size < 1:
|
|
96
|
+
warn(f"tclock: config default.size must be >= 1, got {config.default.size}; using 1")
|
|
97
|
+
return 1
|
|
98
|
+
return config.default.size
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_clock(options: Options, config: Config, *, warn: Warn = warn_stderr) -> Clock:
|
|
102
|
+
tz: ZoneInfo | None = None
|
|
103
|
+
if options.timezone is not None:
|
|
104
|
+
tz = parse_timezone(options.timezone)
|
|
105
|
+
elif config.clock.timezone is not None:
|
|
106
|
+
try:
|
|
107
|
+
tz = parse_timezone(config.clock.timezone)
|
|
108
|
+
except ParseError as exc:
|
|
109
|
+
warn(f"tclock: config clock.timezone: {exc}; using local time")
|
|
110
|
+
return Clock(
|
|
111
|
+
show_date=not options.no_date and config.clock.show_date,
|
|
112
|
+
show_secs=not options.no_seconds and config.clock.show_seconds,
|
|
113
|
+
show_millis=options.millis or config.clock.show_millis,
|
|
114
|
+
tz=tz,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def build_timer(options: Options, config: Config, *, warn: Warn = warn_stderr) -> Timer:
|
|
119
|
+
if options.durations:
|
|
120
|
+
durations_ms = [parse_duration(d) for d in options.durations]
|
|
121
|
+
else:
|
|
122
|
+
durations_ms = []
|
|
123
|
+
for text in config.timer.durations:
|
|
124
|
+
try:
|
|
125
|
+
durations_ms.append(parse_duration(text))
|
|
126
|
+
except ParseError as exc:
|
|
127
|
+
warn(f"tclock: config timer.durations: {exc}; skipping it")
|
|
128
|
+
if not durations_ms:
|
|
129
|
+
durations_ms = [25 * 60_000, 5 * 60_000]
|
|
130
|
+
|
|
131
|
+
show_millis = not options.no_millis and config.timer.show_millis
|
|
132
|
+
execute = options.execute or " ".join(config.timer.execute) or None
|
|
133
|
+
return Timer(
|
|
134
|
+
durations_ms,
|
|
135
|
+
titles=options.titles or config.timer.titles,
|
|
136
|
+
repeat=options.repeat or config.timer.repeat,
|
|
137
|
+
fmt=DurationFormat.HOUR_MIN_SEC_DECI if show_millis else DurationFormat.HOUR_MIN_SEC,
|
|
138
|
+
paused=options.paused or config.timer.start_paused,
|
|
139
|
+
auto_quit=options.auto_quit or config.timer.auto_quit,
|
|
140
|
+
execute=execute,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_stopwatch() -> Stopwatch:
|
|
145
|
+
return Stopwatch()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def build_countdown(options: Options, config: Config, *, warn: Warn = warn_stderr) -> Countdown:
|
|
149
|
+
time_text = options.time or config.countdown.time
|
|
150
|
+
if time_text is None:
|
|
151
|
+
raise ResolveError("countdown needs --time or a [countdown] time entry in the config file")
|
|
152
|
+
try:
|
|
153
|
+
target = parse_datetime(time_text)
|
|
154
|
+
except ParseError as exc:
|
|
155
|
+
raise ResolveError(str(exc)) from None
|
|
156
|
+
show_millis = options.millis or config.countdown.show_millis
|
|
157
|
+
return Countdown(
|
|
158
|
+
target,
|
|
159
|
+
title=options.title or config.countdown.title,
|
|
160
|
+
continue_on_zero=options.continue_on_zero or config.countdown.continue_on_zero,
|
|
161
|
+
reverse=options.reverse or config.countdown.reverse,
|
|
162
|
+
fmt=DurationFormat.HOUR_MIN_SEC_DECI if show_millis else DurationFormat.HOUR_MIN_SEC,
|
|
163
|
+
)
|
tclock/timefmt.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Formatting of millisecond durations as D:HH:MM:SS[.d] strings."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DurationFormat(Enum):
|
|
7
|
+
"""How much precision a formatted duration shows."""
|
|
8
|
+
|
|
9
|
+
HOUR_MIN_SEC = "hms"
|
|
10
|
+
HOUR_MIN_SEC_DECI = "hmsd"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def format_duration(ms: int, fmt: DurationFormat) -> str:
|
|
14
|
+
"""Format ``ms`` like ``1:05``, ``1:30:00``, ``2:01:00:00`` or ``0:05.3``.
|
|
15
|
+
|
|
16
|
+
Minutes and seconds are always present. Hours appear once the duration reaches one
|
|
17
|
+
hour, days once it reaches 24 hours. The leading component is not zero-padded, the
|
|
18
|
+
rest are. Negative durations get a leading ``-``.
|
|
19
|
+
"""
|
|
20
|
+
negative = ms < 0
|
|
21
|
+
ms = abs(ms)
|
|
22
|
+
total_seconds, millis = divmod(ms, 1000)
|
|
23
|
+
total_minutes, seconds = divmod(total_seconds, 60)
|
|
24
|
+
total_hours, minutes = divmod(total_minutes, 60)
|
|
25
|
+
days, hours = divmod(total_hours, 24)
|
|
26
|
+
|
|
27
|
+
parts: list[str] = []
|
|
28
|
+
if days > 0:
|
|
29
|
+
parts.append(str(days))
|
|
30
|
+
if total_hours > 0:
|
|
31
|
+
parts.append(f"{hours:02d}" if parts else str(hours))
|
|
32
|
+
parts.append(f"{minutes:02d}" if parts else str(minutes))
|
|
33
|
+
parts.append(f"{seconds:02d}")
|
|
34
|
+
result = ":".join(parts)
|
|
35
|
+
if fmt is DurationFormat.HOUR_MIN_SEC_DECI:
|
|
36
|
+
result += f".{millis // 100}"
|
|
37
|
+
return f"-{result}" if negative else result
|
tclock/ui.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Textual front end: one app, one big-digit widget, header and footer labels."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from textual import events, work
|
|
8
|
+
from textual.app import App, ComposeResult
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import Vertical
|
|
11
|
+
from textual.geometry import Size
|
|
12
|
+
from textual.screen import ModalScreen, Screen
|
|
13
|
+
from textual.timer import Timer as TextualTimer
|
|
14
|
+
from textual.widget import Widget
|
|
15
|
+
from textual.widgets import Footer, Label, Static
|
|
16
|
+
|
|
17
|
+
from tclock import font
|
|
18
|
+
from tclock.config import Config
|
|
19
|
+
from tclock.modes import Mode, Pausable, Timer
|
|
20
|
+
from tclock.resolve import Options, build_clock, build_stopwatch, build_timer
|
|
21
|
+
|
|
22
|
+
FLASH_CLASS = "-flash"
|
|
23
|
+
FLASH_TEXT_COLOR = "ansi_black"
|
|
24
|
+
|
|
25
|
+
# (keys, what they do) as shown in the "?" overlay.
|
|
26
|
+
KEY_HELP: tuple[tuple[str, str], ...] = (
|
|
27
|
+
("q, Ctrl+C", "Quit"),
|
|
28
|
+
("Space", "Pause / resume (timer and stopwatch)"),
|
|
29
|
+
("c", "Clock"),
|
|
30
|
+
("w", "Stopwatch"),
|
|
31
|
+
("t", "Timer (durations from the config file)"),
|
|
32
|
+
("?", "Toggle this help"),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_shell(command: str) -> str:
|
|
37
|
+
"""Run ``command`` in the platform shell and summarise the outcome on one line."""
|
|
38
|
+
try:
|
|
39
|
+
proc = subprocess.run(command, shell=True, capture_output=True, text=True, errors="replace")
|
|
40
|
+
except OSError as exc:
|
|
41
|
+
return f"[FAILED] {exc}"
|
|
42
|
+
if proc.returncode != 0:
|
|
43
|
+
return " ".join(f"[ERROR] {proc.stderr}".split())
|
|
44
|
+
return " ".join(f"[SUCCEED] {proc.stdout}".split())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BigTime(Widget):
|
|
48
|
+
"""Draws text in the bricks font, centred horizontally."""
|
|
49
|
+
|
|
50
|
+
DEFAULT_CSS = """
|
|
51
|
+
BigTime {
|
|
52
|
+
width: 100%;
|
|
53
|
+
height: auto;
|
|
54
|
+
text-wrap: nowrap;
|
|
55
|
+
text-overflow: clip;
|
|
56
|
+
}
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, size: int = 1, *, id: str | None = None) -> None:
|
|
60
|
+
super().__init__(id=id)
|
|
61
|
+
# Not ``self.size``: Textual's Widget.size is a read-only property.
|
|
62
|
+
self.scale = size
|
|
63
|
+
self.rows: list[str] = font.render("", size)
|
|
64
|
+
|
|
65
|
+
def set_text(self, text: str | None) -> None:
|
|
66
|
+
rows = font.render(text or "", self.scale)
|
|
67
|
+
if rows != self.rows:
|
|
68
|
+
self.rows = rows
|
|
69
|
+
self.refresh(layout=True)
|
|
70
|
+
|
|
71
|
+
def render(self) -> Text:
|
|
72
|
+
# Centre by hand: when the digits are wider than the terminal this leaves them
|
|
73
|
+
# left-aligned and clipped on the right, like clock-tui, instead of cutting
|
|
74
|
+
# off the leading digit.
|
|
75
|
+
width = self.size.width
|
|
76
|
+
pad = " " * max(0, (width - font.text_width_rows(self.rows)) // 2)
|
|
77
|
+
return Text("\n".join(pad + row for row in self.rows), no_wrap=True, end="")
|
|
78
|
+
|
|
79
|
+
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
|
|
80
|
+
return len(self.rows)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class HelpScreen(ModalScreen[None]):
|
|
84
|
+
"""The "?" overlay listing every key."""
|
|
85
|
+
|
|
86
|
+
BINDINGS = [
|
|
87
|
+
Binding("question_mark", "dismiss", "Close help", show=False),
|
|
88
|
+
Binding("escape", "dismiss", "Close help", show=False),
|
|
89
|
+
Binding("q", "dismiss", "Close help", show=False),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
def compose(self) -> ComposeResult:
|
|
93
|
+
width = max(len(keys) for keys, _ in KEY_HELP)
|
|
94
|
+
lines = "\n".join(f"{keys:<{width}} {what}" for keys, what in KEY_HELP)
|
|
95
|
+
with Vertical(id="help") as box:
|
|
96
|
+
box.border_title = "Keys"
|
|
97
|
+
yield Static(lines, id="help-keys")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ClockApp(App[None]):
|
|
101
|
+
CSS_PATH = "app.tcss"
|
|
102
|
+
ENABLE_COMMAND_PALETTE = False
|
|
103
|
+
TICK_SECONDS = 0.1
|
|
104
|
+
KEY_BAR_SECONDS = 3.0
|
|
105
|
+
"""How long the key bar stays visible after the last keyboard or mouse input."""
|
|
106
|
+
BINDINGS = [
|
|
107
|
+
Binding("q", "quit", "Quit"),
|
|
108
|
+
Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
|
|
109
|
+
Binding("space", "toggle_pause", "Pause", key_display="Space"),
|
|
110
|
+
Binding("c", "mode_clock", "Clock"),
|
|
111
|
+
Binding("w", "mode_stopwatch", "Stopwatch"),
|
|
112
|
+
Binding("t", "mode_timer", "Timer"),
|
|
113
|
+
Binding("question_mark", "toggle_help", "Help", key_display="?"),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
engine: Mode,
|
|
119
|
+
*,
|
|
120
|
+
color: str,
|
|
121
|
+
size: int,
|
|
122
|
+
config: Config,
|
|
123
|
+
runner: Callable[[str], str] = run_shell,
|
|
124
|
+
) -> None:
|
|
125
|
+
super().__init__()
|
|
126
|
+
self.engine = engine
|
|
127
|
+
# Not ``self.size``/``self.color``: App.size is a read-only Textual property.
|
|
128
|
+
self.digit_color = color
|
|
129
|
+
self.digit_size = size
|
|
130
|
+
self.config = config
|
|
131
|
+
self._runner = runner
|
|
132
|
+
self._execute_running = False
|
|
133
|
+
self._key_bar_timer: TextualTimer | None = None
|
|
134
|
+
|
|
135
|
+
def compose(self) -> ComposeResult:
|
|
136
|
+
with Vertical(id="body"):
|
|
137
|
+
yield Label("", id="header")
|
|
138
|
+
yield BigTime(self.digit_size, id="time")
|
|
139
|
+
yield Label("", id="footer")
|
|
140
|
+
yield Footer(id="keybar")
|
|
141
|
+
|
|
142
|
+
def on_mount(self) -> None:
|
|
143
|
+
# Keep references: ``self.query_one`` looks at the *active* screen, which is
|
|
144
|
+
# the help overlay while it is open.
|
|
145
|
+
self.main_screen: Screen[object] = self.screen
|
|
146
|
+
self._header = self.main_screen.query_one("#header", Label)
|
|
147
|
+
self._footer = self.main_screen.query_one("#footer", Label)
|
|
148
|
+
self._big = self.main_screen.query_one(BigTime)
|
|
149
|
+
self._key_bar = self.main_screen.query_one(Footer)
|
|
150
|
+
self._key_bar.display = False
|
|
151
|
+
self.tick()
|
|
152
|
+
self.set_interval(self.TICK_SECONDS, self.tick)
|
|
153
|
+
|
|
154
|
+
async def on_event(self, event: events.Event) -> None:
|
|
155
|
+
if isinstance(event, events.InputEvent):
|
|
156
|
+
self._show_key_bar()
|
|
157
|
+
await super().on_event(event)
|
|
158
|
+
|
|
159
|
+
def _show_key_bar(self) -> None:
|
|
160
|
+
if self._key_bar_timer is not None:
|
|
161
|
+
self._key_bar_timer.stop()
|
|
162
|
+
self._key_bar.display = True
|
|
163
|
+
self._key_bar_timer = self.set_timer(self.KEY_BAR_SECONDS, self._hide_key_bar)
|
|
164
|
+
|
|
165
|
+
def _hide_key_bar(self) -> None:
|
|
166
|
+
self._key_bar.display = False
|
|
167
|
+
self._key_bar_timer = None
|
|
168
|
+
|
|
169
|
+
def tick(self) -> None:
|
|
170
|
+
frame = self.engine.snapshot()
|
|
171
|
+
self._header.update(frame.header or "")
|
|
172
|
+
self._footer.update(frame.footer or "")
|
|
173
|
+
self._big.set_text(frame.text)
|
|
174
|
+
self._big.styles.color = FLASH_TEXT_COLOR if frame.flash else self.digit_color
|
|
175
|
+
self.main_screen.set_class(frame.flash, FLASH_CLASS)
|
|
176
|
+
self._maybe_execute()
|
|
177
|
+
if frame.finished:
|
|
178
|
+
self.exit()
|
|
179
|
+
|
|
180
|
+
def _maybe_execute(self) -> None:
|
|
181
|
+
engine = self.engine
|
|
182
|
+
if not isinstance(engine, Timer) or not engine.execute_pending or self._execute_running:
|
|
183
|
+
return
|
|
184
|
+
if engine.execute is None:
|
|
185
|
+
return
|
|
186
|
+
self._execute_running = True
|
|
187
|
+
self._run_execute(engine, engine.execute)
|
|
188
|
+
|
|
189
|
+
@work(thread=True, exclusive=True)
|
|
190
|
+
def _run_execute(self, timer: Timer, command: str) -> None:
|
|
191
|
+
result = self._runner(command)
|
|
192
|
+
self.call_from_thread(self._on_execute_done, timer, result)
|
|
193
|
+
|
|
194
|
+
def _on_execute_done(self, timer: Timer, result: str) -> None:
|
|
195
|
+
timer.set_execute_result(result)
|
|
196
|
+
self._execute_running = False
|
|
197
|
+
if timer is self.engine:
|
|
198
|
+
self.tick()
|
|
199
|
+
|
|
200
|
+
def action_toggle_pause(self) -> None:
|
|
201
|
+
if isinstance(self.engine, Pausable):
|
|
202
|
+
self.engine.toggle_paused()
|
|
203
|
+
self.tick()
|
|
204
|
+
|
|
205
|
+
def action_mode_clock(self) -> None:
|
|
206
|
+
self._switch(build_clock(Options(), self.config))
|
|
207
|
+
|
|
208
|
+
def action_mode_stopwatch(self) -> None:
|
|
209
|
+
self._switch(build_stopwatch())
|
|
210
|
+
|
|
211
|
+
def action_mode_timer(self) -> None:
|
|
212
|
+
self._switch(build_timer(Options(), self.config))
|
|
213
|
+
|
|
214
|
+
def action_toggle_help(self) -> None:
|
|
215
|
+
if isinstance(self.screen, HelpScreen):
|
|
216
|
+
self.pop_screen()
|
|
217
|
+
else:
|
|
218
|
+
self.push_screen(HelpScreen())
|
|
219
|
+
|
|
220
|
+
def _switch(self, engine: Mode) -> None:
|
|
221
|
+
self.engine = engine
|
|
222
|
+
self._execute_running = False
|
|
223
|
+
self.tick()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def run(engine: Mode, *, color: str, size: int, config: Config) -> Mode:
|
|
227
|
+
"""Run the TUI until the user quits; return the engine that was active at exit."""
|
|
228
|
+
app = ClockApp(engine, color=color, size=size, config=config)
|
|
229
|
+
app.run()
|
|
230
|
+
return app.engine
|