-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrender.py
More file actions
407 lines (326 loc) · 13.6 KB
/
Copy pathrender.py
File metadata and controls
407 lines (326 loc) · 13.6 KB
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
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
"""Presentation layer for the TaskManager CLI.
Pure formatting: terminal width, ANSI color, glyphs, section layout and the
little status lines the dispatcher prints. Knows nothing about persistence or
argument parsing -- it is handed plain data and returns strings.
"""
import os
import re
import sys
import shutil
import colorsys
import textwrap
from tasks import (SHORTTERM, LONGTERM, WAIT, BACKLOG, NAME, LIST, MODES,
list_display_name)
RESET = '\033[0m'
BOLD = '\033[1m'
DIM = '\033[2m'
_FG = {
'green': '\033[32m', 'blue': '\033[34m', 'yellow': '\033[33m',
'magenta': '\033[35m', 'cyan': '\033[36m', 'red': '\033[31m',
}
# Background codes for status pills (40-47), keyed by the same names as _FG.
_BG = {
'green': 42, 'blue': 44, 'yellow': 43,
'magenta': 45, 'cyan': 46, 'red': 41,
}
# (label, color, glyph) per category. Active sections use a filled glyph;
# backlog is "parked" so it gets a hollow one.
STYLE = {
SHORTTERM: ('Short-Term', 'green', '●'), # ●
LONGTERM: ('Long-Term', 'blue', '●'), # ●
WAIT: ('Wait/Watch', 'yellow', '●'), # ●
BACKLOG: ('Backlog', 'magenta', '○'), # ○
}
ORDER = MODES # [short, long, wait, backlog]
# Tri-state color switch: None = auto-detect, else forced on/off via configure().
_COLOR = None
def configure(no_color=False):
"""Force color on/off for the process (used by --no-color)."""
global _COLOR
_COLOR = not no_color and _auto_color()
def _auto_color():
return sys.stdout.isatty() and os.environ.get('NO_COLOR') is None
def _use_color():
return _auto_color() if _COLOR is None else _COLOR
def _paint(s, *codes):
if not _use_color() or not codes:
return s
return ''.join(codes) + s + RESET
def bold(s):
return _paint(s, BOLD)
def dim(s):
return _paint(s, DIM)
def color(s, name):
return _paint(s, _FG.get(name, ''))
def term_width():
cols = shutil.get_terminal_size((80, 24)).columns
return max(40, min(cols, 100))
# --- color depth ---------------------------------------------------------
# Borders and pills use 24-bit color when the terminal advertises it
# (COLORTERM), a 256-color rainbow ramp as the fallback, and finally the plain
# 8-color set. This is what lets the border be a smooth gradient.
def _truecolor():
return os.environ.get('COLORTERM', '').lower() in ('truecolor', '24bit')
# A hue wheel approximated in the xterm-256 palette, so rainbow borders still
# read as a rainbow on terminals without 24-bit color.
_RAMP_256 = [196, 202, 208, 214, 220, 190, 154, 46, 48, 51, 39, 33,
21, 57, 93, 129, 165, 201, 199, 197]
def _hue(h):
"""ANSI foreground escape for hue `h` in [0, 1) (truecolor or 256-color)."""
h %= 1.0
if _truecolor():
r, g, b = colorsys.hsv_to_rgb(h, 0.75, 1.0)
return '\033[38;2;%d;%d;%dm' % (int(r * 255), int(g * 255), int(b * 255))
return '\033[38;5;%dm' % _RAMP_256[int(h * len(_RAMP_256)) % len(_RAMP_256)]
# --- box framing ---------------------------------------------------------
# The default views are wrapped in a single-line box. The border can be a
# rainbow gradient (default), the active category's color, or plain dim --
# set TASKS_BORDER. All padding/truncation is ANSI-aware: color codes carry no
# visible width, so they are excluded from layout math, otherwise a colored
# status pill would push the right border out.
_ANSI_RE = re.compile('\033\\[[0-9;]*m')
# top-left, top-right, bottom-left, bottom-right, horizontal, vertical
_BOX = ('┌', '┐', '└', '┘', '─', '│')
# Border style: 'rainbow' | 'category' | 'dim' (env override, plain when off).
_BORDER = os.environ.get('TASKS_BORDER', 'rainbow').lower()
def _visible_len(s):
"""Length of a string as drawn, ignoring ANSI escape sequences."""
return len(_ANSI_RE.sub('', s))
def _truncate(s, width):
"""Cut a (possibly colored) string to `width` visible cols with an ellipsis,
preserving escape sequences and always resetting at the end."""
out, vis, i = [], 0, 0
while i < len(s):
if s[i] == '\033': # copy the whole escape, no width cost
j = s.find('m', i)
j = len(s) - 1 if j == -1 else j
out.append(s[i:j + 1])
i = j + 1
continue
if vis >= width - 1:
out.append('…')
break
out.append(s[i])
vis += 1
i += 1
if _use_color():
out.append(RESET)
return ''.join(out)
def _pad(s, width):
"""Pad with spaces (or truncate) so the string occupies exactly `width`."""
vis = _visible_len(s)
if vis > width:
return _truncate(s, width)
return s + ' ' * (width - vis)
def _rule_paint(width, base_col):
"""Return f(col) -> ANSI prefix that colors a border char at column `col`.
Rainbow varies the hue across the width; category is a flat color; dim is
the old subdued look; color-off yields no prefix."""
if not _use_color():
return lambda col: ''
if _BORDER == 'rainbow':
return lambda col: _hue(col / max(1, width - 1))
if _BORDER == 'category':
code = _FG.get(base_col, '')
return lambda col: code
return lambda col: DIM
def _side_paint(row, rows, base_col):
"""ANSI prefix for the two vertical bars on a given content row. Rainbow
runs the hue top-to-bottom so the frame is a continuous gradient."""
if not _use_color():
return ''
if _BORDER == 'rainbow':
return _hue(row / max(1, rows - 1))
if _BORDER == 'category':
return _FG.get(base_col, '')
return DIM
def _compose(segments, width, paint):
"""Build a border row from ('rule', plain) and ('text', styled) segments,
coloring rule characters by absolute column so the gradient flows unbroken
behind the embedded title text."""
out, col = [], 0
for kind, val in segments:
if kind == 'text':
out.append(val)
col += _visible_len(val)
else:
for ch in val:
out.append(paint(col) + ch)
col += 1
if _use_color():
out.append(RESET)
return ''.join(out)
def _box(title_left, title_right, lines, width, base_col='cyan'):
"""Frame `lines` in a single box with a colored border (see _BORDER). The
title row carries a left label (e.g. the list name) and an optional right
label (e.g. a summary), with a rule stretched between them."""
tl, tr, bl, br, h, v = _BOX
inner = width - 4 # '│ ' + content + ' │'
paint = _rule_paint(width, base_col)
if title_right:
fill = max(1, width - 8 - _visible_len(title_left)
- _visible_len(title_right))
top = [('rule', tl + h + ' '), ('text', title_left),
('rule', ' ' + h * fill + ' '), ('text', title_right),
('rule', ' ' + h + tr)]
else:
fill = max(1, width - 5 - _visible_len(title_left))
top = [('rule', tl + h + ' '), ('text', title_left),
('rule', ' ' + h * fill + tr)]
rows = [_compose(top, width, paint)]
n = len(lines)
for i, ln in enumerate(lines):
c = _side_paint(i, n, base_col)
bar = (c + v + RESET) if c else v
rows.append('%s %s %s' % (bar, _pad(ln, inner), bar))
rows.append(_compose([('rule', bl + h * (width - 2) + br)], width, paint))
return '\n'.join(rows)
def _section_header(label, col, glyph, count, active, inner):
"""A section title line: colored glyph + label on the left, count right."""
head_label = bold(label) if active else label
left = '%s %s' % (color(glyph, col), head_label)
tag = dim('(%d)' % count)
gap = max(1, inner - _visible_len(left) - _visible_len(tag))
return left + ' ' * gap + tag
def _plural(n):
return '' if n == 1 else 's'
def _status_color(status):
"""Pick a color for a free-form status by keyword (best-effort)."""
s = status.lower()
if any(w in s for w in ('done', 'complete', 'finish', 'merged', 'shipped')):
return 'green'
if any(w in s for w in ('block', 'stuck', 'broken', 'fail')):
return 'red'
if any(w in s for w in ('progress', 'wip', 'doing', 'active', 'started')):
return 'yellow'
if any(w in s for w in ('wait', 'hold', 'pending', 'review', 'blocked on')):
return 'cyan'
return 'magenta'
def _status_tag(status):
"""A status rendered as a filled pill (black text on a colored field) when
color is on, falling back to bracketed text otherwise."""
if not _use_color():
return '[%s]' % status
bg = _BG.get(_status_color(status), 47)
return '\033[30;%dm %s %s' % (bg, status, RESET)
def _render_task_lines(items, width):
"""Lines for a numbered task list: title (wrapped), status tag, notes."""
out = []
iw = len(str(len(items) - 1))
indent = 2 + iw + 2 # ' ' + idx + ' '
wrap_at = max(10, width - indent)
for i, task in enumerate(items):
idx = dim(str(i).rjust(iw))
wrapped = textwrap.wrap(task.title, wrap_at) or ['']
head = ' %s %s' % (idx, wrapped[0])
if task.status:
head += ' ' + _status_tag(task.status)
out.append(head)
for cont in wrapped[1:]:
out.append(' ' * indent + cont)
for note in task.notes:
note_lines = textwrap.wrap(note, max(10, wrap_at - 2)) or ['']
for j, nl in enumerate(note_lines):
bullet = '· ' if j == 0 else ' '
out.append(' ' * indent + dim(bullet + nl))
return out
def render_list(lists, active_list, mode, show_all=False):
"""Render one or more category sections of the active list.
lists -- the TaskManager.lists dict {mode: {NAME, LIST}}
active_list -- active list display name (e.g. 'Today')
mode -- the category operations currently target (highlighted)
show_all -- force every category, even empty ones
"""
width = term_width()
inner = width - 4
name = list_display_name(active_list)
total = sum(len(lists[m][LIST]) for m in ORDER)
summary = dim('%s · %d task%s' % (STYLE[mode][0].lower(),
total, _plural(total)))
shown = ORDER if show_all else [m for m in ORDER if lists[m][LIST]]
if not shown: # nothing anywhere -> show active section
shown = [mode]
body = ['']
for m in shown:
label, col, glyph = STYLE[m]
items = lists[m][LIST]
body.append(_section_header(label, col, glyph, len(items),
m == mode, inner))
if not items:
body.append(' ' + dim('(no tasks)'))
else:
body += _render_task_lines(items, inner)
body.append('')
body = body[:-1] # drop the trailing blank before border
return _box(bold(name), summary, body, width, base_col=STYLE[mode][1])
def render_category(lists, active_list, mode):
"""Interactive view: the active category in full, others summarized."""
width = term_width()
inner = width - 4
name = list_display_name(active_list)
label, col, glyph = STYLE[mode]
items = lists[mode][LIST]
body = ['']
if not items:
body.append(' ' + dim('(no tasks)'))
else:
body += _render_task_lines(items, inner)
others = []
for m in ORDER:
if m == mode:
continue
others.append('%s %s' % (STYLE[m][0].lower(),
dim('(%d)' % len(lists[m][LIST]))))
body += ['', dim('other: ' + ' '.join(others))]
return _box(bold(name), color('%s %s' % (glyph, label), col), body, width,
base_col=col)
def breadcrumb(active_list, mode, index=None):
"""A 'List › category › task N' trail for interactive headers."""
label, col, _ = STYLE[mode]
parts = [bold(list_display_name(active_list)), color(label.lower(), col)]
if index is not None:
parts.append(dim('task %d' % index))
return dim(' › ').join(parts)
def render_task_detail(task, index, mode):
"""Full detail of a single task, used by interactive mode."""
label, col, glyph = STYLE[mode]
out = ['%s %s' % (color('▸', col), bold('[%d] %s' % (index, task.title)))]
if task.status:
out.append(' status: ' + _status_tag(task.status))
else:
out.append(' status: ' + dim('(none)'))
out.append(' category: ' + color('%s %s' % (glyph, label.lower()), col))
if task.notes:
out.append(' notes:')
for i, note in enumerate(task.notes):
out.append(' %s %s' % (dim(str(i)), note))
else:
out.append(' notes: ' + dim('(none)'))
return '\n'.join(out)
def render_lists_index(names, active=None):
"""Render the list of available lists (the `ls` view)."""
width = term_width()
body = ['']
for n in sorted(names):
label = list_display_name(n)
marker = color('●', 'green') if label == active else ' '
body.append(' %s %s' % (marker, label))
if len(names) == 0:
body.append(' ' + dim('(none yet)'))
return _box(bold('Lists'), '', body, width)
def render_today(entries):
"""Render the 'finished today' view from devlog task strings."""
width = term_width()
body = ['']
if not entries:
body.append(' ' + dim('(nothing logged yet)'))
return _box(bold('Finished today'), '', body, width)
for e in entries:
body.append(' %s %s' % (color('✓', 'green'), e))
return _box(bold('Finished today'), '', body, width)
def success(msg):
return '%s %s' % (color('✓', 'green'), msg)
def info(msg):
return dim('· %s' % msg)
def warn(msg):
return '%s %s' % (color('!', 'yellow'), msg)