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.
@@ -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
- run: doppler run -- sh -c 'UV_PUBLISH_TOKEN="$PYPI_API" uv publish'
32
- env:
33
- DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }}
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.0
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "td-task"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Fast TUI and CLI manager for tasks and Markdown notes"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -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 range(scroll, end):
600
- prefix = f"{'▸' if line_index == row else ' '} {line_index + 1:>{number_width}} │ "
601
- line = lines[line_index]
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
- output.append(_markdown_preview_line(line))
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 - (end - scroll)):
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
- if row < scroll:
647
- scroll = row
648
- elif row >= scroll + visible_rows:
649
- scroll = row - visible_rows + 1
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
@@ -142,7 +142,7 @@ wheels = [
142
142
 
143
143
  [[package]]
144
144
  name = "td-task"
145
- version = "0.2.0"
145
+ version = "0.2.1"
146
146
  source = { editable = "." }
147
147
  dependencies = [
148
148
  { name = "cryptography" },
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