termrender 0.7.1__tar.gz → 0.7.3__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.
Files changed (45) hide show
  1. {termrender-0.7.1 → termrender-0.7.3}/CHANGELOG.md +30 -0
  2. {termrender-0.7.1 → termrender-0.7.3}/PKG-INFO +1 -1
  3. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/__main__.py +13 -49
  4. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/layout.py +5 -2
  5. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/parser.py +97 -10
  6. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/renderers/code.py +15 -3
  7. {termrender-0.7.1 → termrender-0.7.3}/.github/workflows/publish.yml +0 -0
  8. {termrender-0.7.1 → termrender-0.7.3}/.gitignore +0 -0
  9. {termrender-0.7.1 → termrender-0.7.3}/CLAUDE.md +0 -0
  10. {termrender-0.7.1 → termrender-0.7.3}/LICENSE +0 -0
  11. {termrender-0.7.1 → termrender-0.7.3}/README.md +0 -0
  12. {termrender-0.7.1 → termrender-0.7.3}/design.json +0 -0
  13. {termrender-0.7.1 → termrender-0.7.3}/pyproject.toml +0 -0
  14. {termrender-0.7.1 → termrender-0.7.3}/requirements.json +0 -0
  15. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/CLAUDE.md +0 -0
  16. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/__init__.py +0 -0
  17. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/blocks.py +0 -0
  18. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/emit.py +0 -0
  19. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/py.typed +0 -0
  20. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/renderers/CLAUDE.md +0 -0
  21. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/renderers/__init__.py +0 -0
  22. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/renderers/borders.py +0 -0
  23. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/renderers/charts.py +0 -0
  24. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/renderers/columns.py +0 -0
  25. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/renderers/diff.py +0 -0
  26. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/renderers/divider.py +0 -0
  27. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/renderers/mermaid.py +0 -0
  28. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/renderers/panel.py +0 -0
  29. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/renderers/quote.py +0 -0
  30. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/renderers/stat.py +0 -0
  31. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/renderers/table.py +0 -0
  32. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/renderers/text.py +0 -0
  33. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/renderers/timeline.py +0 -0
  34. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/renderers/tree.py +0 -0
  35. {termrender-0.7.1 → termrender-0.7.3}/src/termrender/style.py +0 -0
  36. {termrender-0.7.1 → termrender-0.7.3}/tests/__init__.py +0 -0
  37. {termrender-0.7.1 → termrender-0.7.3}/tests/test_charts.py +0 -0
  38. {termrender-0.7.1 → termrender-0.7.3}/tests/test_column_alignment.py +0 -0
  39. {termrender-0.7.1 → termrender-0.7.3}/tests/test_diff.py +0 -0
  40. {termrender-0.7.1 → termrender-0.7.3}/tests/test_inline_badge.py +0 -0
  41. {termrender-0.7.1 → termrender-0.7.3}/tests/test_myst_gaps.py +0 -0
  42. {termrender-0.7.1 → termrender-0.7.3}/tests/test_stat.py +0 -0
  43. {termrender-0.7.1 → termrender-0.7.3}/tests/test_tasklist.py +0 -0
  44. {termrender-0.7.1 → termrender-0.7.3}/tests/test_timeline.py +0 -0
  45. {termrender-0.7.1 → termrender-0.7.3}/tests/test_variable_colons.py +0 -0
