1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
"""Task editor page for the pgt TUI."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Literal
from pygit2 import Oid
from pygittools.tasks import BOARD_REF_PREFIX, EMPTY_TREE_OID_HEX, TASK_REF_PREFIX, Task, get_board, get_task
from pygittools.tasks_query import BOARD_STATUS_ORDER, get_task_view
from pygittools.tui.draw import draw_line
from pygittools.tui.git_colors import current_theme, focus_attr
from pygittools.tui.types import PageAction, PageContext, PageResult
from pygittools.tui.ucurses import ( # type: ignore[import-untyped]
KEY_BACKSPACE,
KEY_DOWN,
KEY_ENTER,
KEY_RESIZE,
KEY_UP,
)
_FieldName = Literal["title", "description", "status", "priority", "assignee", "due_date"]
_FIELDS: tuple[_FieldName, ...] = ("title", "description", "status", "priority", "assignee", "due_date")
_PRIORITY_ORDER: tuple[str, ...] = ("LOW", "MEDIUM", "HIGH", "CRITICAL")
@dataclass(frozen=True, slots=True)
class _EditorState:
title: str
description: str
status: str | None
priority: str | None
assignee: str
due_date: str
class TaskEditorPage:
title = "task"
def __init__(self, *, board_name: str, task_ref: str | None, default_status: str | None) -> None:
self._ctx: PageContext | None = None
self._board_name = board_name
self._task_ref = task_ref
self._default_status = default_status
self._state = _EditorState(
title="",
description="",
status=default_status,
priority=None,
assignee="",
due_date="",
)
self._field_index = 0
self._input_mode: Literal["nav", "input"] = "input"
self._error: str | None = None
def on_enter(self, ctx: PageContext) -> None:
self._ctx = ctx
if self._task_ref is None:
return
view = get_task_view(ctx.repo, self._task_ref)
self._state = _EditorState(
title=view.get("title", ""),
description=view.get("description", ""),
status=view.get("status"),
priority=view.get("priority"),
assignee=view.get("assignee") or "",
due_date=view.get("due_date") or "",
)
def status_text(self) -> str:
if self._error:
return self._error
return "Enter save — j/k move field — type to edit — Esc or q back"
def draw(self, stdscr: int, height: int, width: int) -> None:
del stdscr
if height <= 0:
return
theme = current_theme()
header = f"Task in board {self._board_name}"
draw_line(0, 0, header[:width], width, theme.header)
if height <= 1:
return
rows = [
f"Title: {self._state.title}",
f"Description: {self._state.description}",
f"Status: {self._state.status or '(none)'}",
f"Priority: {self._state.priority or '(none)'}",
f"Assignee: {self._state.assignee}",
f"Due date (ISO): {self._state.due_date}",
]
for index, text in enumerate(rows):
if index + 1 >= height:
break
attr = theme.normal
if index == self._field_index:
attr = focus_attr(attr)
draw_line(index + 1, 0, text[:width], width, attr)
def handle_key(self, key: int) -> PageResult:
if key in (ord("q"), ord("Q")):
return PageResult(action=PageAction.POP)
if key == 27:
if self._input_mode == "input":
self._input_mode = "nav"
return PageResult()
return PageResult(action=PageAction.POP)
if key == KEY_RESIZE:
return PageResult()
if self._error is not None:
self._error = None
field = _FIELDS[self._field_index]
# Input mode: edit text fields, cycle status/priority; j/k/h/l are text in text fields.
if self._input_mode == "input":
if field in ("title", "description", "assignee", "due_date"):
if key in (KEY_UP, KEY_DOWN):
# Move between fields with arrows even while in input mode.
if key == KEY_UP:
self._field_index = max(0, self._field_index - 1)
else:
self._field_index = min(len(_FIELDS) - 1, self._field_index + 1)
return PageResult()
if key in (KEY_ENTER, 10, 13):
return self._save()
return self._edit_text_field(field, key)
if field == "status":
self._cycle_status(key)
return PageResult()
if field == "priority":
self._cycle_priority(key)
return PageResult()
return PageResult()
# Nav mode: j/k and arrows move between fields; Enter saves.
if key in (KEY_UP, ord("k"), ord("K")):
self._field_index = max(0, self._field_index - 1)
return PageResult()
if key in (KEY_DOWN, ord("j"), ord("J")):
self._field_index = min(len(_FIELDS) - 1, self._field_index + 1)
return PageResult()
if key in (KEY_ENTER, 10, 13):
return self._save()
return PageResult()
def _edit_text_field(self, field: _FieldName, key: int) -> PageResult:
if key in (KEY_BACKSPACE, 127, 8):
self._state = _replace_state_field(self._state, field, _text_for_field(self._state, field)[:-1])
return PageResult()
if 32 <= key <= 126:
text = _text_for_field(self._state, field) + chr(key)
self._state = _replace_state_field(self._state, field, text)
return PageResult()
return PageResult()
def _cycle_status(self, key: int) -> None:
if key not in (KEY_UP, KEY_DOWN, ord("j"), ord("J"), ord("k"), ord("K")):
return
current = self._state.status or self._default_status or BOARD_STATUS_ORDER[0]
try:
index = BOARD_STATUS_ORDER.index(current)
except ValueError:
index = 0
if key in (KEY_UP, ord("k"), ord("K")):
index = (index - 1) % len(BOARD_STATUS_ORDER)
else:
index = (index + 1) % len(BOARD_STATUS_ORDER)
self._state = _EditorState(
title=self._state.title,
description=self._state.description,
status=BOARD_STATUS_ORDER[index],
priority=self._state.priority,
assignee=self._state.assignee,
due_date=self._state.due_date,
)
def _cycle_priority(self, key: int) -> None:
if key not in (KEY_UP, KEY_DOWN, ord("j"), ord("J"), ord("k"), ord("K")):
return
if not _PRIORITY_ORDER:
return
current = self._state.priority or _PRIORITY_ORDER[0]
try:
index = _PRIORITY_ORDER.index(current)
except ValueError:
index = 0
if key in (KEY_UP, ord("k"), ord("K")):
index = (index - 1) % len(_PRIORITY_ORDER)
else:
index = (index + 1) % len(_PRIORITY_ORDER)
self._state = _EditorState(
title=self._state.title,
description=self._state.description,
status=self._state.status,
priority=_PRIORITY_ORDER[index],
assignee=self._state.assignee,
due_date=self._state.due_date,
)
def _save(self) -> PageResult:
ctx = self._ctx
if ctx is None:
return PageResult(action=PageAction.POP)
try:
due_dt = _parse_due_date(self._state.due_date)
except ValueError as exc:
self._error = str(exc)
return PageResult()
if self._task_ref is None:
try:
_create_task(ctx, self._board_name, self._state, due_dt)
except Exception as exc: # noqa: BLE001
self._error = str(exc)
return PageResult()
return PageResult(action=PageAction.POP)
task = get_task(ctx.repo, self._task_ref)
if task is None:
self._error = f"Task not found: {self._task_ref}"
return PageResult()
task.title = self._state.title
task.description = self._state.description
task.status = Task.Status(self._state.status) if self._state.status else None
task.priority = Task.Priority(self._state.priority) if self._state.priority else None
task.assignee = self._state.assignee or None
task.due_date = due_dt
task.update_message()
task.write(ctx.repo)
return PageResult(action=PageAction.POP)
def _replace_state_field(state: _EditorState, field: _FieldName, value: str) -> _EditorState:
if field == "title":
return _EditorState(
title=value,
description=state.description,
status=state.status,
priority=state.priority,
assignee=state.assignee,
due_date=state.due_date,
)
if field == "description":
return _EditorState(
title=state.title,
description=value,
status=state.status,
priority=state.priority,
assignee=state.assignee,
due_date=state.due_date,
)
if field == "assignee":
return _EditorState(
title=state.title,
description=state.description,
status=state.status,
priority=state.priority,
assignee=value,
due_date=state.due_date,
)
return _EditorState(
title=state.title,
description=state.description,
status=state.status,
priority=state.priority,
assignee=state.assignee,
due_date=value,
)
def _text_for_field(state: _EditorState, field: _FieldName) -> str:
if field == "title":
return state.title
if field == "description":
return state.description
if field == "assignee":
return state.assignee
return state.due_date
def _parse_due_date(text: str) -> datetime | None:
if not text.strip():
return None
try:
return datetime.fromisoformat(text)
except ValueError as exc:
raise ValueError(f"Invalid due date (expected ISO format): {text}") from exc
def _create_task(ctx: PageContext, board_name: str, state: _EditorState, due_dt: datetime | None) -> None:
board_ref = f"{BOARD_REF_PREFIX}{board_name}"
board = get_board(ctx.repo, board_ref)
if board is None:
raise ValueError(f"Board not found: {board_name}")
title = state.title or "Untitled"
slug = title.lower().replace(" ", "_") or "task"
full_ref = f"{TASK_REF_PREFIX}{slug}"
target = Oid(hex=EMPTY_TREE_OID_HEX)
sig = ctx.repo.default_signature
tagger = f"{sig.name} <{sig.email}>"
task = Task(
target,
full_ref,
tagger,
title=title,
description=state.description,
status=Task.Status(state.status) if state.status else None,
priority=Task.Priority(state.priority) if state.priority else None,
assignee=state.assignee or None,
due_date=due_dt,
)
task_oid = task.write(ctx.repo)
board.tasks = [*(getattr(board, "tasks", []) or []), str(task_oid)]
board.update_message()
board.write(ctx.repo)