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 +3 -0
- pttm/app.py +183 -0
- pttm/clock.py +28 -0
- pttm/config.py +53 -0
- pttm/pttm.css +483 -0
- pttm/widgets/__init__.py +1 -0
- pttm/widgets/dashboard.py +9 -0
- pttm/widgets/settings_tab.py +65 -0
- pttm/widgets/shortcuts_screen.py +40 -0
- pttm/widgets/task_item.py +109 -0
- pttm/widgets/task_list_widget.py +104 -0
- pttm/widgets/timer_widget.py +109 -0
- pttm-0.1.0.dist-info/METADATA +96 -0
- pttm-0.1.0.dist-info/RECORD +17 -0
- pttm-0.1.0.dist-info/WHEEL +5 -0
- pttm-0.1.0.dist-info/entry_points.txt +2 -0
- pttm-0.1.0.dist-info/top_level.txt +1 -0
pttm/__init__.py
ADDED
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
|
+
}
|
pttm/widgets/__init__.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
pttm
|