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/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