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
tui/countdown.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from textual.app import App, ComposeResult
|
|
2
|
+
from textual.containers import Container
|
|
3
|
+
from textual.widgets import Digits, Footer, Header, Static
|
|
4
|
+
from textual.reactive import reactive
|
|
5
|
+
from core.formatting import format_time
|
|
6
|
+
from core.termclock import Countdown
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CountdownTui(App):
|
|
10
|
+
"""A countdown timer app."""
|
|
11
|
+
|
|
12
|
+
TITLE = "Time Manager"
|
|
13
|
+
SUB_TITLE = "Countdown"
|
|
14
|
+
|
|
15
|
+
CSS_PATH = "theme.tcss"
|
|
16
|
+
|
|
17
|
+
BINDINGS = [
|
|
18
|
+
("q", "quit", "Quit"),
|
|
19
|
+
("space", "toggle_pause", "Pause/Resume"),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
time_left = reactive(0.0)
|
|
23
|
+
|
|
24
|
+
def __init__(self, seconds: int) -> None:
|
|
25
|
+
super().__init__()
|
|
26
|
+
self.countdown = Countdown(seconds)
|
|
27
|
+
self._finished_announced = False
|
|
28
|
+
|
|
29
|
+
def compose(self) -> ComposeResult:
|
|
30
|
+
yield Header(show_clock=True)
|
|
31
|
+
with Container(id="content"):
|
|
32
|
+
with Container(id="card-row"):
|
|
33
|
+
with Container(id="display-container"):
|
|
34
|
+
with Container(id="time-row"):
|
|
35
|
+
yield Digits("00:00", id="countdown")
|
|
36
|
+
with Container(id="status-row"):
|
|
37
|
+
yield Static("Running", id="status", classes="running")
|
|
38
|
+
yield Footer()
|
|
39
|
+
|
|
40
|
+
def on_mount(self) -> None:
|
|
41
|
+
self.time_left = self.countdown.time_left
|
|
42
|
+
self.update_display()
|
|
43
|
+
self._sync_status()
|
|
44
|
+
self.set_interval(0.1, self.tick)
|
|
45
|
+
|
|
46
|
+
def tick(self) -> None:
|
|
47
|
+
self.countdown.tick()
|
|
48
|
+
self.time_left = self.countdown.time_left
|
|
49
|
+
|
|
50
|
+
if self.countdown.is_finished and not self._finished_announced:
|
|
51
|
+
self._finished_announced = True
|
|
52
|
+
self.notify("Time's up!", severity="error", timeout=10)
|
|
53
|
+
self.bell()
|
|
54
|
+
self.query_one("#status", Static).update("Time's Up!")
|
|
55
|
+
self.query_one("#status", Static).set_class(True, "danger")
|
|
56
|
+
|
|
57
|
+
self.update_display()
|
|
58
|
+
self._sync_status()
|
|
59
|
+
|
|
60
|
+
def update_display(self) -> None:
|
|
61
|
+
time_str = format_time(self.time_left, show_centiseconds=False)
|
|
62
|
+
digits = self.query_one("#countdown", Digits)
|
|
63
|
+
digits.update(time_str)
|
|
64
|
+
|
|
65
|
+
# Subtle urgency cue while still respecting the palette.
|
|
66
|
+
is_finished = self.countdown.is_finished
|
|
67
|
+
is_paused = (not self.countdown.is_running) and (not is_finished)
|
|
68
|
+
is_urgent = (not is_paused) and (is_finished or self.time_left < 10)
|
|
69
|
+
|
|
70
|
+
digits.set_class(is_paused, "muted")
|
|
71
|
+
digits.set_class(is_urgent, "danger")
|
|
72
|
+
|
|
73
|
+
def action_toggle_pause(self) -> None:
|
|
74
|
+
self.countdown.toggle()
|
|
75
|
+
self._sync_status()
|
|
76
|
+
|
|
77
|
+
def _sync_status(self) -> None:
|
|
78
|
+
status_widget = self.query_one("#status", Static)
|
|
79
|
+
|
|
80
|
+
is_finished = self.countdown.is_finished
|
|
81
|
+
if is_finished:
|
|
82
|
+
status_widget.update("Time's Up!")
|
|
83
|
+
status_widget.set_class(True, "danger")
|
|
84
|
+
status_widget.set_class(False, "running", "paused")
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
if self.countdown.is_running:
|
|
88
|
+
status_widget.update("Running")
|
|
89
|
+
status_widget.set_class(True, "running")
|
|
90
|
+
status_widget.set_class(False, "paused", "danger")
|
|
91
|
+
else:
|
|
92
|
+
status_widget.update("Paused")
|
|
93
|
+
status_widget.set_class(True, "paused")
|
|
94
|
+
status_widget.set_class(False, "running", "danger")
|
tui/stopwatch.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from textual.app import App, ComposeResult
|
|
2
|
+
from textual.containers import Container
|
|
3
|
+
from textual.widgets import Header, Footer, Digits, Button, Static
|
|
4
|
+
from textual.reactive import reactive
|
|
5
|
+
from core.formatting import format_time
|
|
6
|
+
from core.termclock import Stopwatch
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _format_stopwatch(seconds: float) -> str:
|
|
10
|
+
"""Always format stopwatch as HH:MM:SS"""
|
|
11
|
+
time_str = format_time(seconds, show_centiseconds=False)
|
|
12
|
+
if time_str.count(":") == 1:
|
|
13
|
+
return f"00:{time_str}"
|
|
14
|
+
return time_str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StopwatchTui(App):
|
|
18
|
+
"""A simple stopwatch app."""
|
|
19
|
+
|
|
20
|
+
TITLE = "Time Manager"
|
|
21
|
+
SUB_TITLE = "Stopwatch"
|
|
22
|
+
|
|
23
|
+
CSS_PATH = "theme.tcss"
|
|
24
|
+
|
|
25
|
+
BINDINGS = [
|
|
26
|
+
("q", "quit", "Quit"),
|
|
27
|
+
("space", "toggle_timer", "Start/Stop"),
|
|
28
|
+
("r", "reset_timer", "Reset"),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
time_elapsed = reactive(0.0)
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
super().__init__()
|
|
35
|
+
self.stopwatch = Stopwatch()
|
|
36
|
+
|
|
37
|
+
def compose(self) -> ComposeResult:
|
|
38
|
+
yield Header(show_clock=True)
|
|
39
|
+
with Container(id="content"):
|
|
40
|
+
with Container(id="card-row"):
|
|
41
|
+
with Container(id="display-container"):
|
|
42
|
+
with Container(id="time-row"):
|
|
43
|
+
yield Digits("00:00:00", id="time-display")
|
|
44
|
+
with Container(id="hint-row"):
|
|
45
|
+
yield Static("HH:MM:SS", id="format-hint")
|
|
46
|
+
with Container(id="status-row"):
|
|
47
|
+
yield Static("Ready", id="status", classes="ready")
|
|
48
|
+
with Container(id="buttons-row"):
|
|
49
|
+
with Container(id="buttons"):
|
|
50
|
+
# Don't use `variant=` here; we want fully deterministic styling via TCSS.
|
|
51
|
+
yield Button("START", id="start", classes="start", flat=True)
|
|
52
|
+
yield Button(
|
|
53
|
+
"STOP", id="stop", classes="stop", disabled=True, flat=True
|
|
54
|
+
)
|
|
55
|
+
yield Button("RESET", id="reset", classes="reset", flat=True)
|
|
56
|
+
yield Footer()
|
|
57
|
+
|
|
58
|
+
def on_mount(self) -> None:
|
|
59
|
+
self.set_interval(1 / 60, self.update_time)
|
|
60
|
+
self.update_buttons()
|
|
61
|
+
|
|
62
|
+
def update_time(self) -> None:
|
|
63
|
+
self.time_elapsed = self.stopwatch.elapsed
|
|
64
|
+
time_str = _format_stopwatch(self.time_elapsed)
|
|
65
|
+
self.query_one("#time-display", Digits).update(time_str)
|
|
66
|
+
|
|
67
|
+
def action_toggle_timer(self) -> None:
|
|
68
|
+
self.stopwatch.toggle()
|
|
69
|
+
self.update_buttons()
|
|
70
|
+
|
|
71
|
+
def action_reset_timer(self) -> None:
|
|
72
|
+
self.stopwatch.reset()
|
|
73
|
+
self.time_elapsed = 0.0
|
|
74
|
+
self.query_one("#time-display", Digits).update("00:00:00")
|
|
75
|
+
self.update_buttons()
|
|
76
|
+
|
|
77
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
78
|
+
if event.button.id == "start":
|
|
79
|
+
self.stopwatch.start()
|
|
80
|
+
elif event.button.id == "stop":
|
|
81
|
+
self.stopwatch.stop()
|
|
82
|
+
elif event.button.id == "reset":
|
|
83
|
+
self.stopwatch.reset()
|
|
84
|
+
self.time_elapsed = 0.0
|
|
85
|
+
self.query_one("#time-display", Digits).update("00:00:00")
|
|
86
|
+
|
|
87
|
+
self.update_buttons()
|
|
88
|
+
|
|
89
|
+
def update_buttons(self) -> None:
|
|
90
|
+
running = self.stopwatch.is_running
|
|
91
|
+
if running:
|
|
92
|
+
self.query_one("#start").disabled = True
|
|
93
|
+
self.query_one("#stop").disabled = False
|
|
94
|
+
else:
|
|
95
|
+
self.query_one("#start").disabled = False
|
|
96
|
+
self.query_one("#stop").disabled = True
|
|
97
|
+
|
|
98
|
+
status = (
|
|
99
|
+
"Running" if running else ("Ready" if self.time_elapsed == 0 else "Paused")
|
|
100
|
+
)
|
|
101
|
+
status_widget = self.query_one("#status", Static)
|
|
102
|
+
status_widget.update(status)
|
|
103
|
+
status_widget.set_class(running, "running")
|
|
104
|
+
status_widget.set_class((not running) and self.time_elapsed == 0, "ready")
|
|
105
|
+
status_widget.set_class((not running) and self.time_elapsed > 0, "paused")
|
tui/theme.tcss
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/* Shared Textual theme for time-manager TUIs
|
|
2
|
+
*
|
|
3
|
+
* Primary: #081e32
|
|
4
|
+
* Secondary: #d5b77c
|
|
5
|
+
*
|
|
6
|
+
* Note: Textual TCSS doesn't support `linear-gradient(...)` or `box-shadow`,
|
|
7
|
+
* so the "glass" / glow effect is approximated with alpha backgrounds + outline.
|
|
8
|
+
*
|
|
9
|
+
* Linter note: Properties like `text-style`, `text-opacity`, `content-align`,
|
|
10
|
+
* `layout`, and `align` are valid Textual TCSS extensions, not standard CSS.
|
|
11
|
+
* These warnings can be safely ignored.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
Screen {
|
|
15
|
+
background: #081e32;
|
|
16
|
+
color: #d5b77c;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Header {
|
|
20
|
+
background: transparent;
|
|
21
|
+
color: #d5b77c;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
Footer {
|
|
25
|
+
background: transparent;
|
|
26
|
+
color: #d5b77c 60%;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* Footer "keycaps" */
|
|
30
|
+
FooterLabel {
|
|
31
|
+
background: transparent;
|
|
32
|
+
color: #d5b77c 60%;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.footer-key--key {
|
|
36
|
+
background: #d5b77c 10%;
|
|
37
|
+
color: #d5b77c;
|
|
38
|
+
padding: 0 1;
|
|
39
|
+
text-style: bold;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.footer-key--description {
|
|
43
|
+
background: transparent;
|
|
44
|
+
color: #d5b77c 60%;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Main content area */
|
|
48
|
+
#content {
|
|
49
|
+
/* Container defaults to height: 1fr; we keep that behavior (no hard-coded heights). */
|
|
50
|
+
/* Use explicit axis alignment types:
|
|
51
|
+
- vertical: https://textual.textualize.io/css_types/vertical/
|
|
52
|
+
- horizontal: https://textual.textualize.io/css_types/horizontal/ */
|
|
53
|
+
align-vertical: middle;
|
|
54
|
+
align-horizontal: center;
|
|
55
|
+
padding: 1 2;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* Center “card” */
|
|
59
|
+
#display-container {
|
|
60
|
+
outline: round #d5b77c 20%;
|
|
61
|
+
layout: vertical;
|
|
62
|
+
align-horizontal: center;
|
|
63
|
+
align-vertical: middle;
|
|
64
|
+
padding: 3 10;
|
|
65
|
+
margin: 1 0 2 0;
|
|
66
|
+
width: auto;
|
|
67
|
+
height: auto;
|
|
68
|
+
/* With width:auto, use content-box so padding expands the card instead of
|
|
69
|
+
shrinking the content area (prevents Digits from being clipped). */
|
|
70
|
+
box-sizing: content-box;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* Center each section independently so we don't need width/min-width coupling */
|
|
74
|
+
#card-row,
|
|
75
|
+
#buttons-row {
|
|
76
|
+
layout: horizontal;
|
|
77
|
+
height: auto;
|
|
78
|
+
align-horizontal: center;
|
|
79
|
+
align-vertical: middle;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* Center the time + status using horizontal rows (more reliable for multi-line Digits) */
|
|
83
|
+
#time-row,
|
|
84
|
+
#hint-row,
|
|
85
|
+
#status-row {
|
|
86
|
+
layout: horizontal;
|
|
87
|
+
# width: auto;
|
|
88
|
+
height: auto;
|
|
89
|
+
align-vertical: middle;
|
|
90
|
+
align-horizontal: center;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#hint-row {
|
|
94
|
+
margin-top: 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#status-row {
|
|
98
|
+
margin-top: 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
Digits {
|
|
103
|
+
color: #d5b77c;
|
|
104
|
+
text-opacity: 100%;
|
|
105
|
+
width: auto;
|
|
106
|
+
content-align: center middle;
|
|
107
|
+
text-align: center;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#time-display {
|
|
111
|
+
width: auto;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
Digits.muted {
|
|
115
|
+
text-opacity: 70%;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
Digits.danger {
|
|
119
|
+
color: #8b3a3a;
|
|
120
|
+
text-opacity: 100%;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#status {
|
|
124
|
+
width: auto;
|
|
125
|
+
text-style: italic;
|
|
126
|
+
text-opacity: 70%;
|
|
127
|
+
color: #d5b77c;
|
|
128
|
+
content-align: center middle;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#status.running {
|
|
132
|
+
text-opacity: 100%;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#status.paused,
|
|
136
|
+
#status.ready {
|
|
137
|
+
text-opacity: 70%;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#status.danger {
|
|
141
|
+
color: #8b3a3a;
|
|
142
|
+
text-opacity: 100%;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* Stopwatch time format hint */
|
|
146
|
+
#format-hint {
|
|
147
|
+
width: auto;
|
|
148
|
+
text-style: italic;
|
|
149
|
+
text-opacity: 60%;
|
|
150
|
+
color: #d5b77c;
|
|
151
|
+
content-align: center middle;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* Buttons (stopwatch only) */
|
|
155
|
+
#buttons {
|
|
156
|
+
layout: horizontal;
|
|
157
|
+
height: auto;
|
|
158
|
+
width: auto;
|
|
159
|
+
align-vertical: middle;
|
|
160
|
+
align-horizontal: center;
|
|
161
|
+
margin-top: 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
Button {
|
|
165
|
+
/* Flatten Textual's default "3D" button styling (which can show as internal
|
|
166
|
+
border lines). See Button docs: https://textual.textualize.io/widgets/button/ */
|
|
167
|
+
background: #d5b77c 15%;
|
|
168
|
+
color: #d5b77c;
|
|
169
|
+
border: none;
|
|
170
|
+
border-top: none;
|
|
171
|
+
border-bottom: none;
|
|
172
|
+
line-pad: 1;
|
|
173
|
+
height: auto;
|
|
174
|
+
padding: 1 7;
|
|
175
|
+
margin: 0 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
Button:disabled {
|
|
179
|
+
/* Avoid full-widget opacity: terminals blend alpha differently which can make disabled
|
|
180
|
+
buttons appear to "disappear" in some emulators. */
|
|
181
|
+
opacity: 100%;
|
|
182
|
+
background: #d5b77c 8%;
|
|
183
|
+
color: #d5b77c 45%;
|
|
184
|
+
border: none;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* Hover effects should be color-only (no offsets) */
|
|
188
|
+
|
|
189
|
+
Button.start {
|
|
190
|
+
background: #d5b77c;
|
|
191
|
+
color: #081e32;
|
|
192
|
+
border: none;
|
|
193
|
+
text-style: bold;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
Button.start:hover {
|
|
197
|
+
background: #d5b77c 75%;
|
|
198
|
+
color: #8b3a3a;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
Button.stop {
|
|
202
|
+
background: #8b3a3a;
|
|
203
|
+
color: #fffaf0;
|
|
204
|
+
border: none;
|
|
205
|
+
text-style: bold;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
Button.stop:hover {
|
|
209
|
+
background: #a04545 50%;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
Button.stop:disabled {
|
|
213
|
+
background: #8b3a3a 25%;
|
|
214
|
+
color: #fffaf0 55%;
|
|
215
|
+
border: none;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
Button.reset {
|
|
219
|
+
background: #d5b77c 15%;
|
|
220
|
+
color: #d5b77c;
|
|
221
|
+
border: none;
|
|
222
|
+
# outline: round #d5b77c;
|
|
223
|
+
text-style: bold;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
Button.reset:hover {
|
|
227
|
+
background: #d5b77c 25%;
|
|
228
|
+
color: #8b3a3a;
|
|
229
|
+
}
|