td-task 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {td_task-0.2.0 → td_task-0.2.1}/.github/workflows/publish.yml +9 -6
- {td_task-0.2.0 → td_task-0.2.1}/PKG-INFO +12 -1
- {td_task-0.2.0 → td_task-0.2.1}/README.md +11 -0
- {td_task-0.2.0 → td_task-0.2.1}/pyproject.toml +1 -1
- {td_task-0.2.0 → td_task-0.2.1}/src/td/CHANGELOG.md +4 -0
- {td_task-0.2.0 → td_task-0.2.1}/src/td/tui.py +89 -20
- {td_task-0.2.0 → td_task-0.2.1}/tests/test_tui_notes.py +48 -0
- {td_task-0.2.0 → td_task-0.2.1}/uv.lock +1 -1
- {td_task-0.2.0 → td_task-0.2.1}/.env.enc +0 -0
- {td_task-0.2.0 → td_task-0.2.1}/.gitignore +0 -0
- {td_task-0.2.0 → td_task-0.2.1}/.python-version +0 -0
- {td_task-0.2.0 → td_task-0.2.1}/assets/demo.gif +0 -0
- {td_task-0.2.0 → td_task-0.2.1}/demo.tape +0 -0
- {td_task-0.2.0 → td_task-0.2.1}/src/td/__init__.py +0 -0
- {td_task-0.2.0 → td_task-0.2.1}/src/td/__main__.py +0 -0
- {td_task-0.2.0 → td_task-0.2.1}/src/td/db.py +0 -0
- {td_task-0.2.0 → td_task-0.2.1}/src/td/terminal.py +0 -0
- {td_task-0.2.0 → td_task-0.2.1}/tests/test_cli_notes.py +0 -0
- {td_task-0.2.0 → td_task-0.2.1}/tests/test_notes.py +0 -0
|
@@ -8,7 +8,13 @@ on:
|
|
|
8
8
|
|
|
9
9
|
jobs:
|
|
10
10
|
deploy:
|
|
11
|
+
name: Publish distribution to PyPI
|
|
11
12
|
runs-on: ubuntu-latest
|
|
13
|
+
environment:
|
|
14
|
+
name: pypi
|
|
15
|
+
url: https://pypi.org/project/td-task/
|
|
16
|
+
permissions:
|
|
17
|
+
id-token: write
|
|
12
18
|
steps:
|
|
13
19
|
- name: Checkout code
|
|
14
20
|
uses: actions/checkout@v4
|
|
@@ -16,9 +22,6 @@ jobs:
|
|
|
16
22
|
- name: Install uv
|
|
17
23
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
|
18
24
|
|
|
19
|
-
- name: Install Doppler CLI
|
|
20
|
-
uses: dopplerhq/cli-action@v3
|
|
21
|
-
|
|
22
25
|
- name: Set up Python
|
|
23
26
|
uses: actions/setup-python@v5
|
|
24
27
|
with:
|
|
@@ -28,6 +31,6 @@ jobs:
|
|
|
28
31
|
run: uv build
|
|
29
32
|
|
|
30
33
|
- name: Publish to PyPI
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
|
|
35
|
+
with:
|
|
36
|
+
skip-existing: true
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: td-task
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Fast TUI and CLI manager for tasks and Markdown notes
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: cryptography>=42.0.0
|
|
@@ -28,6 +28,17 @@ Or with pip:
|
|
|
28
28
|
pip install td-task
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
## Local publishing environment
|
|
32
|
+
|
|
33
|
+
PyPI credentials live in the ignored `.env` file. The encrypted backup is
|
|
34
|
+
`.env.enc`.
|
|
35
|
+
|
|
36
|
+
```nu
|
|
37
|
+
^age --decrypt .env.enc | save --force .env
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Never print decrypted secrets or commit `.env`.
|
|
41
|
+
|
|
31
42
|
Run directly without installing:
|
|
32
43
|
|
|
33
44
|
```bash
|
|
@@ -17,6 +17,17 @@ Or with pip:
|
|
|
17
17
|
pip install td-task
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
## Local publishing environment
|
|
21
|
+
|
|
22
|
+
PyPI credentials live in the ignored `.env` file. The encrypted backup is
|
|
23
|
+
`.env.enc`.
|
|
24
|
+
|
|
25
|
+
```nu
|
|
26
|
+
^age --decrypt .env.enc | save --force .env
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Never print decrypted secrets or commit `.env`.
|
|
30
|
+
|
|
20
31
|
Run directly without installing:
|
|
21
32
|
|
|
22
33
|
```bash
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.1] - 2026-08-04
|
|
4
|
+
- Soft-wrap long note lines to terminal width without changing stored Markdown.
|
|
5
|
+
- Keep Markdown preview styling across wrapped rows and scroll with the active cursor.
|
|
6
|
+
|
|
3
7
|
## [0.2.0] - 2026-08-03
|
|
4
8
|
- Add task-backed Markdown notes with a full-screen editor and live line preview.
|
|
5
9
|
- Add `A` to create notes, `e`/`Enter` to edit note bodies, and `E` to rename notes.
|
|
@@ -573,6 +573,78 @@ def _bullet_line_parts(source: str) -> tuple[str, str, str] | None:
|
|
|
573
573
|
return match.group(1), match.group(2), match.group(3)
|
|
574
574
|
|
|
575
575
|
|
|
576
|
+
def _wrap_editor_text(source: Text, width: int) -> list[Text]:
|
|
577
|
+
"""Soft-wrap styled text without changing or dropping source characters."""
|
|
578
|
+
from rich.cells import cell_len
|
|
579
|
+
|
|
580
|
+
width = max(1, width)
|
|
581
|
+
plain = source.plain
|
|
582
|
+
if not plain:
|
|
583
|
+
return [Text()]
|
|
584
|
+
|
|
585
|
+
offsets: list[tuple[int, int]] = []
|
|
586
|
+
start = 0
|
|
587
|
+
while start < len(plain):
|
|
588
|
+
end = start
|
|
589
|
+
used = 0
|
|
590
|
+
last_break: int | None = None
|
|
591
|
+
has_content = False
|
|
592
|
+
|
|
593
|
+
while end < len(plain):
|
|
594
|
+
char_width = cell_len(plain[end])
|
|
595
|
+
if end > start and used + char_width > width:
|
|
596
|
+
break
|
|
597
|
+
used += char_width
|
|
598
|
+
end += 1
|
|
599
|
+
if plain[end - 1].isspace() and has_content:
|
|
600
|
+
last_break = end
|
|
601
|
+
elif not plain[end - 1].isspace():
|
|
602
|
+
has_content = True
|
|
603
|
+
if used >= width:
|
|
604
|
+
break
|
|
605
|
+
|
|
606
|
+
if end < len(plain) and last_break is not None and last_break > start:
|
|
607
|
+
end = last_break
|
|
608
|
+
if end == start:
|
|
609
|
+
end += 1
|
|
610
|
+
|
|
611
|
+
offsets.append((start, end))
|
|
612
|
+
start = end
|
|
613
|
+
|
|
614
|
+
return [source[start:end] for start, end in offsets]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _note_editor_visual_rows(
|
|
618
|
+
lines: list[str],
|
|
619
|
+
active_row: int,
|
|
620
|
+
column: int,
|
|
621
|
+
content_width: int,
|
|
622
|
+
) -> tuple[list[tuple[int, int, Text]], int]:
|
|
623
|
+
"""Build wrapped display rows and return the active cursor row index."""
|
|
624
|
+
visual_rows: list[tuple[int, int, Text]] = []
|
|
625
|
+
cursor_visual_row = 0
|
|
626
|
+
|
|
627
|
+
for line_index, line in enumerate(lines):
|
|
628
|
+
if line_index == active_row:
|
|
629
|
+
rendered = Text(line, style="cyan bold")
|
|
630
|
+
if column < len(line):
|
|
631
|
+
rendered.stylize("reverse cyan bold", column, column + 1)
|
|
632
|
+
else:
|
|
633
|
+
rendered.append(" ", style="reverse cyan bold")
|
|
634
|
+
wrapped = _wrap_editor_text(rendered, content_width)
|
|
635
|
+
else:
|
|
636
|
+
wrapped = _wrap_editor_text(_markdown_preview_line(line), content_width)
|
|
637
|
+
|
|
638
|
+
for segment_index, segment in enumerate(wrapped):
|
|
639
|
+
visual_rows.append((line_index, segment_index, segment))
|
|
640
|
+
if line_index == active_row and any(
|
|
641
|
+
"reverse" in str(span.style) for span in segment.spans
|
|
642
|
+
):
|
|
643
|
+
cursor_visual_row = len(visual_rows) - 1
|
|
644
|
+
|
|
645
|
+
return visual_rows, cursor_visual_row
|
|
646
|
+
|
|
647
|
+
|
|
576
648
|
def _render_note_editor(
|
|
577
649
|
title: str,
|
|
578
650
|
lines: list[str],
|
|
@@ -592,28 +664,21 @@ def _render_note_editor(
|
|
|
592
664
|
console.print(Text("─" * width, style="dim"), end="\033[K\n")
|
|
593
665
|
|
|
594
666
|
visible_rows = max(3, height - 6)
|
|
595
|
-
end = min(len(lines), scroll + visible_rows)
|
|
596
667
|
number_width = max(2, len(str(len(lines))))
|
|
597
668
|
content_width = max(10, width - number_width - 5)
|
|
669
|
+
visual_rows, _ = _note_editor_visual_rows(lines, row, column, content_width)
|
|
670
|
+
visible = visual_rows[scroll:scroll + visible_rows]
|
|
598
671
|
|
|
599
|
-
for line_index in
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
output = Text(prefix, style="cyan bold" if line_index == row else "dim")
|
|
603
|
-
if line_index == row:
|
|
604
|
-
offset = max(0, column - content_width + 1)
|
|
605
|
-
visible = line[offset:offset + content_width]
|
|
606
|
-
visible_column = column - offset
|
|
607
|
-
output.append(visible[:visible_column], style="cyan bold")
|
|
608
|
-
char_under = visible[visible_column] if visible_column < len(visible) else " "
|
|
609
|
-
output.append(char_under, style="reverse cyan bold")
|
|
610
|
-
if visible_column < len(visible):
|
|
611
|
-
output.append(visible[visible_column + 1:], style="cyan bold")
|
|
672
|
+
for line_index, segment_index, segment in visible:
|
|
673
|
+
if segment_index == 0:
|
|
674
|
+
prefix = f"{'▸' if line_index == row else ' '} {line_index + 1:>{number_width}} │ "
|
|
612
675
|
else:
|
|
613
|
-
|
|
676
|
+
prefix = f" {'':>{number_width}} │ "
|
|
677
|
+
output = Text(prefix, style="cyan bold" if line_index == row else "dim")
|
|
678
|
+
output.append(segment)
|
|
614
679
|
console.print(output, end="\033[K\n", overflow="crop", no_wrap=True)
|
|
615
680
|
|
|
616
|
-
for _ in range(visible_rows - (
|
|
681
|
+
for _ in range(visible_rows - len(visible)):
|
|
617
682
|
console.print(end="\033[K\n")
|
|
618
683
|
|
|
619
684
|
footer = " Esc:save & close │ Ctrl+S:save │ Enter:new line │ arrows:move"
|
|
@@ -643,10 +708,14 @@ def _run_note_editor(note_id: int) -> None:
|
|
|
643
708
|
|
|
644
709
|
while True:
|
|
645
710
|
visible_rows = max(3, (console.height or 24) - 6)
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
711
|
+
width = max(30, console.width or 80)
|
|
712
|
+
number_width = max(2, len(str(len(lines))))
|
|
713
|
+
content_width = max(10, width - number_width - 5)
|
|
714
|
+
_, cursor_visual_row = _note_editor_visual_rows(lines, row, column, content_width)
|
|
715
|
+
if cursor_visual_row < scroll:
|
|
716
|
+
scroll = cursor_visual_row
|
|
717
|
+
elif cursor_visual_row >= scroll + visible_rows:
|
|
718
|
+
scroll = cursor_visual_row - visible_rows + 1
|
|
650
719
|
_render_note_editor(note["title"], lines, row, column, scroll, status_msg)
|
|
651
720
|
status_msg = ""
|
|
652
721
|
key = term.read_key()
|
|
@@ -6,7 +6,9 @@ import unittest
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from unittest.mock import patch
|
|
8
8
|
|
|
9
|
+
from rich.cells import cell_len
|
|
9
10
|
from rich.console import Console
|
|
11
|
+
from rich.text import Text
|
|
10
12
|
|
|
11
13
|
from td import db, terminal, tui
|
|
12
14
|
|
|
@@ -100,6 +102,52 @@ class NoteTuiTests(unittest.TestCase):
|
|
|
100
102
|
self.assertIn("bold", str(inline.spans[0].style))
|
|
101
103
|
self.assertIn("italic", str(inline.spans[1].style))
|
|
102
104
|
|
|
105
|
+
def test_note_editor_wrap_preserves_every_character(self) -> None:
|
|
106
|
+
source = Text("alpha beta gamma-supercalifragilistic", style="bold")
|
|
107
|
+
wrapped = tui._wrap_editor_text(source, 8)
|
|
108
|
+
|
|
109
|
+
self.assertEqual("".join(part.plain for part in wrapped), source.plain)
|
|
110
|
+
self.assertTrue(all(cell_len(part.plain) <= 8 for part in wrapped))
|
|
111
|
+
self.assertTrue(all("bold" in str(part.style) for part in wrapped))
|
|
112
|
+
|
|
113
|
+
def test_note_editor_cursor_follows_wrapped_line(self) -> None:
|
|
114
|
+
line = "one two three four five six"
|
|
115
|
+
visual_rows, cursor_row = tui._note_editor_visual_rows(
|
|
116
|
+
[line], 0, len(line), 7
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
self.assertGreater(len(visual_rows), 1)
|
|
120
|
+
self.assertEqual(visual_rows[cursor_row][0], 0)
|
|
121
|
+
self.assertTrue(any(
|
|
122
|
+
"reverse" in str(span.style)
|
|
123
|
+
for span in visual_rows[cursor_row][2].spans
|
|
124
|
+
))
|
|
125
|
+
|
|
126
|
+
def test_note_editor_scroll_tracks_wrapped_cursor(self) -> None:
|
|
127
|
+
note = db.add_note("Wrapping")
|
|
128
|
+
assert note is not None
|
|
129
|
+
db.update_note_content(note["id"], "word " * 40)
|
|
130
|
+
scroll_positions: list[int] = []
|
|
131
|
+
test_console = Console(file=io.StringIO(), width=30, height=10, force_terminal=False)
|
|
132
|
+
keys = iter([terminal.KEY_END, terminal.KEY_ESC])
|
|
133
|
+
|
|
134
|
+
with (
|
|
135
|
+
patch.object(tui, "console", test_console),
|
|
136
|
+
patch.object(terminal, "read_key", side_effect=lambda: next(keys)),
|
|
137
|
+
patch.object(terminal, "clear_screen"),
|
|
138
|
+
patch.object(
|
|
139
|
+
tui,
|
|
140
|
+
"_render_note_editor",
|
|
141
|
+
side_effect=lambda _title, _lines, _row, _column, scroll, _status: (
|
|
142
|
+
scroll_positions.append(scroll)
|
|
143
|
+
),
|
|
144
|
+
),
|
|
145
|
+
):
|
|
146
|
+
tui._run_note_editor(note["id"])
|
|
147
|
+
|
|
148
|
+
self.assertEqual(scroll_positions[0], 0)
|
|
149
|
+
self.assertGreater(scroll_positions[-1], 0)
|
|
150
|
+
|
|
103
151
|
def test_alt_arrows_move_editor_lines(self) -> None:
|
|
104
152
|
note = db.add_note("Movement")
|
|
105
153
|
assert note is not None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|