pttm 0.1.0__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.
pttm/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # Pomodoro TUI Package Public API
2
+ from pttm.config import load_config, save_config, CONFIG_FILE
3
+ from pttm.clock import make_clock_ascii
pttm/app.py ADDED
@@ -0,0 +1,183 @@
1
+ import json
2
+ import os
3
+ from textual.app import App, ComposeResult
4
+ from textual.widgets import Header, TabbedContent, TabPane, Label, Input
5
+ from pttm.config import load_config, save_config
6
+ from pttm.widgets.dashboard import Dashboard
7
+ from pttm.widgets.settings_tab import SettingsTab
8
+ from pttm.widgets.shortcuts_screen import ShortcutsScreen
9
+ from pttm.widgets.timer_widget import TimerWidget
10
+ from pttm.widgets.task_list_widget import TaskListWidget
11
+
12
+ class PomodoroApp(App):
13
+ CSS_PATH = "pttm.css"
14
+ TITLE = "TS PMO"
15
+ COMMANDS = set()
16
+ ENABLE_COMMAND_PALETTE = False
17
+
18
+ BINDINGS = [
19
+ ("q", "quit", "Quit"),
20
+ ("s", "toggle_timer", "Start/Pause"),
21
+ ("r", "reset_timer", "Reset"),
22
+ ("ctrl+r", "reset_session", "Reset Session"),
23
+ ("k", "skip_timer", "Skip"),
24
+ ("f", "set_mode_focus", "Focus Mode"),
25
+ ("g", "set_mode_short", "Short Break"),
26
+ ("b", "set_mode_long", "Long Break"),
27
+ ("t", "focus_todo_input", "Focus Todo"),
28
+ ("ctrl+p", "toggle_shortcuts", "Shortcuts"),
29
+ ]
30
+
31
+ def __init__(self, **kwargs):
32
+ super().__init__(**kwargs)
33
+ self.config = load_config()
34
+ # Log config to startup file next to config file
35
+ from pttm.config import CONFIG_FILE
36
+ log_dir = os.path.dirname(CONFIG_FILE)
37
+ if log_dir:
38
+ os.makedirs(log_dir, exist_ok=True)
39
+ log_file = os.path.join(log_dir, "pmo_startup.log")
40
+ else:
41
+ log_file = "pmo_startup.log"
42
+ try:
43
+ with open(log_file, "w") as f:
44
+ f.write(json.dumps(self.config, indent=4))
45
+ except Exception:
46
+ pass
47
+ self.active_task_id = None
48
+
49
+ def compose(self) -> ComposeResult:
50
+ # yield Header(show_clock=True)
51
+ with TabbedContent(initial="timer-tab"):
52
+ with TabPane("Timer", id="timer-tab"):
53
+ yield TimerWidget()
54
+ with TabPane("Tasks", id="tasks-tab"):
55
+ yield TaskListWidget()
56
+ with TabPane("Settings", id="settings-tab"):
57
+ yield SettingsTab()
58
+
59
+ def action_toggle_timer(self) -> None:
60
+ try:
61
+ timer_widget = self.query_one(TimerWidget)
62
+ timer_widget.is_running = not timer_widget.is_running #type: ignore
63
+ except Exception:
64
+ pass
65
+
66
+ def action_reset_timer(self) -> None:
67
+ try:
68
+ timer_widget = self.query_one(TimerWidget)
69
+ timer_widget.reset_timer_to_mode()
70
+ except Exception:
71
+ pass
72
+
73
+ def action_skip_timer(self) -> None:
74
+ try:
75
+ timer_widget = self.query_one(TimerWidget)
76
+ timer_widget.transition_to_next()
77
+ except Exception:
78
+ pass
79
+
80
+ def action_set_mode_focus(self) -> None:
81
+ try:
82
+ timer_widget = self.query_one(TimerWidget)
83
+ timer_widget.mode = "Focus"
84
+ timer_widget.reset_timer_to_mode()
85
+ except Exception:
86
+ pass
87
+
88
+ def action_set_mode_short(self) -> None:
89
+ try:
90
+ timer_widget = self.query_one(TimerWidget)
91
+ timer_widget.mode = "Short Break"
92
+ timer_widget.reset_timer_to_mode()
93
+ except Exception:
94
+ pass
95
+
96
+ def action_set_mode_long(self) -> None:
97
+ try:
98
+ timer_widget = self.query_one(TimerWidget)
99
+ timer_widget.mode = "Long Break"
100
+ timer_widget.reset_timer_to_mode()
101
+ except Exception:
102
+ pass
103
+
104
+ def action_focus_todo_input(self) -> None:
105
+ try:
106
+ input_box = self.query_one("#new-task-input", Input)
107
+ input_box.focus()
108
+ except Exception:
109
+ pass
110
+
111
+ def action_toggle_shortcuts(self) -> None:
112
+ if isinstance(self.screen, ShortcutsScreen):
113
+ self.pop_screen()
114
+ else:
115
+ self.push_screen(ShortcutsScreen())
116
+
117
+ def action_reset_session(self) -> None:
118
+ try:
119
+ timer_widget = self.query_one(TimerWidget)
120
+ timer_widget.is_running = False #type: ignore
121
+ timer_widget.completed_focus_sessions = 0
122
+ self.config["completed_focus_sessions"] = 0
123
+ save_config(self.config)
124
+
125
+ timer_widget.query_one("#session-count-display", Label).update("Completed: 0 sessions")
126
+ timer_widget.mode = "Focus"
127
+ timer_widget.reset_timer_to_mode()
128
+ self.notify("Pomodoro session reset completely.", title="Session Reset", severity="information")
129
+ except Exception:
130
+ pass
131
+
132
+ def update_active_task_display(self) -> None:
133
+ try:
134
+ timer_widget = self.query_one(TimerWidget)
135
+ active_label = timer_widget.query_one("#active-task-display", Label)
136
+
137
+ active_task = None
138
+ if self.active_task_id:
139
+ for task in self.config.get("tasks", []):
140
+ if task["id"] == self.active_task_id:
141
+ active_task = task
142
+ break
143
+
144
+ if active_task:
145
+ active_label.update(f"Active Task: [bold #f9e2af]{active_task['title']}[/bold #f9e2af]")
146
+ timer_widget.query_one("#timer-clock").add_class("timer-clock-active")
147
+ else:
148
+ active_label.update("No active task selected")
149
+ timer_widget.query_one("#timer-clock").remove_class("timer-clock-active")
150
+ except Exception:
151
+ pass
152
+
153
+ def increment_active_task_pomodoro(self) -> None:
154
+ if not self.active_task_id:
155
+ return
156
+
157
+ for task in self.config["tasks"]:
158
+ if task["id"] == self.active_task_id:
159
+ task["pomodoros"] += 1
160
+ self.notify(f"Pomodoro recorded for: {task['title']}", title="Task Updated", severity="success") #type: ignore
161
+ break
162
+ save_config(self.config)
163
+
164
+ try:
165
+ task_list_widget = self.query_one(TaskListWidget)
166
+ task_list_widget.refresh_tasks()
167
+ except Exception:
168
+ pass
169
+
170
+ def on_settings_updated(self) -> None:
171
+ try:
172
+ timer_widget = self.query_one(TimerWidget)
173
+ if not timer_widget.is_running:
174
+ timer_widget.reset_timer_to_mode()
175
+ except Exception:
176
+ pass
177
+
178
+ def main():
179
+ app = PomodoroApp()
180
+ app.run()
181
+
182
+ if __name__ == "__main__":
183
+ main()
pttm/clock.py ADDED
@@ -0,0 +1,28 @@
1
+ # Big blocky font digits for the clock
2
+ DIGITS = {
3
+ '0': ["███", "█ █", "█ █", "█ █", "███"],
4
+ '1': [" █ ", "██ ", " █ ", " █ ", "███"],
5
+ '2': ["███", " █", "███", "█ ", "███"],
6
+ '3': ["███", " █", "███", " █", "███"],
7
+ '4': ["█ █", "█ █", "███", " █", " █"],
8
+ '5': ["███", "█ ", "███", " █", "███"],
9
+ '6': ["███", "█ ", "███", "█ █", "███"],
10
+ '7': ["███", " █", " █", " █", " █"],
11
+ '8': ["███", "█ █", "███", "█ █", "███"],
12
+ '9': ["███", "█ █", "███", " █", "███"],
13
+ ':': [" ", " █ ", " ", " █ ", " "],
14
+ ' ': [" ", " ", " ", " ", " "]
15
+ }
16
+
17
+ def make_clock_ascii(seconds: int) -> str:
18
+ mins = seconds // 60
19
+ secs = seconds % 60
20
+ time_str = f"{mins:02d}:{secs:02d}"
21
+
22
+ lines = ["", "", "", "", ""]
23
+ for char in time_str:
24
+ char_lines = DIGITS.get(char, DIGITS[' '])
25
+ for idx in range(5):
26
+ lines[idx] += char_lines[idx] + " "
27
+
28
+ return "\n".join(lines)
pttm/config.py ADDED
@@ -0,0 +1,53 @@
1
+ import json
2
+ import os
3
+ import platformdirs
4
+
5
+ CONFIG_PATH_ENV = os.getenv("PMO_CONFIG_PATH")
6
+ if CONFIG_PATH_ENV:
7
+ CONFIG_FILE = CONFIG_PATH_ENV
8
+ else:
9
+ CONFIG_DIR = platformdirs.user_config_dir("pttm", appauthor=False)
10
+ CONFIG_FILE = os.path.join(CONFIG_DIR, "pttm_config.json")
11
+
12
+ DEFAULT_CONFIG = {
13
+ "settings": {
14
+ "focus_time": 25,
15
+ "short_break_time": 5,
16
+ "long_break_time": 15,
17
+ "long_break_interval": 4
18
+ },
19
+ "completed_focus_sessions": 0,
20
+ "tasks": [
21
+ {"id": "1", "title": "Task 1", "completed": True, "pomodoros": 1},
22
+ {"id": "2", "title": "Task 2", "completed": False, "pomodoros": 0},
23
+ {"id": "3", "title": "Task 3", "completed": False, "pomodoros": 0}
24
+ ]
25
+ }
26
+
27
+ def load_config():
28
+ if os.path.exists(CONFIG_FILE):
29
+ try:
30
+ with open(CONFIG_FILE, "r") as f:
31
+ config = json.load(f)
32
+ # Ensure all sections exist
33
+ if "settings" not in config:
34
+ config["settings"] = DEFAULT_CONFIG["settings"]
35
+ if "tasks" not in config:
36
+ config["tasks"] = DEFAULT_CONFIG["tasks"]
37
+ if "completed_focus_sessions" not in config:
38
+ config["completed_focus_sessions"] = 0
39
+ return config
40
+ except Exception:
41
+ return DEFAULT_CONFIG.copy()
42
+ return DEFAULT_CONFIG.copy()
43
+
44
+ def save_config(config):
45
+ try:
46
+ dir_name = os.path.dirname(CONFIG_FILE)
47
+ if dir_name:
48
+ os.makedirs(dir_name, exist_ok=True)
49
+ with open(CONFIG_FILE, "w") as f:
50
+ json.dump(config, f, indent=4)
51
+ except Exception:
52
+ pass
53
+
pttm/pttm.css ADDED
@@ -0,0 +1,483 @@
1
+ /* App wide styling */
2
+ Screen {
3
+ background: #11111b; /* deep obsidian navy */
4
+ min-width: 80;
5
+ min-height: 24;
6
+ }
7
+
8
+ /* Tabs */
9
+ TabbedContent {
10
+ background: #11111b;
11
+ height: 1fr;
12
+ }
13
+
14
+ TabPane {
15
+ padding: 1 1;
16
+ background: #11111b;
17
+ height: 1fr;
18
+ }
19
+
20
+ Tabs {
21
+ background: #1e1e2e;
22
+ color: #cdd6f4;
23
+ }
24
+
25
+ Tabs:focus {
26
+ background: #313244;
27
+ }
28
+
29
+ Tab {
30
+ background: #1e1e2e;
31
+ color: #a6adc8;
32
+ }
33
+
34
+ Tab:hover {
35
+ background: #313244;
36
+ color: #f5c2e7;
37
+ }
38
+
39
+ Tab.--active {
40
+ background: #11111b;
41
+ color: #cba6f7;
42
+ text-style: bold;
43
+ }
44
+
45
+ /* Dashboard Columns */
46
+ Dashboard {
47
+ width: 100%;
48
+ height: 1fr;
49
+ layout: horizontal;
50
+ }
51
+
52
+ /* Timer Widget Styling */
53
+ TimerWidget {
54
+ background: #181825;
55
+ border: double #cba6f7;
56
+ border-title-align: center;
57
+ border-title-style: bold;
58
+ padding: 1 2;
59
+ width: 1fr;
60
+ min-width: 38;
61
+ height: 1fr;
62
+ layout: vertical;
63
+ align: center middle;
64
+ margin-right: 2;
65
+ }
66
+
67
+ #timer-title {
68
+ text-align: center;
69
+ width: 100%;
70
+ height: 1;
71
+ color: #f5c2e7;
72
+ text-style: bold;
73
+ margin-bottom: 1;
74
+ }
75
+
76
+ #active-task-display {
77
+ text-align: center;
78
+ width: 100%;
79
+ height: 1;
80
+ color: #a6adc8;
81
+ margin-bottom: 1;
82
+ }
83
+
84
+ #timer-clock {
85
+ width: 100%;
86
+ height: 9;
87
+ content-align: center middle;
88
+ color: #cba6f7;
89
+ text-style: bold;
90
+ background: #1e1e2e;
91
+ border: solid #313244;
92
+ margin-bottom: 1;
93
+ padding: 1 1;
94
+ }
95
+
96
+ .timer-clock-active {
97
+ color: #f9e2af !important;
98
+ border: solid #f9e2af !important;
99
+ }
100
+
101
+ #timer-controls {
102
+ width: 100%;
103
+ height: 3;
104
+ align: center middle;
105
+ margin-bottom: 1;
106
+ }
107
+
108
+ #timer-controls Button {
109
+ margin: 0 1 0 0;
110
+ background: transparent;
111
+ border: none;
112
+ height: 1;
113
+ padding: 0;
114
+ color: #cdd6f4;
115
+ }
116
+
117
+ #start-btn {
118
+ width: 12;
119
+ }
120
+
121
+ #skip-btn {
122
+ width: 6;
123
+ }
124
+
125
+ #reset-btn {
126
+ width: 7;
127
+ }
128
+
129
+ #timer-controls Button:hover, #timer-controls Button:focus {
130
+ color: #f5c2e7;
131
+ text-style: underline bold;
132
+ }
133
+
134
+ #manual-modes {
135
+ width: 100%;
136
+ height: 3;
137
+ align: center middle;
138
+ margin-bottom: 1;
139
+ }
140
+
141
+ #manual-modes Button {
142
+ margin: 0 1 0 0;
143
+ background: transparent;
144
+ border: none;
145
+ height: 1;
146
+ padding: 0;
147
+ color: #cdd6f4;
148
+ }
149
+
150
+ #mode-focus-btn {
151
+ width: 7;
152
+ }
153
+
154
+ #mode-short-btn {
155
+ width: 7;
156
+ }
157
+
158
+ #mode-long-btn {
159
+ width: 6;
160
+ }
161
+
162
+ #manual-modes Button:hover, #manual-modes Button:focus {
163
+ color: #f5c2e7;
164
+ text-style: underline bold;
165
+ }
166
+
167
+ #session-count-display {
168
+ text-align: center;
169
+ width: 100%;
170
+ height: 1;
171
+ color: #f9e2af;
172
+ text-style: bold;
173
+ }
174
+
175
+ /* Hidden class for toggle button */
176
+ .hidden {
177
+ display: none;
178
+ }
179
+
180
+ /* Todo Panel Styling */
181
+ TaskListWidget {
182
+ background: #181825;
183
+ border: double #cba6f7;
184
+ padding: 1 1;
185
+ width: 1fr;
186
+ min-width: 38;
187
+ height: 1fr;
188
+ layout: vertical;
189
+ }
190
+
191
+ #todo-title {
192
+ text-align: center;
193
+ width: 100%;
194
+ height: 1;
195
+ color: #cba6f7;
196
+ text-style: bold;
197
+ margin-bottom: 1;
198
+ }
199
+
200
+ #todo-input-row {
201
+ width: 100%;
202
+ height: 3;
203
+ margin-bottom: 1;
204
+ align: left middle;
205
+ }
206
+
207
+ #new-task-input {
208
+ width: 1fr;
209
+ background: #1e1e2e;
210
+ color: #cdd6f4;
211
+ border: solid #313244;
212
+ }
213
+
214
+ #new-task-input:focus {
215
+ border: solid #cba6f7;
216
+ }
217
+
218
+ #add-task-btn {
219
+ background: transparent;
220
+ border: none;
221
+ height: 3;
222
+ width: auto;
223
+ padding: 0 1;
224
+ color: #cba6f7;
225
+ margin-left: 1;
226
+ }
227
+
228
+ #add-task-btn:hover, #add-task-btn:focus {
229
+ color: #f5c2e7;
230
+ text-style: underline bold;
231
+ }
232
+
233
+ #tasks-container {
234
+ width: 100%;
235
+ height: 1fr;
236
+ border: none;
237
+ }
238
+
239
+ /* Task Item Styling */
240
+ TaskItem {
241
+ layout: horizontal;
242
+ align: left middle;
243
+ width: 100%;
244
+ height: 3;
245
+ margin-bottom: 1;
246
+ padding: 0 1;
247
+ background: #1e1e2e;
248
+ border: solid #313244;
249
+ }
250
+
251
+ TaskItem:hover {
252
+ background: #313244;
253
+ }
254
+
255
+ TaskItem.active-task {
256
+ background: #45475a;
257
+ border: double #f9e2af;
258
+ }
259
+
260
+ .task-title {
261
+ width: 1fr;
262
+ margin: 0 1;
263
+ color: #cdd6f4;
264
+ }
265
+
266
+ .task-title.completed {
267
+ text-style: strike;
268
+ color: #585b70;
269
+ }
270
+
271
+ .task-edit-input {
272
+ width: 1fr;
273
+ margin: 0 1;
274
+ height: 1;
275
+ border: none;
276
+ background: #313244;
277
+ color: #cdd6f4;
278
+ padding: 0 1;
279
+ }
280
+
281
+ .task-pomo {
282
+ width: auto;
283
+ margin-right: 1;
284
+ color: #f38ba8;
285
+ }
286
+
287
+ /* Sleek Text Link Button Styling */
288
+ .task-check-btn, .task-edit-btn, .task-save-btn, .task-select-btn, .task-delete-btn {
289
+ background: transparent;
290
+ border: none;
291
+ height: 1;
292
+ min-width: 0;
293
+ padding: 0;
294
+ margin: 0;
295
+ }
296
+
297
+ .task-check-btn {
298
+ width: 3;
299
+ color: #89b4fa;
300
+ text-style: bold;
301
+ }
302
+
303
+ .task-check-btn:hover, .task-check-btn:focus {
304
+ color: #b4befe;
305
+ }
306
+
307
+ .task-edit-btn {
308
+ color: #a6e3a1;
309
+ margin-left: 1;
310
+ width: 6;
311
+ }
312
+
313
+ .task-edit-btn:hover, .task-edit-btn:focus {
314
+ text-style: underline;
315
+ }
316
+
317
+ .task-save-btn {
318
+ color: #a6e3a1;
319
+ margin-left: 1;
320
+ width: 6;
321
+ }
322
+
323
+ .task-save-btn:hover, .task-save-btn:focus {
324
+ text-style: underline;
325
+ }
326
+
327
+ .task-select-btn {
328
+ color: #cdd6f4;
329
+ margin-left: 1;
330
+ width: 10;
331
+ }
332
+
333
+ .task-select-btn:hover, .task-select-btn:focus {
334
+ text-style: underline;
335
+ }
336
+
337
+ TaskItem.active-task .task-select-btn {
338
+ color: #fab387;
339
+ text-style: bold;
340
+ }
341
+
342
+ .task-delete-btn {
343
+ color: #f38ba8;
344
+ margin-left: 1;
345
+ width: 5;
346
+ }
347
+
348
+ .task-delete-btn:hover, .task-delete-btn:focus {
349
+ text-style: underline;
350
+ }
351
+
352
+ /* Settings Styling */
353
+ #settings-form {
354
+ background: #181825;
355
+ border: double #cba6f7;
356
+ padding: 2 4;
357
+ max-width: 1fr;
358
+ align: center middle;
359
+ margin: 2 4;
360
+ }
361
+
362
+ .settings-title {
363
+ text-align: center;
364
+ width: 100%;
365
+ height: 1;
366
+ color: #cba6f7;
367
+ text-style: bold;
368
+ margin-bottom: 2;
369
+ }
370
+
371
+ .settings-row {
372
+ width: 100%;
373
+ height: 3;
374
+ align: left middle;
375
+ margin-bottom: 1;
376
+ }
377
+
378
+ .settings-label {
379
+ width: 30;
380
+ color: #cdd6f4;
381
+ }
382
+
383
+ #settings-form Input {
384
+ width: 1fr;
385
+ background: #1e1e2e;
386
+ color: #cdd6f4;
387
+ border: solid #313244;
388
+ }
389
+
390
+ #settings-form Input:focus {
391
+ border: solid #cba6f7;
392
+ }
393
+
394
+ #save-settings-btn {
395
+ margin-top: 2;
396
+ background: transparent;
397
+ border: none;
398
+ height: 1;
399
+ width: auto;
400
+ padding: 0;
401
+ color: #cba6f7;
402
+ text-style: bold;
403
+ }
404
+
405
+ #save-settings-btn:hover, #save-settings-btn:focus {
406
+ color: #f5c2e7;
407
+ text-style: underline bold;
408
+ }
409
+
410
+ #settings-status {
411
+ text-align: center;
412
+ width: 100%;
413
+ height: 1;
414
+ margin-top: 1;
415
+ text-style: bold;
416
+ }
417
+
418
+ /* Focused Task Items & Todo Help */
419
+ TaskItem:focus {
420
+ background: #313244;
421
+ border: solid #cba6f7;
422
+ }
423
+
424
+ TaskItem.active-task:focus {
425
+ background: #585b70;
426
+ border: double #f9e2af;
427
+ }
428
+
429
+ #todo-help {
430
+ text-align: center;
431
+ width: 100%;
432
+ height: 1;
433
+ color: #a6adc8;
434
+ margin-top: 1;
435
+ }
436
+
437
+ /* Keyboard Shortcuts Overlay Styling */
438
+ ShortcutsScreen {
439
+ align: center middle;
440
+ background: rgba(0, 0, 0, 0.7);
441
+ }
442
+
443
+ #shortcuts-dialog {
444
+ width: 90%;
445
+ max-width: 80;
446
+ height: 90%;
447
+ max-height: 28;
448
+ background: #181825;
449
+ border: double #cba6f7;
450
+ padding: 1 2;
451
+ }
452
+
453
+ #shortcuts-title {
454
+ text-align: center;
455
+ width: 100%;
456
+ height: 1;
457
+ color: #cba6f7;
458
+ text-style: bold;
459
+ margin-bottom: 1;
460
+ }
461
+
462
+ #shortcuts-content {
463
+ width: 100%;
464
+ height: 1fr;
465
+ margin-bottom: 1;
466
+ }
467
+
468
+ .section-header {
469
+ color: #f5c2e7;
470
+ margin-top: 1;
471
+ text-style: bold;
472
+ }
473
+
474
+ .shortcut-entry {
475
+ color: #cdd6f4;
476
+ }
477
+
478
+ #shortcuts-close-btn {
479
+ text-align: center;
480
+ width: 100%;
481
+ color: #a6adc8;
482
+ margin-top: 1;
483
+ }
@@ -0,0 +1 @@
1
+ # Widgets package
@@ -0,0 +1,9 @@
1
+ from textual.widget import Widget
2
+ from textual.app import ComposeResult
3
+ from pttm.widgets.timer_widget import TimerWidget
4
+ from pttm.widgets.task_list_widget import TaskListWidget
5
+
6
+ class Dashboard(Widget):
7
+ def compose(self) -> ComposeResult:
8
+ yield TimerWidget(id="timer-widget")
9
+ yield TaskListWidget(id="task-list-widget")
@@ -0,0 +1,65 @@
1
+ from textual.widget import Widget
2
+ from textual.app import ComposeResult
3
+ from textual.widgets import Label, Input, Button
4
+ from textual.containers import Horizontal, VerticalScroll
5
+ from pttm.config import save_config
6
+
7
+ class SettingsTab(Widget):
8
+ def compose(self) -> ComposeResult:
9
+ with VerticalScroll(id="settings-form"):
10
+ yield Label("POMODORO SETTINGS", classes="settings-title")
11
+
12
+ with Horizontal(classes="settings-row"):
13
+ yield Label("Focus Session (mins):", classes="settings-label")
14
+ yield Input(placeholder="25", id="setting-focus", restrict=r"^[0-9]*$")
15
+
16
+ with Horizontal(classes="settings-row"):
17
+ yield Label("Short Break (mins):", classes="settings-label")
18
+ yield Input(placeholder="5", id="setting-short", restrict=r"^[0-9]*$")
19
+
20
+ with Horizontal(classes="settings-row"):
21
+ yield Label("Long Break (mins):", classes="settings-label")
22
+ yield Input(placeholder="15", id="setting-long", restrict=r"^[0-9]*$")
23
+
24
+ with Horizontal(classes="settings-row"):
25
+ yield Label("Sessions before Long Break:", classes="settings-label")
26
+ yield Input(placeholder="4", id="setting-interval", restrict=r"^[0-9]*$")
27
+
28
+ yield Button("[apply & save]", id="save-settings-btn")
29
+ yield Label("", id="settings-status")
30
+
31
+ def on_mount(self) -> None:
32
+ config = self.app.config.get("settings", {}) #type: ignore
33
+ self.query_one("#setting-focus", Input).value = str(config.get("focus_time", 25))
34
+ self.query_one("#setting-short", Input).value = str(config.get("short_break_time", 5))
35
+ self.query_one("#setting-long", Input).value = str(config.get("long_break_time", 15))
36
+ self.query_one("#setting-interval", Input).value = str(config.get("long_break_interval", 4))
37
+
38
+ def on_button_pressed(self, event: Button.Pressed) -> None:
39
+ if event.button.id == "save-settings-btn":
40
+ try:
41
+ focus_time = int(self.query_one("#setting-focus", Input).value)
42
+ short_break = int(self.query_one("#setting-short", Input).value)
43
+ long_break = int(self.query_one("#setting-long", Input).value)
44
+ interval = int(self.query_one("#setting-interval", Input).value)
45
+
46
+ if focus_time <= 0 or short_break <= 0 or long_break <= 0 or interval <= 0:
47
+ raise ValueError("All values must be positive integers.")
48
+
49
+ self.app.config["settings"] = { #type: ignore
50
+ "focus_time": focus_time,
51
+ "short_break_time": short_break,
52
+ "long_break_time": long_break,
53
+ "long_break_interval": interval
54
+ }
55
+ save_config(self.app.config) #type: ignore
56
+
57
+ status_label = self.query_one("#settings-status", Label)
58
+ status_label.update("[OK] Settings saved successfully!")
59
+ status_label.styles.color = "#a6e3a1"
60
+
61
+ self.app.on_settings_updated() #type: ignore
62
+ except ValueError as e:
63
+ status_label = self.query_one("#settings-status", Label)
64
+ status_label.update(f"[Error] {str(e)}")
65
+ status_label.styles.color = "#f38ba8"
@@ -0,0 +1,40 @@
1
+ from textual.screen import ModalScreen
2
+ from textual.app import ComposeResult
3
+ from textual.containers import Vertical, VerticalScroll
4
+ from textual.widgets import Label
5
+
6
+ class ShortcutsScreen(ModalScreen):
7
+ BINDINGS = [
8
+ ("escape", "dismiss_dialog", "Close"),
9
+ ("ctrl+p", "dismiss_dialog", "Close"),
10
+ ]
11
+
12
+ def compose(self) -> ComposeResult:
13
+ with Vertical(id="shortcuts-dialog"):
14
+ yield Label("KEYBOARD SHORTCUTS", id="shortcuts-title")
15
+
16
+ with VerticalScroll(id="shortcuts-content"):
17
+ yield Label("[bold]Global Controls[/bold]", classes="section-header")
18
+ yield Label(" q - Quit App", classes="shortcut-entry")
19
+ yield Label(" s - Start / Pause Timer", classes="shortcut-entry")
20
+ yield Label(" r - Reset Timer", classes="shortcut-entry")
21
+ yield Label(" Ctrl+R - Reset Entire Session", classes="shortcut-entry")
22
+ yield Label(" k - Skip Session", classes="shortcut-entry")
23
+ yield Label(" f - Switch to Focus Session", classes="shortcut-entry")
24
+ yield Label(" g - Switch to Short Break", classes="shortcut-entry")
25
+ yield Label(" b - Switch to Long Break", classes="shortcut-entry")
26
+ yield Label(" t - Focus Todo Creation Box", classes="shortcut-entry")
27
+ yield Label(" Ctrl+P - Toggle Keyboard Shortcuts", classes="shortcut-entry")
28
+
29
+ yield Label("", classes="spacer")
30
+ yield Label("[bold]Todo Checklist Controls[/bold]", classes="section-header")
31
+ yield Label(" Tab - Navigate through checklist", classes="shortcut-entry")
32
+ yield Label(" Space - Toggle task completion", classes="shortcut-entry")
33
+ yield Label(" Enter / f - Set task as active target", classes="shortcut-entry")
34
+ yield Label(" e - Rename task title inline", classes="shortcut-entry")
35
+ yield Label(" d - Delete task from checklist", classes="shortcut-entry")
36
+
37
+ yield Label("[ Close [esc] ]", id="shortcuts-close-btn")
38
+
39
+ def action_dismiss_dialog(self) -> None:
40
+ self.dismiss()
@@ -0,0 +1,109 @@
1
+ from textual.widget import Widget
2
+ from textual.app import ComposeResult
3
+ from textual.widgets import Label, Input
4
+ from textual.message import Message
5
+ from textual import events
6
+
7
+ class TaskItem(Widget):
8
+ """A widget representing a single task item."""
9
+
10
+ can_focus = True
11
+
12
+ class Toggle(Message):
13
+ def __init__(self, task_item: "TaskItem", completed: bool):
14
+ super().__init__()
15
+ self.task_item = task_item
16
+ self.completed = completed
17
+
18
+ class Select(Message):
19
+ def __init__(self, task_item: "TaskItem"):
20
+ super().__init__()
21
+ self.task_item = task_item
22
+
23
+ class Delete(Message):
24
+ def __init__(self, task_item: "TaskItem"):
25
+ super().__init__()
26
+ self.task_item = task_item
27
+
28
+ class Rename(Message):
29
+ def __init__(self, task_item: "TaskItem", new_title: str):
30
+ super().__init__()
31
+ self.task_item = task_item
32
+ self.new_title = new_title
33
+
34
+ def __init__(self, task_id: str, title: str, completed: bool, pomodoros: int, is_active: bool = False):
35
+ super().__init__()
36
+ self.task_id = task_id
37
+ self.title = title
38
+ self.completed = completed
39
+ self.pomodoros = pomodoros
40
+ self.is_active = is_active
41
+
42
+ def compose(self) -> ComposeResult:
43
+ check_char = "[x]" if self.completed else "[ ]"
44
+ yield Label(check_char, classes="task-check-lbl")
45
+
46
+ title_cls = "task-title completed" if self.completed else "task-title"
47
+ yield Label(self.title, classes=title_cls)
48
+ yield Input(value=self.title, classes="task-edit-input hidden")
49
+
50
+ yield Label(f"({self.pomodoros})", classes="task-pomo")
51
+
52
+ def on_key(self, event: events.Key) -> None:
53
+ key = event.key
54
+ if key == "space":
55
+ event.stop()
56
+ self.completed = not self.completed
57
+ self.post_message(self.Toggle(self, self.completed))
58
+ self.refresh_task_display()
59
+ elif key == "e":
60
+ event.stop()
61
+ self.start_editing()
62
+ elif key == "d":
63
+ event.stop()
64
+ self.post_message(self.Delete(self))
65
+ elif key in ("enter", "f"):
66
+ event.stop()
67
+ self.post_message(self.Select(self))
68
+
69
+ def refresh_task_display(self) -> None:
70
+ try:
71
+ check_lbl = self.query_one(".task-check-lbl", Label)
72
+ check_lbl.update("[x]" if self.completed else "[ ]")
73
+ title_lbl = self.query_one(".task-title", Label)
74
+ if self.completed:
75
+ title_lbl.add_class("completed")
76
+ else:
77
+ title_lbl.remove_class("completed")
78
+ except Exception:
79
+ pass
80
+
81
+ def on_input_submitted(self, event: Input.Submitted) -> None:
82
+ if "task-edit-input" in event.input.classes:
83
+ event.stop()
84
+ self.save_edit()
85
+
86
+ def start_editing(self) -> None:
87
+ try:
88
+ self.query_one(".task-title", Label).add_class("hidden")
89
+ edit_input = self.query_one(".task-edit-input", Input)
90
+ edit_input.remove_class("hidden")
91
+ edit_input.focus()
92
+ except Exception:
93
+ pass
94
+
95
+ def save_edit(self) -> None:
96
+ try:
97
+ edit_input = self.query_one(".task-edit-input", Input)
98
+ new_title = edit_input.value.strip()
99
+ if new_title:
100
+ self.title = new_title
101
+ label = self.query_one(".task-title", Label)
102
+ label.update(self.title)
103
+ self.post_message(self.Rename(self, self.title))
104
+
105
+ self.query_one(".task-title", Label).remove_class("hidden")
106
+ edit_input.add_class("hidden")
107
+ self.focus()
108
+ except Exception:
109
+ pass
@@ -0,0 +1,104 @@
1
+ import uuid
2
+ from textual.widget import Widget
3
+ from textual.app import ComposeResult
4
+ from textual.widgets import Label, Input
5
+ from textual.containers import Horizontal, VerticalScroll
6
+ from pttm.widgets.task_item import TaskItem
7
+ from pttm.config import save_config
8
+
9
+ class TaskListWidget(Widget):
10
+ """Widget to display the todo list and manage tasks."""
11
+
12
+ def compose(self) -> ComposeResult:
13
+ yield Label("TASK LIST", id="todo-title")
14
+
15
+ with Horizontal(id="todo-input-row"):
16
+ yield Input(placeholder="Add a new task...", id="new-task-input")
17
+
18
+ yield VerticalScroll(id="tasks-container")
19
+ # yield Label("Space: check | Enter/f: focus | e: edit | d: delete", id="todo-help")
20
+
21
+ def on_mount(self) -> None:
22
+ self.refresh_tasks()
23
+
24
+ def refresh_tasks(self) -> None:
25
+ container = self.query_one("#tasks-container")
26
+
27
+ # Clear existing tasks
28
+ for child in list(container.children):
29
+ child.remove()
30
+
31
+ # Add tasks from app config
32
+ for task in self.app.config.get("tasks", []): #type: ignore
33
+ is_active = (task["id"] == self.app.active_task_id) #type: ignore
34
+ task_item = TaskItem(
35
+ task_id=task["id"],
36
+ title=task["title"],
37
+ completed=task["completed"],
38
+ pomodoros=task["pomodoros"],
39
+ is_active=is_active
40
+ )
41
+ container.mount(task_item)
42
+
43
+ if is_active:
44
+ task_item.add_class("active-task")
45
+
46
+ def on_task_item_toggle(self, event: TaskItem.Toggle) -> None:
47
+ for task in self.app.config["tasks"]: #type: ignore
48
+ if task["id"] == event.task_item.task_id:
49
+ task["completed"] = event.completed
50
+ break
51
+ save_config(self.app.config) #type: ignore
52
+
53
+ def on_task_item_select(self, event: TaskItem.Select) -> None:
54
+ if self.app.active_task_id == event.task_item.task_id: #type: ignore
55
+ self.app.active_task_id = None #type: ignore
56
+ else:
57
+ self.app.active_task_id = event.task_item.task_id #type: ignore
58
+
59
+ self.app.update_active_task_display() #type: ignore
60
+ self.refresh_tasks()
61
+
62
+ def on_task_item_delete(self, event: TaskItem.Delete) -> None:
63
+ if self.app.active_task_id == event.task_item.task_id: #type: ignore
64
+ self.app.active_task_id = None #type: ignore
65
+
66
+ self.app.config["tasks"] = [t for t in self.app.config["tasks"] if t["id"] != event.task_item.task_id] #type: ignore
67
+ save_config(self.app.config) #type: ignore
68
+
69
+ self.app.update_active_task_display() #type: ignore
70
+ self.refresh_tasks()
71
+
72
+ def on_task_item_rename(self, event: TaskItem.Rename) -> None:
73
+ for task in self.app.config["tasks"]: #type: ignore
74
+ if task["id"] == event.task_item.task_id:
75
+ task["title"] = event.new_title
76
+ break
77
+ save_config(self.app.config) #type: ignore
78
+
79
+ if self.app.active_task_id == event.task_item.task_id: #type: ignore
80
+ self.app.update_active_task_display() #type: ignore
81
+
82
+ def add_task(self) -> None:
83
+ input_widget = self.query_one("#new-task-input", Input)
84
+ title = input_widget.value.strip()
85
+ if not title:
86
+ return
87
+
88
+ new_task = {
89
+ "id": str(uuid.uuid4()),
90
+ "title": title,
91
+ "completed": False,
92
+ "pomodoros": 0
93
+ }
94
+
95
+ self.app.config.setdefault("tasks", []) #type: ignore
96
+ self.app.config["tasks"].append(new_task) #type: ignore
97
+ save_config(self.app.config) #type: ignore
98
+
99
+ input_widget.value = ""
100
+ self.refresh_tasks()
101
+
102
+ def on_input_submitted(self, event: Input.Submitted) -> None:
103
+ if event.input.id == "new-task-input":
104
+ self.add_task()
@@ -0,0 +1,109 @@
1
+ from textual.widget import Widget
2
+ from textual.app import ComposeResult
3
+ from textual.widgets import Label, Static
4
+ from textual.reactive import reactive
5
+ from pttm.clock import make_clock_ascii
6
+ from pttm.config import save_config
7
+
8
+ MODE_CONFIG_KEYS = {
9
+ "Focus": "focus_time",
10
+ "Short Break": "short_break_time",
11
+ "Long Break": "long_break_time"
12
+ }
13
+
14
+ class TimerWidget(Widget):
15
+ """Widget to display the large digital countdown timer and control panel."""
16
+
17
+ mode = reactive("Focus")
18
+ time_remaining = reactive(25 * 60)
19
+ is_running = reactive(False) #type: ignore
20
+
21
+ def compose(self) -> ComposeResult:
22
+ yield Label("FOCUS SESSION", id="timer-title")
23
+ yield Label("No task active", id="active-task-display")
24
+ yield Static(id="timer-clock")
25
+ yield Label("Completed: 0 sessions", id="session-count-display")
26
+
27
+ def on_mount(self) -> None:
28
+ self.set_interval(1.0, self.tick)
29
+
30
+ # Load values from config
31
+ focus_mins = self.app.config["settings"].get("focus_time", 25) #type: ignore
32
+ self.time_remaining = focus_mins * 60
33
+ self.completed_focus_sessions = self.app.config.get("completed_focus_sessions", 0) #type: ignore
34
+
35
+ # Force initial display updates
36
+ self.watch_mode(self.mode)
37
+ self.watch_time_remaining(self.time_remaining)
38
+ self.query_one("#session-count-display", Label).update(f"Completed: {self.completed_focus_sessions} sessions")
39
+
40
+ def watch_mode(self, mode: str) -> None:
41
+ try:
42
+ title = self.query_one("#timer-title", Label)
43
+ title.update(f"{mode.upper()} SESSION")
44
+
45
+ self.remove_class("mode-focus", "mode-short", "mode-long")
46
+ if mode == "Focus":
47
+ self.add_class("mode-focus")
48
+ elif mode == "Short Break":
49
+ self.add_class("mode-short")
50
+ elif mode == "Long Break":
51
+ self.add_class("mode-long")
52
+ except Exception:
53
+ pass
54
+
55
+ def watch_time_remaining(self, seconds: int) -> None:
56
+ try:
57
+ self.query_one("#timer-clock", Static).update(make_clock_ascii(seconds))
58
+ except Exception:
59
+ pass
60
+
61
+ def tick(self) -> None:
62
+ if self.is_running:
63
+ if self.time_remaining > 0:
64
+ self.time_remaining -= 1
65
+ else:
66
+ self.timer_finished()
67
+
68
+ def timer_finished(self) -> None:
69
+ self.is_running = False #type: ignore
70
+ self.update_controls()
71
+
72
+ # Terminal visual/audio beep
73
+ print("\a", end="", flush=True)
74
+
75
+ self.transition_to_next()
76
+
77
+ def transition_to_next(self) -> None:
78
+ if self.mode == "Focus":
79
+ self.completed_focus_sessions += 1
80
+ self.app.config["completed_focus_sessions"] = self.completed_focus_sessions #type: ignore
81
+ save_config(self.app.config) #type: ignore
82
+
83
+ self.query_one("#session-count-display", Label).update(f"Completed: {self.completed_focus_sessions} sessions")
84
+
85
+ if self.app.active_task_id: #type: ignore
86
+ self.app.increment_active_task_pomodoro() #type: ignore
87
+
88
+ interval = self.app.config["settings"].get("long_break_interval", 4) #type: ignore
89
+ if self.completed_focus_sessions % interval == 0:
90
+ self.mode = "Long Break"
91
+ self.app.notify("Focus session completed! Time for a long break.", title="Pomodoro Finished", severity="information")
92
+ else:
93
+ self.mode = "Short Break"
94
+ self.app.notify("Focus session completed! Time for a short break.", title="Pomodoro Finished", severity="information")
95
+ else:
96
+ self.mode = "Focus"
97
+ self.app.notify("Break finished! Time to start focusing.", title="Break Finished", severity="warning")
98
+
99
+ self.reset_timer_to_mode()
100
+
101
+ def reset_timer_to_mode(self) -> None:
102
+ key = MODE_CONFIG_KEYS[self.mode]
103
+ minutes = self.app.config["settings"].get(key, 25) #type: ignore
104
+ self.time_remaining = minutes * 60
105
+ self.is_running = False #type: ignore
106
+ self.update_controls()
107
+
108
+ def update_controls(self) -> None:
109
+ pass
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: pttm
3
+ Version: 0.1.0
4
+ Summary: Pomodoro Terminal Timer & Manager
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: linkify-it-py==2.1.0
8
+ Requires-Dist: markdown-it-py==4.2.0
9
+ Requires-Dist: mdit-py-plugins==0.6.1
10
+ Requires-Dist: mdurl==0.1.2
11
+ Requires-Dist: platformdirs==4.10.0
12
+ Requires-Dist: Pygments==2.20.0
13
+ Requires-Dist: rich==15.0.0
14
+ Requires-Dist: textual==8.2.7
15
+ Requires-Dist: typing_extensions==4.15.0
16
+ Requires-Dist: uc-micro-py==2.0.0
17
+
18
+ # PTTM - Pomodoro Terminal Timer & Manager
19
+
20
+ PTTM is a terminal-based Pomodoro app built with [Textual](https://textual.textualize.io/). It provides a timer, task tracking, and configurable work/break settings in a compact TUI.
21
+
22
+ ## Features
23
+
24
+ - Pomodoro timer with focus, short break, and long break modes
25
+ - Task list with per-task Pomodoro counts
26
+ - Persistent configuration stored as JSON
27
+ - Keyboard shortcuts for common timer and task actions
28
+ - In-app settings tab for adjusting timing values
29
+
30
+ ## Requirements
31
+
32
+ - Python 3.10 or newer
33
+
34
+ ## Installation
35
+
36
+ Install the dependencies from the project root:
37
+
38
+ ```bash
39
+ pip install -r requirements.txt
40
+ ```
41
+
42
+ ## Run
43
+
44
+ From the repository root, start the app with:
45
+
46
+ ```bash
47
+ python pttm.py
48
+ ```
49
+
50
+ You can also run the app module directly:
51
+
52
+ ```bash
53
+ python -m pttm.app
54
+ ```
55
+
56
+ ## Keyboard Shortcuts
57
+
58
+ - `q` quit
59
+ - `s` start or pause the timer
60
+ - `r` reset the current timer
61
+ - `ctrl+r` reset the full session
62
+ - `k` skip to the next timer mode
63
+ - `f` switch to focus mode
64
+ - `g` switch to short break mode
65
+ - `b` switch to long break mode
66
+ - `t` focus the new task input
67
+ - `ctrl+p` show or hide the shortcuts screen
68
+
69
+ ## Configuration
70
+
71
+ The app reads and writes a JSON config file. By default, the file is stored in your user config directory. You can override the location by setting `PMO_CONFIG_PATH` before launch.
72
+
73
+ Example:
74
+
75
+ ```bash
76
+ export PMO_CONFIG_PATH=./pmo_config.json
77
+ python pttm.py
78
+ ```
79
+
80
+ The config includes timer settings, completed focus session count, and task data.
81
+
82
+ ## Tests
83
+
84
+ Run the test suite with:
85
+
86
+ ```bash
87
+ python -m unittest test_pttm.py
88
+ ```
89
+
90
+ ## Project Layout
91
+
92
+ - `pttm/app.py` application entry point
93
+ - `pttm/config.py` config load/save helpers
94
+ - `pttm/clock.py` ASCII clock rendering
95
+ - `pttm/widgets/` UI components
96
+ - `pttm/pttm.css` Textual stylesheet
@@ -0,0 +1,17 @@
1
+ pttm/__init__.py,sha256=miJeuMU_HF1HetIG4jMHFzFXDULyeKIly7CIBYChTZQ,136
2
+ pttm/app.py,sha256=3W0zcP3EBE-lxU1p19TsBpdkHuYQTiZIhC_bzC-DeZ4,6330
3
+ pttm/clock.py,sha256=qtIFY4omLzC2ZVs2sBreSwzb2UvNEusVtRcH1DaHekU,1170
4
+ pttm/config.py,sha256=YZFiVQAyNlKZCtZCsDnpRXWaUOoRhelNyi-LjU-pUZU,1672
5
+ pttm/pttm.css,sha256=XNwU8tqU42GHfrKmw1xZbcUOyKCeyPx7rDlqBy6GeEI,7433
6
+ pttm/widgets/__init__.py,sha256=iyFvm2dMR8o5NE_viqH_dsu8P4E0aNrKmDUcKhUMbV4,18
7
+ pttm/widgets/dashboard.py,sha256=Lh48aGrmGw-GPqUzRZ5ZX7kK8oHH_s_pXMLMEK_Dk9E,342
8
+ pttm/widgets/settings_tab.py,sha256=vxSujN6p85_GfUzyur9cXCcoOwjoRWhFN72FEUwSsN8,3336
9
+ pttm/widgets/shortcuts_screen.py,sha256=RMAbYh2y-9P4rpqnzphxrJIqH89A9pTLxbdO3IXSMwc,2289
10
+ pttm/widgets/task_item.py,sha256=AOOwnUVgqznoW9DnrnPQ3ZqnsyaCvo_CLmvGcOjG7Jg,3841
11
+ pttm/widgets/task_list_widget.py,sha256=c7xF9KzxFboIjyYXn75WhI0iLVlcm3izzbmxqSGhncM,3845
12
+ pttm/widgets/timer_widget.py,sha256=zCT6o8Y0GFWMmCbe6bdzmgmZgML-Krmm17s03Itr5Rw,4187
13
+ pttm-0.1.0.dist-info/METADATA,sha256=VK1p1XirvWQaTHPim0Dpn_g0GGI8t50v_7O184Q5bx0,2287
14
+ pttm-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
15
+ pttm-0.1.0.dist-info/entry_points.txt,sha256=51E83u5cM0XY0AxsNhrruuRMoH8iOj2DuB1UKVQy_2E,39
16
+ pttm-0.1.0.dist-info/top_level.txt,sha256=Ylw7xNIsz5KtnrPxMqo0_TbZ1NKZEQAE_a6yv8dfv_Y,5
17
+ pttm-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pttm = pttm.app:main
@@ -0,0 +1 @@
1
+ pttm