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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ lolTasks = task:entry_point
3
+ task = task:entry_point
@@ -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)