@@ -1,6 +1,36 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.7.3 (2026-04-15)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **code**: Wrap long code lines to fit layout width
9
+ ([`31c6e59`](https://github.com/CaptainCrouton89/termrender/commit/31c6e595a438c4ced8c61fff679b59d4ae55f938))
10
+
11
+ Code blocks previously used raw line count for height and let render_box grow beyond the layout
12
+ allocation. Now wraps source lines to the available content width in both layout and renderer.
13
+
14
+ - **parser**: Add directive trace and file-absolute line numbers to error messages
15
+ ([`0f99ea0`](https://github.com/CaptainCrouton89/termrender/commit/0f99ea0310116f8fa06e933cd26126246d7a3b43))
16
+
17
+ Stray-closer and unclosed-directive errors now print the full open/close trace and, when nested
18
+ directives share a colon count, name the specific cause and suggest the fix. Recursive body
19
+ parsing reports file-absolute line numbers via _line_offset threading through parse →
20
+ _split_directives → _directive_to_block.
21
+
22
+
23
+ ## v0.7.2 (2026-04-09)
24
+
25
+ ### Bug Fixes
26
+
27
+ - **cli**: Default --tmux pane to 1/3 window width
28
+ ([`d9c1bcc`](https://github.com/CaptainCrouton89/termrender/commit/d9c1bccbe95a4e5cf1f975b82cbafde6d9d3807a))
29
+
30
+ Instead of preview-rendering at 80 cols to measure content width, default to (window_width - 2) // 3
31
+ for a consistent 1/3 split.
32
+
33
+
4
34
  ## v0.7.1 (2026-04-08)
5
35
 
6
36
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 0.7.1
3
+ Version: 0.7.3
4
4
  Summary: Rich terminal rendering of directive-flavored markdown
5
5
  Project-URL: Homepage, https://github.com/CaptainCrouton89/termrender
6
6
  Project-URL: Repository, https://github.com/CaptainCrouton89/termrender
@@ -298,17 +298,9 @@ def main() -> None:
298
298
  from termrender.parser import parse as _parse
299
299
  _parse(source)
300
300
  except DirectiveError as e:
301
- _error(
302
- f"syntax error: {e}",
303
- fix="check directive openers have matching ::: closers and attribute syntax is key=\"value\"",
304
- code=EXIT_SYNTAX,
305
- )
301
+ _error(f"syntax error: {e}", code=EXIT_SYNTAX)
306
302
  except ValueError as e:
307
- _error(
308
- f"nesting error: {e}",
309
- fix="reduce directive nesting depth (max 50 levels)",
310
- code=EXIT_SYNTAX,
311
- )
303
+ _error(f"nesting error: {e}", code=EXIT_SYNTAX)
312
304
 
313
305
  import shlex
314
306
  import subprocess
@@ -343,28 +335,17 @@ def main() -> None:
343
335
  if args.width:
344
336
  pane_width = args.width
345
337
  else:
346
- # Preview render to measure content width
347
- from termrender.style import visual_len
338
+ # Default: 1/3 of window width (minus separator)
348
339
  try:
349
- preview = render(source, width=80, color=False)
350
- max_w = max(
351
- (visual_len(line) for line in preview.split('\n') if line),
352
- default=40,
340
+ result = subprocess.run(
341
+ ["tmux", "display-message", "-p", "#{window_width}"],
342
+ capture_output=True, text=True, check=True,
353
343
  )
354
- pane_width = max(max_w, 40)
344
+ window_width = int(result.stdout.strip())
345
+ pane_width = (window_width - 2) // 3
355
346
  except Exception:
356
- pane_width = 80
347
+ pane_width = 60
357
348
 
358
- # Cap to available tmux space (leave room for the source pane)
359
- try:
360
- result = subprocess.run(
361
- ["tmux", "display-message", "-p", "#{pane_width}"],
362
- capture_output=True, text=True, check=True,
363
- )
364
- available = int(result.stdout.strip())
365
- pane_width = min(pane_width, available - 10)
366
- except Exception:
367
- pass
368
349
  pane_width = max(pane_width, 20) # absolute minimum
369
350
 
370
351
  # Watch mode points the new pane at the user's real file so edits
@@ -458,17 +439,9 @@ def main() -> None:
458
439
  from termrender.parser import parse
459
440
  parse(source)
460
441
  except DirectiveError as e:
461
- _error(
462
- f"syntax error: {e}",
463
- fix="check directive openers have matching ::: closers and attribute syntax is key=\"value\"",
464
- code=EXIT_SYNTAX,
465
- )
442
+ _error(f"syntax error: {e}", code=EXIT_SYNTAX)
466
443
  except ValueError as e:
467
- _error(
468
- f"nesting error: {e}",
469
- fix="reduce directive nesting depth (max 50 levels)",
470
- code=EXIT_SYNTAX,
471
- )
444
+ _error(f"nesting error: {e}", code=EXIT_SYNTAX)
472
445
  print("ok", file=sys.stderr)
473
446
  sys.exit(EXIT_OK)
474
447
 
@@ -485,18 +458,9 @@ def main() -> None:
485
458
  code=EXIT_TERMINAL,
486
459
  )
487
460
  except DirectiveError as e:
488
- _error(
489
- f"syntax error: {e}",
490
- fix="check directive openers have matching ::: closers and attribute syntax is key=\"value\"",
491
- hint="run: termrender --check <file> to validate before rendering",
492
- code=EXIT_SYNTAX,
493
- )
461
+ _error(f"syntax error: {e}", code=EXIT_SYNTAX)
494
462
  except ValueError as e:
495
- _error(
496
- f"nesting error: {e}",
497
- fix="reduce directive nesting depth (max 50 levels)",
498
- code=EXIT_SYNTAX,
499
- )
463
+ _error(f"nesting error: {e}", code=EXIT_SYNTAX)
500
464
 
501
465
  sys.stdout.write(output)
502
466
 
@@ -92,8 +92,11 @@ def resolve_height(block: Block) -> None:
92
92
 
93
93
  elif bt == BlockType.CODE:
94
94
  source = block.attrs.get("source") or _plain_text(block.text)
95
- code_lines = source.split("\n") if source else [""]
96
- block.height = len(code_lines) + 2
95
+ raw_lines = source.split("\n") if source else [""]
96
+ border_v = visual_len("│")
97
+ content_w = max(width - 2 * border_v - 2, 1)
98
+ total_lines = sum(len(wrap_text(line, content_w)) for line in raw_lines)
99
+ block.height = total_lines + 2
97
100
 
98
101
  elif bt == BlockType.COLUMNS:
99
102
  block.height = max((c.height or 0 for c in block.children), default=0)
@@ -405,21 +405,80 @@ def _parse_markdown(source: str, _depth: int = 0) -> list[Block]:
405
405
  return _convert_ast(ast_nodes, _depth=_depth)
406
406
 
407
407
 
408
- def _split_directives(source: str) -> list[dict]:
408
+ def _find_nested_directives(body_lines: list[str]) -> list[str]:
409
+ """Return names of directive openers found in body lines."""
410
+ names: list[str] = []
411
+ for line in body_lines:
412
+ m = _DIRECTIVE_OPEN.match(line)
413
+ if m:
414
+ names.append(m.group(2))
415
+ return names
416
+
417
+
418
+ def _stray_closer_message(
419
+ abs_line: int, close_colons: str, trace: list[str],
420
+ last_closed: dict | None,
421
+ ) -> str:
422
+ """Format a stray-closer error with trace and fix suggestion."""
423
+ msg = f"line {abs_line}: stray '{close_colons}' closer — no directive is open"
424
+ if trace:
425
+ msg += "\n\n directive trace:\n" + "\n".join(trace)
426
+
427
+ if last_closed:
428
+ nested = _find_nested_directives(last_closed["body_lines"])
429
+ if nested:
430
+ outer = last_closed["name"]
431
+ inner = nested[0]
432
+ fix_colons = ":" * (last_closed["colon_count"] + 1)
433
+ msg += (
434
+ f"\n\n Likely cause: :::{outer} at line {last_closed['open_line']} "
435
+ f"contains a nested :::{inner} with the same colon count.\n"
436
+ f" The inner ':::' closer (line {last_closed['close_line']}) "
437
+ f"matched the outer directive instead.\n"
438
+ f" Fix: use {fix_colons}{outer} for the outer directive"
439
+ )
440
+ return msg
441
+
442
+ msg += (
443
+ "\n\n Fix: remove this stray closer, or if nesting is intended,\n"
444
+ " use more colons on outer directives (::::outer wraps :::inner)"
445
+ )
446
+ return msg
447
+
448
+
449
+ def _unclosed_directive_message(
450
+ open_line: int, colons: str, name: str, trace: list[str],
451
+ body_lines: list[str],
452
+ ) -> str:
453
+ """Format an unclosed-directive error with trace and fix suggestion."""
454
+ msg = f"line {open_line}: unclosed '{colons}{name}' — add '{colons}' on a new line to close it"
455
+ if trace:
456
+ msg += "\n\n directive trace:\n" + "\n".join(trace)
457
+ return msg
458
+
459
+
460
+ def _split_directives(source: str, _line_offset: int = 0) -> list[dict]:
409
461
  """Split source into directive and markdown segments.
410
462
 
411
463
  Returns a list of segments, each being either:
412
464
  {"type": "markdown", "content": str}
413
- {"type": "directive", "name": str, "attrs": dict, "body": str}
465
+ {"type": "directive", "name": str, "attrs": dict, "body": str,
466
+ "body_start_offset": int}
467
+
468
+ On error, raises DirectiveError with a directive trace showing the
469
+ sequence of opens/closes that led to the error state.
414
470
  """
415
471
  lines = source.split("\n")
416
472
  segments: list[dict] = []
417
473
  current_md_lines: list[str] = []
418
474
  stack: list[dict] = [] # stack of open directives
475
+ trace: list[str] = [] # event log for error diagnostics
476
+ last_closed: dict | None = None # most recently closed directive entry
419
477
 
420
478
  i = 0
421
479
  while i < len(lines):
422
480
  line = lines[i]
481
+ abs_line = _line_offset + i + 1 # 1-indexed file-absolute line number
423
482
 
424
483
  # Check for directive opener
425
484
  m_open = _DIRECTIVE_OPEN.match(line)
@@ -440,6 +499,8 @@ def _split_directives(source: str) -> list[dict]:
440
499
  "attrs_raw": attrs_raw,
441
500
  "body_lines": [],
442
501
  "colon_count": len(colons),
502
+ "open_line": abs_line,
503
+ "body_start_offset": _line_offset + i + 1,
443
504
  }
444
505
  # Self-closing directives (no body content expected)
445
506
  if entry["name"] in _SELF_CLOSING_DIRECTIVES:
@@ -448,9 +509,12 @@ def _split_directives(source: str) -> list[dict]:
448
509
  "name": entry["name"],
449
510
  "attrs": _parse_attrs(entry["attrs_raw"]),
450
511
  "body": "",
512
+ "body_start_offset": entry["body_start_offset"],
451
513
  })
514
+ trace.append(f" line {abs_line}: {colons}{name} (self-closing)")
452
515
  else:
453
516
  stack.append(entry)
517
+ trace.append(f" line {abs_line}: {colons}{name} opened")
454
518
  else:
455
519
  # Nested directive — always treat as body content
456
520
  stack[-1]["body_lines"].append(line)
@@ -462,8 +526,9 @@ def _split_directives(source: str) -> list[dict]:
462
526
  if m_close and not stack:
463
527
  if not _any_self_closing_before(lines, i):
464
528
  close_colons = m_close.group(1)
529
+ trace.append(f" line {abs_line}: {close_colons} stray closer (nothing is open)")
465
530
  raise DirectiveError(
466
- f"line {i + 1}: stray '{close_colons}' closer with no open directive"
531
+ _stray_closer_message(abs_line, close_colons, trace, last_closed)
467
532
  )
468
533
  # Stray closer after a self-closing directive like divider — skip
469
534
  i += 1
@@ -476,11 +541,30 @@ def _split_directives(source: str) -> list[dict]:
476
541
  else:
477
542
  # Closing the open directive
478
543
  entry = stack.pop()
544
+ closed_colons = ":" * entry["colon_count"]
545
+ nested = _find_nested_directives(entry["body_lines"])
546
+ nested_note = ""
547
+ if nested:
548
+ names = ", ".join(f":::{n}" for n in nested[:3])
549
+ nested_note = f" — body has nested {names}"
550
+ trace.append(
551
+ f" line {abs_line}: {closed_colons} "
552
+ f"closed {entry['name']} (opened line {entry['open_line']})"
553
+ f"{nested_note}"
554
+ )
555
+ last_closed = {
556
+ "name": entry["name"],
557
+ "colon_count": entry["colon_count"],
558
+ "open_line": entry["open_line"],
559
+ "close_line": abs_line,
560
+ "body_lines": entry["body_lines"],
561
+ }
479
562
  segments.append({
480
563
  "type": "directive",
481
564
  "name": entry["name"],
482
565
  "attrs": _parse_attrs(entry["attrs_raw"]),
483
566
  "body": "\n".join(entry["body_lines"]),
567
+ "body_start_offset": entry["body_start_offset"],
484
568
  })
485
569
  i += 1
486
570
  continue
@@ -504,8 +588,10 @@ def _split_directives(source: str) -> list[dict]:
504
588
  unclosed = stack[-1]
505
589
  colons = ":" * unclosed["colon_count"]
506
590
  name = unclosed["name"]
591
+ open_line = unclosed["open_line"]
592
+ trace.append(f" line {open_line}: {colons}{name} ← still open at end of input")
507
593
  raise DirectiveError(
508
- f"unclosed directive '{colons}{name}' missing closing '{colons}'"
594
+ _unclosed_directive_message(open_line, colons, name, trace, unclosed["body_lines"])
509
595
  )
510
596
 
511
597
  return segments
@@ -514,7 +600,7 @@ def _split_directives(source: str) -> list[dict]:
514
600
  _MAX_PARSE_DEPTH = 50
515
601
 
516
602
 
517
- def _directive_to_block(name: str, attrs: dict[str, Any], body: str, _depth: int = 0) -> Block:
603
+ def _directive_to_block(name: str, attrs: dict[str, Any], body: str, _depth: int = 0, _line_offset: int = 0) -> Block:
518
604
  """Convert a parsed directive into a Block."""
519
605
  # Strip option lines from body; inline attrs take precedence over options
520
606
  options, body = _strip_options(body)
@@ -545,12 +631,12 @@ def _directive_to_block(name: str, attrs: dict[str, Any], body: str, _depth: int
545
631
 
546
632
  # Stat: optional caption body parsed as markdown
547
633
  if block_type == BlockType.STAT:
548
- body_doc = parse(body, _depth=_depth + 1) if body.strip() else Block(type=BlockType.DOCUMENT)
634
+ body_doc = parse(body, _depth=_depth + 1, _line_offset=_line_offset) if body.strip() else Block(type=BlockType.DOCUMENT)
549
635
  return Block(type=block_type, children=body_doc.children, attrs=attrs)
550
636
 
551
637
  # Tasklist alias: parse body, find the inner list, force tasklist styling
552
638
  if name == "tasklist":
553
- body_doc = parse(body, _depth=_depth + 1)
639
+ body_doc = parse(body, _depth=_depth + 1, _line_offset=_line_offset)
554
640
  for child in body_doc.children:
555
641
  if child.type == BlockType.LIST:
556
642
  child.attrs["tasklist"] = True
@@ -563,7 +649,7 @@ def _directive_to_block(name: str, attrs: dict[str, Any], body: str, _depth: int
563
649
  return Block(type=BlockType.LIST, attrs={"tasklist": True, **attrs})
564
650
 
565
651
  # Recursively parse the body through the full two-pass pipeline
566
- body_doc = parse(body, _depth=_depth + 1)
652
+ body_doc = parse(body, _depth=_depth + 1, _line_offset=_line_offset)
567
653
  return Block(
568
654
  type=block_type,
569
655
  children=body_doc.children,
@@ -641,14 +727,14 @@ def _apply_tasklist_markers(block: Block) -> Block:
641
727
  return block
642
728
 
643
729
 
644
- def parse(source: str, _depth: int = 0) -> Block:
730
+ def parse(source: str, _depth: int = 0, _line_offset: int = 0) -> Block:
645
731
  """Parse markdown+directive source into a Block tree.
646
732
 
647
733
  Returns a Block with type=DOCUMENT as root.
648
734
  """
649
735
  if _depth > _MAX_PARSE_DEPTH:
650
736
  raise ValueError(f"Maximum directive nesting depth ({_MAX_PARSE_DEPTH}) exceeded")
651
- segments = _split_directives(source)
737
+ segments = _split_directives(source, _line_offset=_line_offset)
652
738
  children: list[Block] = []
653
739
 
654
740
  for seg in segments:
@@ -657,6 +743,7 @@ def parse(source: str, _depth: int = 0) -> Block:
657
743
  else:
658
744
  children.append(_directive_to_block(
659
745
  seg["name"], seg["attrs"], seg["body"], _depth=_depth,
746
+ _line_offset=seg.get("body_start_offset", 0),
660
747
  ))
661
748
 
662
749
  # Walk the tree to auto-promote any markdown list with [ ]/[x] markers
@@ -10,6 +10,7 @@ from pygments.lexers import TextLexer, get_lexer_by_name
10
10
 
11
11
  from termrender.blocks import Block
12
12
  from termrender.renderers.borders import render_box
13
+ from termrender.style import visual_len, wrap_text
13
14
 
14
15
 
15
16
  def render(
@@ -19,18 +20,29 @@ def render(
19
20
  source = block.attrs.get("source", "")
20
21
  lang = block.attrs.get("lang")
21
22
 
23
+ # Wrap raw source lines to fit within the box before highlighting,
24
+ # so render_box doesn't need to grow beyond the layout allocation.
25
+ border_v = visual_len("│")
26
+ content_w = max((block.width or 1) - 2 * border_v - 2, 1)
27
+ raw_lines = source.split("\n") if source else [""]
28
+ wrapped_lines = []
29
+ for line in raw_lines:
30
+ wrapped_lines.extend(wrap_text(line, content_w))
31
+
32
+ wrapped_source = "\n".join(wrapped_lines)
33
+
22
34
  # Syntax highlight (or plain text)
23
- if color and source:
35
+ if color and wrapped_source:
24
36
  try:
25
37
  lexer = get_lexer_by_name(lang) if lang else TextLexer()
26
38
  except Exception:
27
39
  lexer = TextLexer()
28
- highlighted = highlight(source, lexer, TerminalFormatter())
40
+ highlighted = highlight(wrapped_source, lexer, TerminalFormatter())
29
41
  # Pygments adds a trailing newline — strip it
30
42
  highlighted = highlighted.rstrip("\n")
31
43
  code_lines = highlighted.split("\n")
32
44
  else:
33
- code_lines = source.split("\n") if source else [""]
45
+ code_lines = wrapped_lines
34
46
 
35
47
  return render_box(
36
48
  code_lines,
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes