lolTasks 1.0.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.
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lolTasks
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A terminal-based weekly task management application with advanced text editing
|
|
5
|
+
Home-page: https://github.com/lstephensFederation/lolTasks
|
|
6
|
+
Author: Luke J. Stephens
|
|
7
|
+
Author-email: l.stephens@federation.edu.au
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Bug Reports, https://github.com/lstephensFederation/lolTasks/issues
|
|
10
|
+
Project-URL: Source, https://github.com/lstephensFederation/lolTasks
|
|
11
|
+
Keywords: task management terminal curses weekly planner
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.6
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.6
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Dynamic: author
|
|
27
|
+
Dynamic: author-email
|
|
28
|
+
Dynamic: classifier
|
|
29
|
+
Dynamic: description
|
|
30
|
+
Dynamic: description-content-type
|
|
31
|
+
Dynamic: home-page
|
|
32
|
+
Dynamic: keywords
|
|
33
|
+
Dynamic: license
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
Dynamic: project-url
|
|
36
|
+
Dynamic: requires-python
|
|
37
|
+
Dynamic: summary
|
|
38
|
+
|
|
39
|
+
# lolTasks
|
|
40
|
+
|
|
41
|
+
A powerful terminal-based weekly task management application built with Python and curses.
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
- **Weekly Task Management**: Organize tasks by week with a clean, intuitive interface
|
|
46
|
+
- **Advanced Text Editing**: Full-featured line editor with undo/redo, word navigation, and standard shortcuts
|
|
47
|
+
- **Cross-Platform**: Works on macOS, Linux, and Windows (with appropriate terminal)
|
|
48
|
+
- **Persistent Storage**: JSON-based data storage in your home directory
|
|
49
|
+
- **Vim-Style Shortcuts**: Familiar key bindings for power users
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
### From PyPI (Recommended)
|
|
54
|
+
```bash
|
|
55
|
+
pip install lolTasks
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### From Source
|
|
59
|
+
```bash
|
|
60
|
+
git clone https://github.com/lstephensFederation/lolTasks.git
|
|
61
|
+
cd lolTasks
|
|
62
|
+
pip install .
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
### Basic Usage
|
|
68
|
+
```bash
|
|
69
|
+
lolTasks
|
|
70
|
+
# or
|
|
71
|
+
task
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Command Line Options
|
|
75
|
+
```bash
|
|
76
|
+
lolTasks --help # Show help and key bindings
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Key Bindings
|
|
80
|
+
|
|
81
|
+
### Navigation
|
|
82
|
+
- `↑/↓` or `k/j`: Move selection up/down
|
|
83
|
+
- `←/→` or `h/l`: Navigate to previous/next week
|
|
84
|
+
- `Tab`: Cycle task state forward (TO-DO → PENDING → COMPLETED)
|
|
85
|
+
- `Shift+Tab`: Cycle task state backward
|
|
86
|
+
|
|
87
|
+
### Task Management
|
|
88
|
+
- `a`: Add new task after selected item
|
|
89
|
+
- `Enter`: Edit selected task (or week title if none selected)
|
|
90
|
+
- `I`: Edit task at beginning of line (vim-style)
|
|
91
|
+
- `d`: Delete selected task
|
|
92
|
+
- `r`: Toggle reorder mode for moving tasks up/down
|
|
93
|
+
- `Esc`: Exit edit mode
|
|
94
|
+
|
|
95
|
+
### Global Actions
|
|
96
|
+
- `Ctrl + U`: Undo last action
|
|
97
|
+
- `Ctrl + R`: Redo last undone action
|
|
98
|
+
- `n`: Move task to next week
|
|
99
|
+
- `p`: Move task to previous week
|
|
100
|
+
- `q`: Quit application
|
|
101
|
+
|
|
102
|
+
### Text Editing (when in edit mode)
|
|
103
|
+
- `Esc + U`: Undo last change (vim-style)
|
|
104
|
+
- `Esc + R`: Redo last undone change
|
|
105
|
+
- `Option + Left` (macOS): Skip to previous word
|
|
106
|
+
- `Option + Right` (macOS): Skip to next word
|
|
107
|
+
- `Ctrl + A`: Move to start of line
|
|
108
|
+
- `Ctrl + E`: Move to end of line
|
|
109
|
+
- `Left/Right`: Move cursor
|
|
110
|
+
- `Home/End`: Move to start/end of line
|
|
111
|
+
- `Backspace/Delete`: Delete characters
|
|
112
|
+
- `Enter/Esc`: Save and exit edit mode
|
|
113
|
+
|
|
114
|
+
## Data Storage
|
|
115
|
+
|
|
116
|
+
Tasks are stored in `~/.lolTasks/weekly_tasks.json`. The application automatically creates this directory and file on first run.
|
|
117
|
+
|
|
118
|
+
## Requirements
|
|
119
|
+
|
|
120
|
+
- Python 3.6+
|
|
121
|
+
- A terminal that supports curses (most modern terminals)
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
### Setup Development Environment
|
|
126
|
+
```bash
|
|
127
|
+
git clone https://github.com/lstephensFederation/lolTasks.git
|
|
128
|
+
cd lolTasks
|
|
129
|
+
pip install -e .
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Running Tests
|
|
133
|
+
```bash
|
|
134
|
+
# No tests implemented yet
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Contributing
|
|
138
|
+
|
|
139
|
+
1. Fork the repository
|
|
140
|
+
2. Create a feature branch
|
|
141
|
+
3. Make your changes
|
|
142
|
+
4. Test thoroughly
|
|
143
|
+
5. Submit a pull request
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT License - see LICENSE file for details.
|
|
148
|
+
|
|
149
|
+
## Changelog
|
|
150
|
+
|
|
151
|
+
### Version 1.0.0
|
|
152
|
+
- Initial release
|
|
153
|
+
- Complete task management functionality
|
|
154
|
+
- Advanced text editing features
|
|
155
|
+
- Cross-platform compatibility
|
|
156
|
+
- Comprehensive documentation
|
|
157
|
+
|
|
158
|
+
## Support
|
|
159
|
+
|
|
160
|
+
For issues, questions, or contributions, please visit:
|
|
161
|
+
https://github.com/lstephensFederation/lolTasks
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
task.py,sha256=K8FdAgs1v2YU3DtIjrcvjrlaLLDEozQsXP4dmNcdeeI,21240
|
|
2
|
+
loltasks-1.0.0.dist-info/licenses/LICENSE,sha256=hk-nsgErF1RwyTNTC25zTkmNmgaqQuymLVeCLJnAPpI,1072
|
|
3
|
+
loltasks-1.0.0.dist-info/METADATA,sha256=7z8RkwIrIEzbtRMoSg_lLOmCIa8eqCAS8-OWfGYQiBg,4288
|
|
4
|
+
loltasks-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
5
|
+
loltasks-1.0.0.dist-info/entry_points.txt,sha256=HJsWggwn654FnEdH5YpZmU5PdpQUee-8hnORKptZpHU,70
|
|
6
|
+
loltasks-1.0.0.dist-info/top_level.txt,sha256=jZLIHweWDFNjofQk6I3Utkod1CUTeNU4c_pl6hqrJxs,5
|
|
7
|
+
loltasks-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Luke J. Stephens
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
task
|
task.py
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import curses
|
|
3
|
+
import datetime
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
|
|
9
|
+
DATA_DIR = os.path.expanduser('~/.lolTasks')
|
|
10
|
+
DATA_FILE = os.path.join(DATA_DIR, 'weekly_tasks.json')
|
|
11
|
+
|
|
12
|
+
STATES = ['TO-DO', 'PENDING', 'COMPLETED']
|
|
13
|
+
STATE_CYCLE_FORWARD = {s: STATES[(i + 1) % 3] for i, s in enumerate(STATES)}
|
|
14
|
+
STATE_CYCLE_BACKWARD = {s: STATES[(i - 1) % 3] for i, s in enumerate(STATES)}
|
|
15
|
+
|
|
16
|
+
# Display symbols (fixed visual width = 4 chars including spaces/brackets)
|
|
17
|
+
STATE_SYMBOLS = {
|
|
18
|
+
'TO-DO': '[ ] ',
|
|
19
|
+
'PENDING': '[~] ',
|
|
20
|
+
'COMPLETED': '[x] ',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# For consistent offset calculation during editing (always 4 chars)
|
|
24
|
+
PREFIX_WIDTH = 4
|
|
25
|
+
|
|
26
|
+
COLORS = {'TO-DO': 1, 'PENDING': 2, 'COMPLETED': 3}
|
|
27
|
+
|
|
28
|
+
def get_week_key(date):
|
|
29
|
+
y, w, _ = date.isocalendar()
|
|
30
|
+
return f"{y}-W{w:02d}"
|
|
31
|
+
|
|
32
|
+
def week_to_date(year, week):
|
|
33
|
+
d = datetime.date(year, 1, 4)
|
|
34
|
+
d -= timedelta(days=d.isocalendar()[2] - 1)
|
|
35
|
+
return d + timedelta(weeks=week - 1)
|
|
36
|
+
|
|
37
|
+
def load_data():
|
|
38
|
+
if os.path.exists(DATA_FILE):
|
|
39
|
+
with open(DATA_FILE, 'r') as f:
|
|
40
|
+
return json.load(f)
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
def save_data(data):
|
|
44
|
+
os.makedirs(DATA_DIR, exist_ok=True)
|
|
45
|
+
with open(DATA_FILE, 'w') as f:
|
|
46
|
+
json.dump(data, f, indent=4)
|
|
47
|
+
|
|
48
|
+
def get_input(stdscr, base_y, base_x, initial='', start_at_beginning=False):
|
|
49
|
+
"""Safer line editor with cursor movement + vim-style start support + undo/redo + word navigation"""
|
|
50
|
+
curses.curs_set(1)
|
|
51
|
+
stdscr.keypad(True)
|
|
52
|
+
s = list(initial)
|
|
53
|
+
pos = 0 if start_at_beginning else len(s)
|
|
54
|
+
|
|
55
|
+
# Undo/redo history
|
|
56
|
+
history = [''.join(s)]
|
|
57
|
+
history_pos = 0
|
|
58
|
+
|
|
59
|
+
_, max_x = stdscr.getmaxyx()
|
|
60
|
+
display_width = max_x - base_x - 2 # margin
|
|
61
|
+
|
|
62
|
+
def save_state():
|
|
63
|
+
nonlocal history, history_pos
|
|
64
|
+
current = ''.join(s)
|
|
65
|
+
# Remove any history after current position
|
|
66
|
+
history = history[:history_pos + 1]
|
|
67
|
+
history.append(current)
|
|
68
|
+
history_pos = len(history) - 1
|
|
69
|
+
# Limit history to 50 entries
|
|
70
|
+
if len(history) > 50:
|
|
71
|
+
history.pop(0)
|
|
72
|
+
history_pos -= 1
|
|
73
|
+
|
|
74
|
+
def undo():
|
|
75
|
+
nonlocal s, pos, history_pos
|
|
76
|
+
if history_pos > 0:
|
|
77
|
+
history_pos -= 1
|
|
78
|
+
s = list(history[history_pos])
|
|
79
|
+
pos = min(pos, len(s))
|
|
80
|
+
|
|
81
|
+
def redo():
|
|
82
|
+
nonlocal s, pos, history_pos
|
|
83
|
+
if history_pos < len(history) - 1:
|
|
84
|
+
history_pos += 1
|
|
85
|
+
s = list(history[history_pos])
|
|
86
|
+
pos = min(pos, len(s))
|
|
87
|
+
|
|
88
|
+
def skip_word_left():
|
|
89
|
+
nonlocal pos
|
|
90
|
+
# Skip whitespace
|
|
91
|
+
while pos > 0 and s[pos - 1].isspace():
|
|
92
|
+
pos -= 1
|
|
93
|
+
# Skip word
|
|
94
|
+
while pos > 0 and not s[pos - 1].isspace():
|
|
95
|
+
pos -= 1
|
|
96
|
+
|
|
97
|
+
def skip_word_right():
|
|
98
|
+
nonlocal pos
|
|
99
|
+
# Skip word
|
|
100
|
+
while pos < len(s) and not s[pos].isspace():
|
|
101
|
+
pos += 1
|
|
102
|
+
# Skip whitespace
|
|
103
|
+
while pos < len(s) and s[pos].isspace():
|
|
104
|
+
pos += 1
|
|
105
|
+
|
|
106
|
+
while True:
|
|
107
|
+
start = max(0, pos - display_width + 5)
|
|
108
|
+
visible = s[start:start + display_width]
|
|
109
|
+
visible_str = ''.join(visible)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
stdscr.move(base_y, base_x)
|
|
113
|
+
stdscr.clrtoeol()
|
|
114
|
+
stdscr.addstr(base_y, base_x, visible_str)
|
|
115
|
+
except curses.error:
|
|
116
|
+
pass # Skip if can't draw
|
|
117
|
+
try:
|
|
118
|
+
cursor_x = base_x + (pos - start)
|
|
119
|
+
stdscr.move(base_y, cursor_x)
|
|
120
|
+
except curses.error:
|
|
121
|
+
pass
|
|
122
|
+
stdscr.refresh()
|
|
123
|
+
|
|
124
|
+
key = stdscr.getkey()
|
|
125
|
+
|
|
126
|
+
if key == '\n':
|
|
127
|
+
break
|
|
128
|
+
elif key == '\x01': # Ctrl+A - jump to start of line
|
|
129
|
+
pos = 0
|
|
130
|
+
elif key == '\x05': # Ctrl+E - jump to end of line
|
|
131
|
+
pos = len(s)
|
|
132
|
+
elif key == '\x1b': # Escape key or start of escape sequence
|
|
133
|
+
# Check for escape sequences
|
|
134
|
+
stdscr.nodelay(True)
|
|
135
|
+
try:
|
|
136
|
+
next_key = stdscr.getkey()
|
|
137
|
+
if next_key == 'u': # Esc + U = undo (vim-style)
|
|
138
|
+
undo()
|
|
139
|
+
elif next_key == 'r': # Esc + R = redo (vim-style)
|
|
140
|
+
redo()
|
|
141
|
+
elif next_key == 'b': # Option + Left on macOS (\x1bb)
|
|
142
|
+
skip_word_left()
|
|
143
|
+
elif next_key == 'f': # Option + Right on macOS (\x1bf)
|
|
144
|
+
skip_word_right()
|
|
145
|
+
elif next_key == '[': # CSI sequences
|
|
146
|
+
seq = stdscr.getkey()
|
|
147
|
+
if seq == 'D': # Left arrow
|
|
148
|
+
pos = max(0, pos - 1)
|
|
149
|
+
elif seq == 'C': # Right arrow
|
|
150
|
+
pos = min(len(s), pos + 1)
|
|
151
|
+
elif seq == '1': # \x1b[1~ (Home) or \x1b[1;9D (Option+Left)
|
|
152
|
+
next_char = stdscr.getkey()
|
|
153
|
+
if next_char == '~': # \x1b[1~ - Home
|
|
154
|
+
pos = 0
|
|
155
|
+
elif next_char == ';': # \x1b[1;9D - Option+Left
|
|
156
|
+
modifier = stdscr.getkey()
|
|
157
|
+
direction = stdscr.getkey()
|
|
158
|
+
if modifier == '9' and direction == 'D':
|
|
159
|
+
skip_word_left()
|
|
160
|
+
elif seq == '4': # \x1b[4~ (End)
|
|
161
|
+
next_char = stdscr.getkey()
|
|
162
|
+
if next_char == '~':
|
|
163
|
+
pos = len(s)
|
|
164
|
+
elif seq == '7': # \x1b[7~ (Home on some terminals)
|
|
165
|
+
next_char = stdscr.getkey()
|
|
166
|
+
if next_char == '~':
|
|
167
|
+
pos = 0
|
|
168
|
+
elif seq == '8': # \x1b[8~ (End on some terminals)
|
|
169
|
+
next_char = stdscr.getkey()
|
|
170
|
+
if next_char == '~':
|
|
171
|
+
pos = len(s)
|
|
172
|
+
else:
|
|
173
|
+
# Unknown CSI sequence
|
|
174
|
+
pass
|
|
175
|
+
else:
|
|
176
|
+
# Single escape - exit edit mode
|
|
177
|
+
break
|
|
178
|
+
except curses.error:
|
|
179
|
+
# Timeout or no more keys - treat as single escape
|
|
180
|
+
break
|
|
181
|
+
finally:
|
|
182
|
+
stdscr.nodelay(False)
|
|
183
|
+
elif key in ('KEY_LEFT', 'KEY_BACKSPACE', '\x7f', '\b'):
|
|
184
|
+
save_state()
|
|
185
|
+
if pos > 0:
|
|
186
|
+
pos -= 1
|
|
187
|
+
if key in ('\x7f', '\b'):
|
|
188
|
+
del s[pos]
|
|
189
|
+
elif key == 'KEY_RIGHT':
|
|
190
|
+
if pos < len(s):
|
|
191
|
+
pos += 1
|
|
192
|
+
elif key == 'KEY_HOME':
|
|
193
|
+
pos = 0
|
|
194
|
+
elif key == 'KEY_END':
|
|
195
|
+
pos = len(s)
|
|
196
|
+
elif key == 'KEY_DC':
|
|
197
|
+
save_state()
|
|
198
|
+
if pos < len(s):
|
|
199
|
+
del s[pos]
|
|
200
|
+
elif len(key) == 1 and 32 <= ord(key) <= 126:
|
|
201
|
+
save_state()
|
|
202
|
+
s.insert(pos, key)
|
|
203
|
+
pos += 1
|
|
204
|
+
|
|
205
|
+
curses.curs_set(0)
|
|
206
|
+
return ''.join(s)
|
|
207
|
+
|
|
208
|
+
def show_help():
|
|
209
|
+
print("Weekly Tasks App - task")
|
|
210
|
+
print("Keys:")
|
|
211
|
+
print(" ↑↓/kj Move selection / Reorder (when in reorder mode)")
|
|
212
|
+
print(" r Toggle reorder mode")
|
|
213
|
+
print(" ←→/hl Prev/Next week")
|
|
214
|
+
print(" Tab Cycle state forward")
|
|
215
|
+
print(" Shift+Tab Cycle state backward")
|
|
216
|
+
print(" I Edit current item at start of line (vim-style I)")
|
|
217
|
+
print(" a Add new task after selected")
|
|
218
|
+
print(" Enter Edit selected item (cursor at end)")
|
|
219
|
+
print(" d Delete selected task")
|
|
220
|
+
print(" n / p Shift task next / prev week")
|
|
221
|
+
print(" Ctrl+U Undo last action")
|
|
222
|
+
print(" Ctrl+R Redo last undone action")
|
|
223
|
+
print(" q Quit")
|
|
224
|
+
print()
|
|
225
|
+
print("In edit mode:")
|
|
226
|
+
print(" Esc Exit edit mode")
|
|
227
|
+
print(" Esc+u Undo (vim-style)")
|
|
228
|
+
print(" Esc+r Redo (vim-style)")
|
|
229
|
+
print(" Option+←→ Word navigation")
|
|
230
|
+
print(" Ctrl+A/E Line navigation (start/end)")
|
|
231
|
+
print(" Arrow keys Cursor movement")
|
|
232
|
+
sys.exit(0)
|
|
233
|
+
|
|
234
|
+
def main(stdscr):
|
|
235
|
+
if len(sys.argv) > 1 and sys.argv[1] in ('--help', '-h'):
|
|
236
|
+
show_help()
|
|
237
|
+
|
|
238
|
+
curses.curs_set(0)
|
|
239
|
+
stdscr.keypad(True)
|
|
240
|
+
curses.start_color()
|
|
241
|
+
curses.init_pair(1, curses.COLOR_RED, curses.COLOR_BLACK)
|
|
242
|
+
curses.init_pair(2, curses.COLOR_BLUE, curses.COLOR_BLACK)
|
|
243
|
+
curses.init_pair(3, curses.COLOR_GREEN, curses.COLOR_BLACK)
|
|
244
|
+
|
|
245
|
+
today = datetime.date.today()
|
|
246
|
+
current_week = get_week_key(today)
|
|
247
|
+
|
|
248
|
+
active_week = current_week
|
|
249
|
+
selected = -1
|
|
250
|
+
edit_mode = False
|
|
251
|
+
reorder_mode = False
|
|
252
|
+
scroll_offset = 0
|
|
253
|
+
force_start = False
|
|
254
|
+
|
|
255
|
+
# Global undo/redo history
|
|
256
|
+
undo_history = []
|
|
257
|
+
undo_pos = -1
|
|
258
|
+
MAX_UNDO = 20
|
|
259
|
+
|
|
260
|
+
# Save initial state
|
|
261
|
+
initial_data = load_data()
|
|
262
|
+
undo_history.append(json.dumps(initial_data))
|
|
263
|
+
undo_pos = 0
|
|
264
|
+
|
|
265
|
+
def save_undo_state():
|
|
266
|
+
nonlocal undo_history, undo_pos
|
|
267
|
+
# Save current in-memory data, not from disk
|
|
268
|
+
current_data = data.copy()
|
|
269
|
+
# Remove any history after current position
|
|
270
|
+
undo_history = undo_history[:undo_pos + 1]
|
|
271
|
+
undo_history.append(json.dumps(current_data))
|
|
272
|
+
undo_pos = len(undo_history) - 1
|
|
273
|
+
# Limit history
|
|
274
|
+
if len(undo_history) > MAX_UNDO:
|
|
275
|
+
undo_history.pop(0)
|
|
276
|
+
undo_pos -= 1
|
|
277
|
+
|
|
278
|
+
def undo():
|
|
279
|
+
nonlocal undo_history, undo_pos, selected, active, prev, nxt, data
|
|
280
|
+
if undo_pos > 0:
|
|
281
|
+
undo_pos -= 1
|
|
282
|
+
restored_data = json.loads(undo_history[undo_pos])
|
|
283
|
+
save_data(restored_data)
|
|
284
|
+
# Reload data immediately
|
|
285
|
+
data = load_data()
|
|
286
|
+
active = data[active_week]
|
|
287
|
+
nxt = data[next_week]
|
|
288
|
+
prev = data[prev_week]
|
|
289
|
+
# Adjust selected index
|
|
290
|
+
selected = max(-1, min(selected, len(active['tasks']) - 1))
|
|
291
|
+
return True
|
|
292
|
+
return False
|
|
293
|
+
|
|
294
|
+
def redo():
|
|
295
|
+
nonlocal undo_history, undo_pos, selected, active, prev, nxt, data
|
|
296
|
+
if undo_pos < len(undo_history) - 1:
|
|
297
|
+
undo_pos += 1
|
|
298
|
+
restored_data = json.loads(undo_history[undo_pos])
|
|
299
|
+
save_data(restored_data)
|
|
300
|
+
# Reload data immediately
|
|
301
|
+
data = load_data()
|
|
302
|
+
active = data[active_week]
|
|
303
|
+
nxt = data[next_week]
|
|
304
|
+
prev = data[prev_week]
|
|
305
|
+
# Adjust selected index
|
|
306
|
+
selected = max(-1, min(selected, len(active['tasks']) - 1))
|
|
307
|
+
return True
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
while True:
|
|
311
|
+
data = load_data()
|
|
312
|
+
|
|
313
|
+
if active_week not in data:
|
|
314
|
+
data[active_week] = {'title': 'Editable Title here for the week', 'tasks': []}
|
|
315
|
+
save_data(data)
|
|
316
|
+
|
|
317
|
+
stdscr.clear()
|
|
318
|
+
maxy, maxx = stdscr.getmaxyx()
|
|
319
|
+
|
|
320
|
+
y, w_part = active_week.split('-W')
|
|
321
|
+
y = int(y)
|
|
322
|
+
w = int(w_part)
|
|
323
|
+
active_date = week_to_date(y, w)
|
|
324
|
+
prev_date = active_date - timedelta(weeks=1)
|
|
325
|
+
next_date = active_date + timedelta(weeks=1)
|
|
326
|
+
prev_week = get_week_key(prev_date)
|
|
327
|
+
next_week = get_week_key(next_date)
|
|
328
|
+
|
|
329
|
+
for wk in [prev_week, active_week, next_week]:
|
|
330
|
+
if wk not in data:
|
|
331
|
+
data[wk] = {'title': 'Week title', 'tasks': []}
|
|
332
|
+
save_data(data)
|
|
333
|
+
|
|
334
|
+
prev = data[prev_week]
|
|
335
|
+
active = data[active_week]
|
|
336
|
+
nxt = data[next_week]
|
|
337
|
+
|
|
338
|
+
selected = max(-1, min(selected, len(active['tasks']) - 1))
|
|
339
|
+
|
|
340
|
+
# Layout positions - ensure active title is always visible
|
|
341
|
+
title_y = 0
|
|
342
|
+
prev_start_y = 2
|
|
343
|
+
prev_end_y = 6 # Previous week: title at 0, sep at 1, tasks at 2-5 (max 4 tasks)
|
|
344
|
+
active_title_y = prev_end_y + 1 # Title at line 7
|
|
345
|
+
active_start_y = active_title_y + 2 # Tasks start at line 9
|
|
346
|
+
next_start_y = maxy - 12 if maxy > 35 else active_start_y + 12
|
|
347
|
+
|
|
348
|
+
# Adjust scroll_offset to make selected visible
|
|
349
|
+
if selected >= 0:
|
|
350
|
+
visible_rows = next_start_y - active_start_y - 1
|
|
351
|
+
if selected < scroll_offset:
|
|
352
|
+
scroll_offset = selected
|
|
353
|
+
elif selected > scroll_offset + visible_rows - 1:
|
|
354
|
+
scroll_offset = selected - (visible_rows - 1)
|
|
355
|
+
scroll_offset = max(0, scroll_offset)
|
|
356
|
+
|
|
357
|
+
# Titles & separators
|
|
358
|
+
def draw_title(y, week_key, text, attr=curses.A_NORMAL):
|
|
359
|
+
if y >= maxy: return # Skip if beyond screen
|
|
360
|
+
label = f"{week_key} – {text}"
|
|
361
|
+
if len(label) > maxx - 6:
|
|
362
|
+
label = label[:maxx-9] + "..."
|
|
363
|
+
stdscr.addstr(y, 2, label, attr)
|
|
364
|
+
|
|
365
|
+
draw_title(title_y, prev_week, prev['title'], curses.A_DIM)
|
|
366
|
+
if title_y + 1 < maxy:
|
|
367
|
+
stdscr.addstr(title_y + 1, 0, "─" * (maxx - 2), curses.A_DIM)
|
|
368
|
+
|
|
369
|
+
draw_title(active_title_y, active_week, active['title'], curses.A_BOLD)
|
|
370
|
+
if active_title_y + 1 < maxy:
|
|
371
|
+
stdscr.addstr(active_title_y + 1, 0, "═" * (maxx - 2), curses.A_BOLD)
|
|
372
|
+
|
|
373
|
+
draw_title(next_start_y - 2, next_week, nxt['title'], curses.A_DIM)
|
|
374
|
+
if next_start_y - 1 < maxy:
|
|
375
|
+
stdscr.addstr(next_start_y - 1, 0, "─" * (maxx - 2), curses.A_DIM)
|
|
376
|
+
|
|
377
|
+
# Improved tasks drawing with word-wrap for selected
|
|
378
|
+
def draw_week_tasks(base_y, week_data, is_active, sel_idx=-1, max_tasks=8, scroll_offset=0, max_y=None):
|
|
379
|
+
if max_y is None:
|
|
380
|
+
max_y = maxy
|
|
381
|
+
max_y = min(max_y, maxy - 2) # Leave room for help bar at maxy-1
|
|
382
|
+
tasks = week_data['tasks']
|
|
383
|
+
y = base_y
|
|
384
|
+
wrap_width = maxx - 16 # margin for prefix + indent
|
|
385
|
+
|
|
386
|
+
start_idx = scroll_offset if is_active else 0
|
|
387
|
+
end_idx = len(tasks)
|
|
388
|
+
if max_tasks is not None:
|
|
389
|
+
end_idx = min(end_idx, start_idx + max_tasks)
|
|
390
|
+
|
|
391
|
+
idx = start_idx
|
|
392
|
+
while idx < end_idx and y < max_y:
|
|
393
|
+
t = tasks[idx]
|
|
394
|
+
prefix = STATE_SYMBOLS[t['state']] # always 4 chars
|
|
395
|
+
text = t['text']
|
|
396
|
+
attr = curses.color_pair(COLORS.get(t['state'], 1))
|
|
397
|
+
if is_active and idx == sel_idx:
|
|
398
|
+
attr |= curses.A_REVERSE
|
|
399
|
+
if not is_active:
|
|
400
|
+
attr |= curses.A_DIM
|
|
401
|
+
|
|
402
|
+
full = prefix + text
|
|
403
|
+
if is_active and idx == sel_idx and len(full) > wrap_width:
|
|
404
|
+
lines = []
|
|
405
|
+
rem = full
|
|
406
|
+
while rem:
|
|
407
|
+
if len(rem) <= wrap_width:
|
|
408
|
+
lines.append(rem)
|
|
409
|
+
break
|
|
410
|
+
split = rem.rfind(' ', 0, wrap_width)
|
|
411
|
+
if split == -1:
|
|
412
|
+
split = wrap_width
|
|
413
|
+
lines.append(rem[:split])
|
|
414
|
+
rem = rem[split:].lstrip()
|
|
415
|
+
if rem:
|
|
416
|
+
rem = ' ' * PREFIX_WIDTH + rem
|
|
417
|
+
for line in lines:
|
|
418
|
+
if y >= max_y: break
|
|
419
|
+
try:
|
|
420
|
+
stdscr.addstr(y, 2, line, attr)
|
|
421
|
+
except curses.error:
|
|
422
|
+
pass
|
|
423
|
+
y += 1
|
|
424
|
+
else:
|
|
425
|
+
if len(full) > wrap_width + 5:
|
|
426
|
+
full = full[:wrap_width - 3] + "..."
|
|
427
|
+
if y < max_y:
|
|
428
|
+
try:
|
|
429
|
+
stdscr.addstr(y, 2, full, attr)
|
|
430
|
+
except curses.error:
|
|
431
|
+
pass
|
|
432
|
+
y += 1
|
|
433
|
+
|
|
434
|
+
idx += 1
|
|
435
|
+
|
|
436
|
+
if idx < len(tasks) and y < max_y:
|
|
437
|
+
stdscr.addstr(y, 2, "... more", curses.A_DIM)
|
|
438
|
+
|
|
439
|
+
draw_week_tasks(prev_start_y, prev, False, max_tasks=4, max_y=active_title_y - 1)
|
|
440
|
+
draw_week_tasks(active_start_y, active, True, selected, max_tasks=None, scroll_offset=scroll_offset, max_y=next_start_y - 2)
|
|
441
|
+
draw_week_tasks(next_start_y, nxt, False, max_tasks=8, max_y=maxy - 1)
|
|
442
|
+
|
|
443
|
+
# Help bar
|
|
444
|
+
mode = " [REORDER]" if reorder_mode else ""
|
|
445
|
+
hint = " (after selected)" if 0 <= selected < len(active['tasks']) else " (at end)"
|
|
446
|
+
help_txt = f"↑↓/kj:Move{'/Reorder'+mode} | r:Reorder | ←→:Week | Tab/S-Tab:State | I:Edit@start | a:Add{hint} | ⏎:Edit | d:Del | n/p:Shift | Ctrl+U:Undo | Ctrl+R:Redo | q:Quit"
|
|
447
|
+
if maxy - 1 < maxy:
|
|
448
|
+
stdscr.addstr(maxy - 1, 0, help_txt[:maxx - 1], curses.A_DIM)
|
|
449
|
+
|
|
450
|
+
stdscr.refresh()
|
|
451
|
+
|
|
452
|
+
if edit_mode:
|
|
453
|
+
if selected == -1:
|
|
454
|
+
offset = len(f"{active_week} – ")
|
|
455
|
+
new_title = get_input(stdscr, active_title_y, 2 + offset,
|
|
456
|
+
active['title'], start_at_beginning=force_start)
|
|
457
|
+
active['title'] = new_title
|
|
458
|
+
else:
|
|
459
|
+
t = active['tasks'][selected]
|
|
460
|
+
offset = PREFIX_WIDTH # ← Fixed!
|
|
461
|
+
edit_y = active_start_y + (selected - scroll_offset)
|
|
462
|
+
new_text = get_input(stdscr, edit_y, 2 + offset,
|
|
463
|
+
t['text'], start_at_beginning=force_start)
|
|
464
|
+
t['text'] = new_text
|
|
465
|
+
save_data(data)
|
|
466
|
+
edit_mode = False
|
|
467
|
+
force_start = False
|
|
468
|
+
continue
|
|
469
|
+
|
|
470
|
+
key = stdscr.getkey()
|
|
471
|
+
force_start = False
|
|
472
|
+
|
|
473
|
+
# Handle control key sequences
|
|
474
|
+
if key == '\x15': # Ctrl+U = undo
|
|
475
|
+
if undo():
|
|
476
|
+
continue
|
|
477
|
+
elif key == '\x12': # Ctrl+R = redo
|
|
478
|
+
if redo():
|
|
479
|
+
continue
|
|
480
|
+
|
|
481
|
+
# Normal mode commands
|
|
482
|
+
if key.lower() == 'q':
|
|
483
|
+
save_data(data)
|
|
484
|
+
break
|
|
485
|
+
elif key.lower() == 'r':
|
|
486
|
+
reorder_mode = not reorder_mode
|
|
487
|
+
elif key == '\t' and 0 <= selected < len(active['tasks']):
|
|
488
|
+
active['tasks'][selected]['state'] = STATE_CYCLE_FORWARD[active['tasks'][selected]['state']]
|
|
489
|
+
save_data(data)
|
|
490
|
+
save_undo_state()
|
|
491
|
+
elif key == '\x1b[Z' and 0 <= selected < len(active['tasks']):
|
|
492
|
+
active['tasks'][selected]['state'] = STATE_CYCLE_BACKWARD[active['tasks'][selected]['state']]
|
|
493
|
+
save_data(data)
|
|
494
|
+
save_undo_state()
|
|
495
|
+
elif key == 'I': # Shift+I - edit at start
|
|
496
|
+
if selected == -1 or 0 <= selected < len(active['tasks']):
|
|
497
|
+
edit_mode = True
|
|
498
|
+
force_start = True
|
|
499
|
+
elif key in ('KEY_UP', 'k'):
|
|
500
|
+
tasks = active['tasks']
|
|
501
|
+
if reorder_mode:
|
|
502
|
+
if selected > 0:
|
|
503
|
+
tasks[selected-1], tasks[selected] = tasks[selected], tasks[selected-1]
|
|
504
|
+
selected -= 1
|
|
505
|
+
save_data(data)
|
|
506
|
+
save_undo_state()
|
|
507
|
+
else:
|
|
508
|
+
if selected > 0:
|
|
509
|
+
selected -= 1
|
|
510
|
+
elif selected == -1 and tasks:
|
|
511
|
+
selected = len(tasks) - 1
|
|
512
|
+
elif key in ('KEY_DOWN', 'j'):
|
|
513
|
+
tasks = active['tasks']
|
|
514
|
+
if reorder_mode:
|
|
515
|
+
if selected < len(tasks) - 1:
|
|
516
|
+
tasks[selected+1], tasks[selected] = tasks[selected], tasks[selected+1]
|
|
517
|
+
selected += 1
|
|
518
|
+
save_data(data)
|
|
519
|
+
save_undo_state()
|
|
520
|
+
else:
|
|
521
|
+
if selected == -1 and tasks:
|
|
522
|
+
selected = 0
|
|
523
|
+
elif selected < len(tasks) - 1:
|
|
524
|
+
selected += 1
|
|
525
|
+
elif selected == len(tasks) - 1:
|
|
526
|
+
selected = -1
|
|
527
|
+
elif key in ('KEY_LEFT', 'h'):
|
|
528
|
+
active_week = prev_week
|
|
529
|
+
selected = -1
|
|
530
|
+
scroll_offset = 0
|
|
531
|
+
elif key in ('KEY_RIGHT', 'l'):
|
|
532
|
+
active_week = next_week
|
|
533
|
+
selected = -1
|
|
534
|
+
scroll_offset = 0
|
|
535
|
+
elif key.lower() == 'a':
|
|
536
|
+
tasks = active['tasks']
|
|
537
|
+
new_task = {'text': 'New task', 'state': 'TO-DO'}
|
|
538
|
+
if selected == -1 or selected >= len(tasks):
|
|
539
|
+
tasks.append(new_task)
|
|
540
|
+
selected = len(tasks) - 1
|
|
541
|
+
else:
|
|
542
|
+
pos = selected + 1
|
|
543
|
+
tasks.insert(pos, new_task)
|
|
544
|
+
selected = pos
|
|
545
|
+
save_data(data)
|
|
546
|
+
save_undo_state()
|
|
547
|
+
edit_mode = True
|
|
548
|
+
force_start = False
|
|
549
|
+
elif key == '\n' and selected is not None:
|
|
550
|
+
edit_mode = True
|
|
551
|
+
force_start = False
|
|
552
|
+
elif key.lower() == 'd' and 0 <= selected < len(active['tasks']):
|
|
553
|
+
del active['tasks'][selected]
|
|
554
|
+
selected = max(-1, selected - 1)
|
|
555
|
+
save_data(data)
|
|
556
|
+
save_undo_state()
|
|
557
|
+
elif key.lower() in ('n', 'p') and 0 <= selected < len(active['tasks']):
|
|
558
|
+
tasks = active['tasks']
|
|
559
|
+
task = tasks.pop(selected)
|
|
560
|
+
delta = 1 if key.lower() == 'n' else -1
|
|
561
|
+
target_date = active_date + timedelta(weeks=delta)
|
|
562
|
+
target_week = get_week_key(target_date)
|
|
563
|
+
if target_week not in data:
|
|
564
|
+
data[target_week] = {'title': 'Week title', 'tasks': []}
|
|
565
|
+
data[target_week]['tasks'].append(task)
|
|
566
|
+
selected = max(-1, min(selected, len(tasks) - 1))
|
|
567
|
+
save_data(data)
|
|
568
|
+
save_undo_state()
|
|
569
|
+
|
|
570
|
+
if __name__ == '__main__':
|
|
571
|
+
curses.wrapper(main)
|
|
572
|
+
|
|
573
|
+
def entry_point():
|
|
574
|
+
"""Entry point for console script"""
|
|
575
|
+
curses.wrapper(main)
|