time-manager 0.2.20__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.
- app.py +237 -0
- cli/__init__.py +5 -0
- cli/cli.py +140 -0
- core/formatting.py +19 -0
- core/termclock.py +97 -0
- time_manager-0.2.20.dist-info/METADATA +192 -0
- time_manager-0.2.20.dist-info/RECORD +14 -0
- time_manager-0.2.20.dist-info/WHEEL +4 -0
- time_manager-0.2.20.dist-info/entry_points.txt +3 -0
- time_manager-0.2.20.dist-info/licenses/LICENSE +661 -0
- tui/__init__.py +5 -0
- tui/countdown.py +94 -0
- tui/stopwatch.py +105 -0
- tui/theme.tcss +229 -0
app.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""time-manager entry point.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
- `time-manager sw` Start a stopwatch
|
|
5
|
+
- `tm sw` Start a stopwatch
|
|
6
|
+
- `tm stopwatch` Start a stopwatch
|
|
7
|
+
- `time-manager cd <amount> [unit]` Start a countdown timer
|
|
8
|
+
- `tm cd <amount> [unit]` Start a countdown timer
|
|
9
|
+
- `tm countdown <amount> [unit]` Start a countdown timer
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
# Terminal emulators differ in how they advertise TrueColor support.
|
|
17
|
+
# These defaults help keep Textual/Rich rendering consistent across Windows Terminal,
|
|
18
|
+
# VS Code/Cursor terminals, etc. Users can still override by setting these env vars.
|
|
19
|
+
os.environ.setdefault("COLORTERM", "truecolor")
|
|
20
|
+
os.environ.setdefault("RICH_COLOR_SYSTEM", "truecolor")
|
|
21
|
+
|
|
22
|
+
from importlib import metadata as _metadata
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
|
|
26
|
+
from cli import run_countdown_cli, run_stopwatch_cli
|
|
27
|
+
from tui import CountdownTui, StopwatchTui
|
|
28
|
+
|
|
29
|
+
_ALIASES: dict[str, str] = {
|
|
30
|
+
"stopwatch": "sw",
|
|
31
|
+
"countdown": "cd",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _TmGroup(typer.core.TyperGroup):
|
|
36
|
+
"""Typer group with command aliases (so help doesn't list duplicates)."""
|
|
37
|
+
|
|
38
|
+
def get_command(self, ctx: typer.Context, cmd_name: str):
|
|
39
|
+
command = super().get_command(ctx, cmd_name)
|
|
40
|
+
if command is not None:
|
|
41
|
+
return command
|
|
42
|
+
|
|
43
|
+
alias = _ALIASES.get(cmd_name)
|
|
44
|
+
if alias is None:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
return super().get_command(ctx, alias)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Create the Typer app
|
|
51
|
+
app = typer.Typer(
|
|
52
|
+
help=(
|
|
53
|
+
"A terminal based stopwatch and countdown timer.\n\n"
|
|
54
|
+
"Examples:\n"
|
|
55
|
+
" tm sw\n"
|
|
56
|
+
" tm sw -i\n"
|
|
57
|
+
" tm cd 5 m\n"
|
|
58
|
+
" tm cd 5 m -i\n"
|
|
59
|
+
" tm countdown 10 s\n"
|
|
60
|
+
),
|
|
61
|
+
cls=_TmGroup,
|
|
62
|
+
add_completion=False,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
INTERACTIVE = typer.Option(
|
|
66
|
+
False,
|
|
67
|
+
"--interactive",
|
|
68
|
+
"-i",
|
|
69
|
+
help="Run in interactive (Textual TUI) mode.",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _get_version() -> str:
|
|
74
|
+
for dist_name in ("time-manager", "tm"):
|
|
75
|
+
try:
|
|
76
|
+
return _metadata.version(dist_name)
|
|
77
|
+
except _metadata.PackageNotFoundError:
|
|
78
|
+
continue
|
|
79
|
+
return "unknown"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _version_callback(value: bool) -> None:
|
|
83
|
+
if not value:
|
|
84
|
+
return
|
|
85
|
+
typer.echo(_get_version())
|
|
86
|
+
raise typer.Exit()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_UNIT_SECONDS: dict[str, int] = {
|
|
90
|
+
# seconds
|
|
91
|
+
"s": 1,
|
|
92
|
+
"sec": 1,
|
|
93
|
+
"secs": 1,
|
|
94
|
+
"second": 1,
|
|
95
|
+
"seconds": 1,
|
|
96
|
+
# minutes
|
|
97
|
+
"m": 60,
|
|
98
|
+
"min": 60,
|
|
99
|
+
"mins": 60,
|
|
100
|
+
"minute": 60,
|
|
101
|
+
"minutes": 60,
|
|
102
|
+
# hours
|
|
103
|
+
"h": 3600,
|
|
104
|
+
"hr": 3600,
|
|
105
|
+
"hrs": 3600,
|
|
106
|
+
"hour": 3600,
|
|
107
|
+
"hours": 3600,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _die(message: str) -> None:
|
|
112
|
+
typer.secho(f"Error: {message}", fg=typer.colors.RED, err=True)
|
|
113
|
+
raise typer.Exit(code=1)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _parse_countdown_seconds(amount: int, unit: str) -> int:
|
|
117
|
+
if amount <= 0:
|
|
118
|
+
_die("Time must be greater than 0.")
|
|
119
|
+
|
|
120
|
+
normalized_unit = unit.lower().strip()
|
|
121
|
+
multiplier = _UNIT_SECONDS.get(normalized_unit)
|
|
122
|
+
if multiplier is None:
|
|
123
|
+
_die(f"Unknown unit '{normalized_unit}'. Please use 's', 'm', or 'h'.")
|
|
124
|
+
|
|
125
|
+
seconds = amount * multiplier
|
|
126
|
+
if seconds <= 0:
|
|
127
|
+
_die("Time must be greater than 0.")
|
|
128
|
+
|
|
129
|
+
return seconds
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _print_error_box(message: str) -> None:
|
|
133
|
+
"""Print an error message in a boxed panel when Rich is available."""
|
|
134
|
+
try:
|
|
135
|
+
from rich import box
|
|
136
|
+
from rich.console import Console
|
|
137
|
+
from rich.panel import Panel
|
|
138
|
+
from rich.text import Text
|
|
139
|
+
except Exception:
|
|
140
|
+
typer.secho(f"Error: {message}", fg=typer.colors.RED, err=True)
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
Console(stderr=True).print(
|
|
144
|
+
Panel(
|
|
145
|
+
Text(message, style="bold red"),
|
|
146
|
+
title="Error",
|
|
147
|
+
border_style="red",
|
|
148
|
+
box=box.ROUNDED,
|
|
149
|
+
expand=True,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.callback(invoke_without_command=True)
|
|
155
|
+
def _root(
|
|
156
|
+
ctx: typer.Context,
|
|
157
|
+
interactive: bool = INTERACTIVE,
|
|
158
|
+
version: bool = typer.Option(
|
|
159
|
+
False,
|
|
160
|
+
"--version",
|
|
161
|
+
"-V",
|
|
162
|
+
help="Show the version and exit.",
|
|
163
|
+
callback=_version_callback,
|
|
164
|
+
is_eager=True,
|
|
165
|
+
),
|
|
166
|
+
) -> None:
|
|
167
|
+
"""
|
|
168
|
+
A terminal based stopwatch and countdown timer.
|
|
169
|
+
"""
|
|
170
|
+
ctx.ensure_object(dict)
|
|
171
|
+
ctx.obj["interactive"] = interactive
|
|
172
|
+
|
|
173
|
+
if ctx.invoked_subcommand is not None:
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
# Treat calling `tm` with no command as an error, but show help by default.
|
|
177
|
+
_print_error_box("Missing command.")
|
|
178
|
+
# Use Typer/Click's built-in help so it's complete and stays standard
|
|
179
|
+
# (includes e.g. completion flags and any future global options).
|
|
180
|
+
typer.echo(ctx.get_help())
|
|
181
|
+
raise typer.Exit(code=1)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@app.command(help="Start a stopwatch. (alias: stopwatch)")
|
|
185
|
+
def sw(ctx: typer.Context, interactive: bool = INTERACTIVE) -> None:
|
|
186
|
+
"""
|
|
187
|
+
Start a stopwatch.
|
|
188
|
+
|
|
189
|
+
Examples:
|
|
190
|
+
tm sw
|
|
191
|
+
tm sw -i
|
|
192
|
+
tm stopwatch
|
|
193
|
+
"""
|
|
194
|
+
effective_interactive = bool(
|
|
195
|
+
interactive or (ctx.obj or {}).get("interactive", False)
|
|
196
|
+
)
|
|
197
|
+
if effective_interactive:
|
|
198
|
+
StopwatchTui().run()
|
|
199
|
+
else:
|
|
200
|
+
run_stopwatch_cli()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.command(help="Start a countdown timer. (alias: countdown)")
|
|
204
|
+
def cd(
|
|
205
|
+
ctx: typer.Context,
|
|
206
|
+
amount: int = typer.Argument(..., help="The amount of time."),
|
|
207
|
+
unit: str = typer.Argument(
|
|
208
|
+
"m", help="The unit of time. [s]econds, [m]inutes, [h]ours."
|
|
209
|
+
),
|
|
210
|
+
interactive: bool = INTERACTIVE,
|
|
211
|
+
):
|
|
212
|
+
"""
|
|
213
|
+
Start a countdown timer.
|
|
214
|
+
|
|
215
|
+
Examples:
|
|
216
|
+
tm cd 5 m
|
|
217
|
+
tm cd 5 m -i
|
|
218
|
+
tm countdown 10 s
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
seconds = _parse_countdown_seconds(amount, unit)
|
|
222
|
+
|
|
223
|
+
effective_interactive = bool(
|
|
224
|
+
interactive or (ctx.obj or {}).get("interactive", False)
|
|
225
|
+
)
|
|
226
|
+
if effective_interactive:
|
|
227
|
+
CountdownTui(seconds).run()
|
|
228
|
+
else:
|
|
229
|
+
run_countdown_cli(seconds)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def main() -> None:
|
|
233
|
+
app()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
main()
|
cli/__init__.py
ADDED
cli/cli.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import sys
|
|
3
|
+
import select
|
|
4
|
+
import termios
|
|
5
|
+
import tty
|
|
6
|
+
from rich.align import Align
|
|
7
|
+
from rich.console import Group
|
|
8
|
+
from rich.live import Live
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from rich import box
|
|
12
|
+
from core.formatting import format_time
|
|
13
|
+
from core.termclock import Stopwatch, Countdown
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NonBlockingInput:
|
|
17
|
+
"""Context manager for non-blocking terminal input."""
|
|
18
|
+
|
|
19
|
+
def __enter__(self):
|
|
20
|
+
self.old_settings = termios.tcgetattr(sys.stdin)
|
|
21
|
+
tty.setcbreak(sys.stdin.fileno())
|
|
22
|
+
return self
|
|
23
|
+
|
|
24
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
25
|
+
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self.old_settings)
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def get_char():
|
|
29
|
+
"""Check for and return a character if available, else None."""
|
|
30
|
+
if select.select([sys.stdin], [], [], 0) == ([sys.stdin], [], []):
|
|
31
|
+
return sys.stdin.read(1)
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_stopwatch_cli():
|
|
36
|
+
stopwatch = Stopwatch()
|
|
37
|
+
stopwatch.start()
|
|
38
|
+
|
|
39
|
+
subtitle = "Space: Start/Stop | r: Reset | q: Quit"
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
with NonBlockingInput(), Live(refresh_per_second=60, screen=False) as live:
|
|
43
|
+
while True:
|
|
44
|
+
# Handle Input
|
|
45
|
+
char = NonBlockingInput.get_char()
|
|
46
|
+
if char:
|
|
47
|
+
if char.lower() == "q":
|
|
48
|
+
break
|
|
49
|
+
elif char == " ":
|
|
50
|
+
stopwatch.toggle()
|
|
51
|
+
elif char.lower() == "r":
|
|
52
|
+
stopwatch.reset()
|
|
53
|
+
|
|
54
|
+
# Update Display
|
|
55
|
+
elapsed = stopwatch.elapsed
|
|
56
|
+
time_str = format_time(elapsed, show_centiseconds=False)
|
|
57
|
+
# Always display HH:MM:SS (even when hours == 0)
|
|
58
|
+
if time_str.count(":") == 1:
|
|
59
|
+
time_str = f"00:{time_str}"
|
|
60
|
+
|
|
61
|
+
# Visual feedback for paused state
|
|
62
|
+
style = "bold green" if stopwatch.is_running else "dim green"
|
|
63
|
+
border_style = "green" if stopwatch.is_running else "white"
|
|
64
|
+
|
|
65
|
+
display = Group(
|
|
66
|
+
Align.center(Text(time_str, style=style)),
|
|
67
|
+
Align.center(Text("HH:MM:SS", style="dim")),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
panel = Panel(
|
|
71
|
+
display,
|
|
72
|
+
title="Stopwatch",
|
|
73
|
+
subtitle=subtitle,
|
|
74
|
+
box=box.ROUNDED,
|
|
75
|
+
border_style=border_style,
|
|
76
|
+
padding=(1, 2),
|
|
77
|
+
)
|
|
78
|
+
live.update(panel)
|
|
79
|
+
time.sleep(1 / 60)
|
|
80
|
+
except KeyboardInterrupt:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_countdown_cli(seconds: int):
|
|
85
|
+
countdown = Countdown(seconds)
|
|
86
|
+
|
|
87
|
+
subtitle = "Space: Pause/Resume | q: Quit"
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
with NonBlockingInput(), Live(refresh_per_second=10, screen=False) as live:
|
|
91
|
+
while not countdown.is_finished:
|
|
92
|
+
# Handle Input
|
|
93
|
+
char = NonBlockingInput.get_char()
|
|
94
|
+
if char:
|
|
95
|
+
if char.lower() == "q":
|
|
96
|
+
break
|
|
97
|
+
elif char == " ":
|
|
98
|
+
countdown.toggle()
|
|
99
|
+
|
|
100
|
+
countdown.tick()
|
|
101
|
+
remaining = countdown.time_left
|
|
102
|
+
time_str = format_time(remaining, show_centiseconds=False)
|
|
103
|
+
|
|
104
|
+
# Change color based on urgency
|
|
105
|
+
color = "blue"
|
|
106
|
+
if remaining < 10:
|
|
107
|
+
color = "red"
|
|
108
|
+
elif remaining < 30:
|
|
109
|
+
color = "yellow"
|
|
110
|
+
|
|
111
|
+
# Visual feedback for paused state
|
|
112
|
+
style = f"bold {color}" if countdown.is_running else f"dim {color}"
|
|
113
|
+
border_style = color if countdown.is_running else "white"
|
|
114
|
+
|
|
115
|
+
panel = Panel(
|
|
116
|
+
Text(time_str, style=style, justify="center"),
|
|
117
|
+
title="Countdown",
|
|
118
|
+
subtitle=subtitle,
|
|
119
|
+
box=box.ROUNDED,
|
|
120
|
+
border_style=border_style,
|
|
121
|
+
padding=(1, 2),
|
|
122
|
+
)
|
|
123
|
+
live.update(panel)
|
|
124
|
+
time.sleep(0.1)
|
|
125
|
+
|
|
126
|
+
# Final "Time's Up" display
|
|
127
|
+
if countdown.is_finished:
|
|
128
|
+
panel = Panel(
|
|
129
|
+
Text("00:00", style="bold red blink", justify="center"),
|
|
130
|
+
title="Countdown",
|
|
131
|
+
subtitle="Time's Up!",
|
|
132
|
+
box=box.ROUNDED,
|
|
133
|
+
border_style="red",
|
|
134
|
+
padding=(1, 2),
|
|
135
|
+
)
|
|
136
|
+
live.update(panel)
|
|
137
|
+
time.sleep(2) # Show for a bit before exiting
|
|
138
|
+
|
|
139
|
+
except KeyboardInterrupt:
|
|
140
|
+
pass
|
core/formatting.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def format_time(seconds: float, *, show_centiseconds: bool = True) -> str:
|
|
5
|
+
"""Format a duration in seconds as MM:SS(.CC) or HH:MM:SS(.CC)."""
|
|
6
|
+
|
|
7
|
+
seconds = max(0.0, float(seconds))
|
|
8
|
+
minutes, secs = divmod(seconds, 60)
|
|
9
|
+
hours, minutes = divmod(minutes, 60)
|
|
10
|
+
|
|
11
|
+
if show_centiseconds:
|
|
12
|
+
centiseconds = int((seconds * 100) % 100)
|
|
13
|
+
if hours > 0:
|
|
14
|
+
return f"{int(hours):02}:{int(minutes):02}:{int(secs):02}.{centiseconds:02}"
|
|
15
|
+
return f"{int(minutes):02}:{int(secs):02}.{centiseconds:02}"
|
|
16
|
+
|
|
17
|
+
if hours > 0:
|
|
18
|
+
return f"{int(hours):02}:{int(minutes):02}:{int(secs):02}"
|
|
19
|
+
return f"{int(minutes):02}:{int(secs):02}"
|
core/termclock.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from time import monotonic
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Stopwatch:
|
|
8
|
+
"""Core logic for a stopwatch."""
|
|
9
|
+
|
|
10
|
+
_start_time: Optional[float] = None
|
|
11
|
+
_accumulated_time: float = 0.0
|
|
12
|
+
_running: bool = False
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def is_running(self) -> bool:
|
|
16
|
+
return self._running
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def elapsed(self) -> float:
|
|
20
|
+
"""Return the total elapsed time in seconds."""
|
|
21
|
+
if self._running:
|
|
22
|
+
return self._accumulated_time + (monotonic() - self._start_time)
|
|
23
|
+
return self._accumulated_time
|
|
24
|
+
|
|
25
|
+
def start(self):
|
|
26
|
+
if not self._running:
|
|
27
|
+
self._start_time = monotonic()
|
|
28
|
+
self._running = True
|
|
29
|
+
|
|
30
|
+
def stop(self):
|
|
31
|
+
if self._running:
|
|
32
|
+
self._accumulated_time += monotonic() - self._start_time
|
|
33
|
+
self._start_time = None
|
|
34
|
+
self._running = False
|
|
35
|
+
|
|
36
|
+
def reset(self):
|
|
37
|
+
self._running = False
|
|
38
|
+
self._accumulated_time = 0.0
|
|
39
|
+
self._start_time = None
|
|
40
|
+
|
|
41
|
+
def toggle(self):
|
|
42
|
+
if self._running:
|
|
43
|
+
self.stop()
|
|
44
|
+
else:
|
|
45
|
+
self.start()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Countdown:
|
|
50
|
+
"""Core logic for a countdown timer."""
|
|
51
|
+
|
|
52
|
+
initial_seconds: int
|
|
53
|
+
_time_left: float = field(init=False)
|
|
54
|
+
_last_tick: Optional[float] = field(init=False, default=None)
|
|
55
|
+
_running: bool = field(init=False, default=True)
|
|
56
|
+
|
|
57
|
+
def __post_init__(self):
|
|
58
|
+
self._time_left = float(self.initial_seconds)
|
|
59
|
+
self._last_tick = monotonic()
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def time_left(self) -> float:
|
|
63
|
+
return max(0.0, self._time_left)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def is_running(self) -> bool:
|
|
67
|
+
return self._running
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_finished(self) -> bool:
|
|
71
|
+
return self._time_left <= 0
|
|
72
|
+
|
|
73
|
+
def tick(self):
|
|
74
|
+
"""Update the timer based on elapsed real time."""
|
|
75
|
+
if self._running and self._time_left > 0:
|
|
76
|
+
now = monotonic()
|
|
77
|
+
if self._last_tick:
|
|
78
|
+
delta = now - self._last_tick
|
|
79
|
+
self._time_left -= delta
|
|
80
|
+
self._last_tick = now
|
|
81
|
+
else:
|
|
82
|
+
self._last_tick = monotonic()
|
|
83
|
+
|
|
84
|
+
def pause(self):
|
|
85
|
+
self._running = False
|
|
86
|
+
self._last_tick = None
|
|
87
|
+
|
|
88
|
+
def resume(self):
|
|
89
|
+
if not self._running:
|
|
90
|
+
self._running = True
|
|
91
|
+
self._last_tick = monotonic()
|
|
92
|
+
|
|
93
|
+
def toggle(self):
|
|
94
|
+
if self._running:
|
|
95
|
+
self.pause()
|
|
96
|
+
else:
|
|
97
|
+
self.resume()
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: time-manager
|
|
3
|
+
Version: 0.2.20
|
|
4
|
+
Summary: A terminal based stopwatch and countdown timer
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: textual-dev>=1.8.0
|
|
8
|
+
Requires-Dist: textual>=6.11.0
|
|
9
|
+
Requires-Dist: typer>=0.21.0
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# time-manager
|
|
13
|
+
|
|
14
|
+
A powerful and visually stunning terminal-based timer application built with [Textual](https://textual.textualize.io/) and [Typer](https://typer.tiangolo.com/).
|
|
15
|
+
|
|
16
|
+
- **CLI Interface (default)**: Lightweight mode (no Textual UI).
|
|
17
|
+
- **TUI Interface**: Beautiful, responsive terminal user interface via `-i/--interactive`.
|
|
18
|
+
- **Notifications**: Visual and audio feedback (bell) when a countdown completes.
|
|
19
|
+
|
|
20
|
+

|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- **Stopwatch**: Precise stopwatch with centisecond resolution.
|
|
25
|
+
- **Countdown**: Configurable countdown timer with support for seconds, minutes, and hours.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
- Requires **Python 3.10+**
|
|
30
|
+
- Installs two commands: `tm` (recommended) and `time-manager`
|
|
31
|
+
|
|
32
|
+
From PyPI:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install time-manager
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
If you use uv:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv tool install time-manager
|
|
42
|
+
|
|
43
|
+
# or (inside a project)
|
|
44
|
+
uv add time-manager
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
time-manager provides two command names for convenience:
|
|
50
|
+
|
|
51
|
+
- `tm` - Short and convenient alias
|
|
52
|
+
- `time-manager` - Full command name
|
|
53
|
+
|
|
54
|
+
Both commands work identically. Examples:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
tm sw # or: time-manager sw (also: tm stopwatch)
|
|
58
|
+
tm cd 5 m # or: time-manager cd 5 m (also: tm countdown 5 m)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Stopwatch
|
|
62
|
+
|
|
63
|
+
Start a stopwatch to track elapsed time:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
tm sw
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
For interactive TUI mode:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
tm sw -i
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Controls (TUI mode):**
|
|
76
|
+
- `Space`: Start/Stop
|
|
77
|
+
- `r`: Reset
|
|
78
|
+
- `q`: Quit
|
|
79
|
+
|
|
80
|
+
### Countdown Timer
|
|
81
|
+
|
|
82
|
+
Start a countdown for a specific duration:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
tm cd 5 m # 5 minutes
|
|
86
|
+
tm cd 60 s # 60 seconds
|
|
87
|
+
tm cd 1 h # 1 hour
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
For interactive TUI mode:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
tm cd 5 m -i
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Controls (TUI mode):**
|
|
97
|
+
- `Space`: Pause/Resume
|
|
98
|
+
- `q`: Quit
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
### Prerequisites
|
|
103
|
+
|
|
104
|
+
- [uv](https://github.com/astral-sh/uv) installed on your system.
|
|
105
|
+
- Python 3.10+
|
|
106
|
+
|
|
107
|
+
### Installation for Development
|
|
108
|
+
|
|
109
|
+
To install in editable mode for development:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
make local
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Global Installation (Recommended for Testing)
|
|
116
|
+
|
|
117
|
+
To install as a system-wide utility:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
make global
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
This builds a standalone executable and copies it to `/usr/local/bin/time-manager`, and also creates a `/usr/local/bin/tm` symlink.
|
|
124
|
+
|
|
125
|
+
### Make Commands
|
|
126
|
+
|
|
127
|
+
| Command | Description |
|
|
128
|
+
| ---------------------- | ------------------------------------------------- |
|
|
129
|
+
| `make local` | Install in editable mode for development |
|
|
130
|
+
| `make global` | Build and install system-wide to `/usr/local/bin` |
|
|
131
|
+
| `make build` | Build standalone executable (with version bump) |
|
|
132
|
+
| `make bump` | Bump patch version (default) |
|
|
133
|
+
| `TYPE=MINOR make bump` | Bump minor version |
|
|
134
|
+
| `TYPE=MAJOR make bump` | Bump major version |
|
|
135
|
+
| `make clean` | Remove build artifacts |
|
|
136
|
+
| `make uninstall` | Remove global installation |
|
|
137
|
+
|
|
138
|
+
### Project Structure
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
time-manager/
|
|
142
|
+
├── src/
|
|
143
|
+
│ ├── app.py # CLI entry point using Typer
|
|
144
|
+
│ ├── cli/
|
|
145
|
+
│ │ ├── __init__.py # CLI package exports
|
|
146
|
+
│ │ └── cli.py # CLI implementations for timers
|
|
147
|
+
│ ├── core/
|
|
148
|
+
│ │ ├── formatting.py # Time formatting utilities
|
|
149
|
+
│ │ └── termclock.py # Core timer logic
|
|
150
|
+
│ └── tui/
|
|
151
|
+
│ ├── __init__.py # TUI package exports
|
|
152
|
+
│ ├── countdown.py # Countdown TUI
|
|
153
|
+
│ ├── stopwatch.py # Stopwatch TUI
|
|
154
|
+
│ └── theme.tcss # Textual CSS theme
|
|
155
|
+
├── scripts/
|
|
156
|
+
│ └── bump.sh # Version bump script
|
|
157
|
+
├── pyproject.toml # Project configuration
|
|
158
|
+
├── uv.lock # Dependency lock file
|
|
159
|
+
├── Makefile # Build and install commands
|
|
160
|
+
├── LICENSE # Project license
|
|
161
|
+
└── README.md # This file
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Publishing to PyPI
|
|
165
|
+
|
|
166
|
+
This repo uses `make publish` (via `scripts/publish.sh`) and defaults to **TestPyPI**.
|
|
167
|
+
|
|
168
|
+
#### Test PyPI (default)
|
|
169
|
+
|
|
170
|
+
To publish to Test PyPI (uses `TEST_PYPI_PUBLISH_TOKEN`):
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
make build
|
|
174
|
+
make publish
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
#### Production PyPI
|
|
178
|
+
|
|
179
|
+
To publish to production PyPI (uses `PYPI_PUBLISH_TOKEN`):
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
make build
|
|
183
|
+
PROD=TRUE make publish
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Uninstallation
|
|
187
|
+
|
|
188
|
+
To remove the global installation:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
make uninstall
|
|
192
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
app.py,sha256=gCGHj1heTrg_pFafK-0ZJGAWaYSlVjnbcuvzuRul1Zo,5787
|
|
2
|
+
cli/__init__.py,sha256=438YugZ3QU07-5Spswk0OLcHC_B3JDjB8ZHRT9hn4SM,139
|
|
3
|
+
cli/cli.py,sha256=yZi5u8phWkSucZ9zPi1bF8jdtJmzNW4iUzV7SEna6pU,4639
|
|
4
|
+
core/formatting.py,sha256=3SVZ3xUaEN3mtEecFgMqxesSh5-XDUbAiwBzZ61846k,694
|
|
5
|
+
core/termclock.py,sha256=RptBEob2O__-tRu9siD8HUxw5UT2uPLN1B1HodyxkLI,2467
|
|
6
|
+
tui/__init__.py,sha256=TwfmFKYVnc3n4uIrm19PYSAcgdsaH2W4YxVlVbEDYCc,139
|
|
7
|
+
tui/countdown.py,sha256=krCZgbz4ILVcOsv-8Lo1iwPnnUhHVI37_x273CHcxqQ,3265
|
|
8
|
+
tui/stopwatch.py,sha256=11lFQ3q96oyL-Krt6_ePRc8eVvVf6Am1MOhJLMSmaV8,3841
|
|
9
|
+
tui/theme.tcss,sha256=-DtnprXFmvzuOVIVjLSkIhGT8Q4KD3eA50ry7Twzpt4,4510
|
|
10
|
+
time_manager-0.2.20.dist-info/METADATA,sha256=msDYBTDI5V4QfYeHTow_1HcWauzI80Dm-W2xIxDd0mI,4734
|
|
11
|
+
time_manager-0.2.20.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
+
time_manager-0.2.20.dist-info/entry_points.txt,sha256=m_D7FaJTbf0SyyQMkyqsTWjgNEo20-MbzF-1DyZEUv0,56
|
|
13
|
+
time_manager-0.2.20.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
|
|
14
|
+
time_manager-0.2.20.dist-info/RECORD,,
|