termrender 0.1.0__py3-none-any.whl
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.
- termrender/__init__.py +43 -0
- termrender/__main__.py +46 -0
- termrender/blocks.py +48 -0
- termrender/emit.py +52 -0
- termrender/layout.py +134 -0
- termrender/parser.py +322 -0
- termrender/py.typed +0 -0
- termrender/renderers/__init__.py +1 -0
- termrender/renderers/borders.py +70 -0
- termrender/renderers/code.py +41 -0
- termrender/renderers/columns.py +34 -0
- termrender/renderers/divider.py +23 -0
- termrender/renderers/mermaid.py +34 -0
- termrender/renderers/panel.py +64 -0
- termrender/renderers/quote.py +32 -0
- termrender/renderers/text.py +157 -0
- termrender/renderers/tree.py +164 -0
- termrender/style.py +147 -0
- termrender-0.1.0.dist-info/METADATA +26 -0
- termrender-0.1.0.dist-info/RECORD +23 -0
- termrender-0.1.0.dist-info/WHEEL +4 -0
- termrender-0.1.0.dist-info/entry_points.txt +2 -0
- termrender-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Shared box-drawing border helper for termrender renderers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from termrender.style import style, visual_len, visual_ljust
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def render_box(
|
|
9
|
+
content_lines: list[str],
|
|
10
|
+
width: int,
|
|
11
|
+
color: bool,
|
|
12
|
+
title: str | None = None,
|
|
13
|
+
border_color: str | None = None,
|
|
14
|
+
dim: bool = False,
|
|
15
|
+
) -> list[str]:
|
|
16
|
+
"""Render content lines inside a box-drawing border.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
content_lines: Pre-rendered content to wrap in a box.
|
|
20
|
+
width: Total width of the box including borders.
|
|
21
|
+
color: Whether ANSI styling is enabled.
|
|
22
|
+
title: Optional title to display in the top border.
|
|
23
|
+
border_color: Color name for the border (used by panels).
|
|
24
|
+
dim: Whether to dim the border (used by code blocks).
|
|
25
|
+
"""
|
|
26
|
+
inner_w = width - 2 # border chars on each side
|
|
27
|
+
content_w = inner_w - 2 # 1-char padding on each side
|
|
28
|
+
|
|
29
|
+
# Build style kwargs for borders
|
|
30
|
+
style_kw: dict = {"enabled": color}
|
|
31
|
+
if border_color:
|
|
32
|
+
style_kw["color"] = border_color
|
|
33
|
+
if dim:
|
|
34
|
+
style_kw["dim"] = True
|
|
35
|
+
|
|
36
|
+
# Top border
|
|
37
|
+
if title:
|
|
38
|
+
title_part = f"─ {title} "
|
|
39
|
+
fill_count = max(0, inner_w - visual_len(title_part))
|
|
40
|
+
top_raw = "┌" + title_part + "─" * fill_count + "┐"
|
|
41
|
+
else:
|
|
42
|
+
top_raw = "┌" + "─" * inner_w + "┐"
|
|
43
|
+
top = style(top_raw, **style_kw)
|
|
44
|
+
top = visual_ljust(top, width)
|
|
45
|
+
|
|
46
|
+
# Bottom border
|
|
47
|
+
bot_raw = "└" + "─" * inner_w + "┘"
|
|
48
|
+
bot = style(bot_raw, **style_kw)
|
|
49
|
+
bot = visual_ljust(bot, width)
|
|
50
|
+
|
|
51
|
+
# Side borders
|
|
52
|
+
left = style("│", **style_kw)
|
|
53
|
+
right = style("│", **style_kw)
|
|
54
|
+
|
|
55
|
+
# Build output lines
|
|
56
|
+
lines = [top]
|
|
57
|
+
for cl in content_lines:
|
|
58
|
+
padded = visual_ljust(cl, content_w)
|
|
59
|
+
line = left + " " + padded + " " + right
|
|
60
|
+
line = visual_ljust(line, width)
|
|
61
|
+
lines.append(line)
|
|
62
|
+
|
|
63
|
+
# If no content, add one empty line
|
|
64
|
+
if not content_lines:
|
|
65
|
+
empty = left + " " + " " * content_w + " " + right
|
|
66
|
+
empty = visual_ljust(empty, width)
|
|
67
|
+
lines.append(empty)
|
|
68
|
+
|
|
69
|
+
lines.append(bot)
|
|
70
|
+
return lines
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Syntax-highlighted code block renderer for termrender."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from pygments import highlight
|
|
8
|
+
from pygments.formatters import TerminalFormatter
|
|
9
|
+
from pygments.lexers import TextLexer, get_lexer_by_name
|
|
10
|
+
|
|
11
|
+
from termrender.blocks import Block
|
|
12
|
+
from termrender.renderers.borders import render_box
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def render(
|
|
16
|
+
block: Block, color: bool, render_child: Callable[[Block, bool], list[str]]
|
|
17
|
+
) -> list[str]:
|
|
18
|
+
"""Render a code block with syntax highlighting and box-drawing borders."""
|
|
19
|
+
source = block.attrs.get("source", "")
|
|
20
|
+
lang = block.attrs.get("lang")
|
|
21
|
+
|
|
22
|
+
# Syntax highlight (or plain text)
|
|
23
|
+
if color and source:
|
|
24
|
+
try:
|
|
25
|
+
lexer = get_lexer_by_name(lang) if lang else TextLexer()
|
|
26
|
+
except Exception:
|
|
27
|
+
lexer = TextLexer()
|
|
28
|
+
highlighted = highlight(source, lexer, TerminalFormatter())
|
|
29
|
+
# Pygments adds a trailing newline — strip it
|
|
30
|
+
highlighted = highlighted.rstrip("\n")
|
|
31
|
+
code_lines = highlighted.split("\n")
|
|
32
|
+
else:
|
|
33
|
+
code_lines = source.split("\n") if source else [""]
|
|
34
|
+
|
|
35
|
+
return render_box(
|
|
36
|
+
code_lines,
|
|
37
|
+
width=block.width,
|
|
38
|
+
color=color,
|
|
39
|
+
title=lang,
|
|
40
|
+
dim=True,
|
|
41
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Column layout renderer (DES-008)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from termrender.blocks import Block
|
|
8
|
+
from termrender.style import visual_ljust
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render(block: Block, color: bool, render_child: Callable[[Block, bool], list[str]]) -> list[str]:
|
|
12
|
+
"""Render a COLUMNS block by zipping child COL blocks horizontally."""
|
|
13
|
+
col_outputs: list[tuple[int, list[str]]] = []
|
|
14
|
+
max_height = 0
|
|
15
|
+
|
|
16
|
+
for col in block.children:
|
|
17
|
+
lines = render_child(col, color)
|
|
18
|
+
col_outputs.append((col.width, lines))
|
|
19
|
+
if len(lines) > max_height:
|
|
20
|
+
max_height = len(lines)
|
|
21
|
+
|
|
22
|
+
if not col_outputs:
|
|
23
|
+
return [' ' * block.width] if block.width else ['']
|
|
24
|
+
|
|
25
|
+
result: list[str] = []
|
|
26
|
+
for row in range(max_height):
|
|
27
|
+
parts: list[str] = []
|
|
28
|
+
for col_width, lines in col_outputs:
|
|
29
|
+
line = lines[row] if row < len(lines) else ''
|
|
30
|
+
parts.append(visual_ljust(line, col_width))
|
|
31
|
+
joined = ' '.join(parts)
|
|
32
|
+
result.append(visual_ljust(joined, block.width))
|
|
33
|
+
|
|
34
|
+
return result
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Horizontal divider renderer for termrender."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from termrender.blocks import Block
|
|
6
|
+
from termrender.style import style, visual_center, visual_ljust
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def render(block: Block, color: bool) -> list[str]:
|
|
10
|
+
"""Render a horizontal divider, optionally with a centered label."""
|
|
11
|
+
w = block.width
|
|
12
|
+
label = block.attrs.get("label")
|
|
13
|
+
|
|
14
|
+
if label:
|
|
15
|
+
# Center label with surrounding dashes: ──── Label ────
|
|
16
|
+
inner = f" {label} "
|
|
17
|
+
line = visual_center(inner, w, "─")
|
|
18
|
+
else:
|
|
19
|
+
line = "─" * w
|
|
20
|
+
|
|
21
|
+
line = style(line, dim=True, enabled=color)
|
|
22
|
+
line = visual_ljust(line, w)
|
|
23
|
+
return [line]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Mermaid diagram renderer for termrender."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
from termrender.blocks import Block
|
|
8
|
+
from termrender.style import visual_ljust
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render(block: Block, color: bool) -> list[str]:
|
|
12
|
+
"""Render a mermaid diagram from pre-rendered or on-the-fly ASCII output."""
|
|
13
|
+
w = block.width
|
|
14
|
+
rendered = block.attrs.get("_rendered")
|
|
15
|
+
|
|
16
|
+
if rendered is None:
|
|
17
|
+
source = block.attrs.get("source", "")
|
|
18
|
+
try:
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
["mermaid-ascii", "-f", "-"],
|
|
21
|
+
input=source,
|
|
22
|
+
capture_output=True,
|
|
23
|
+
text=True,
|
|
24
|
+
timeout=30,
|
|
25
|
+
)
|
|
26
|
+
rendered = result.stdout
|
|
27
|
+
except Exception:
|
|
28
|
+
rendered = source
|
|
29
|
+
|
|
30
|
+
lines: list[str] = []
|
|
31
|
+
for raw_line in rendered.split("\n"):
|
|
32
|
+
lines.append(visual_ljust(raw_line, w))
|
|
33
|
+
|
|
34
|
+
return lines
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Panel and callout renderers for termrender."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from termrender.blocks import Block, BlockType
|
|
8
|
+
from termrender.renderers.borders import render_box
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render(
|
|
12
|
+
block: Block, color: bool, render_child: Callable[[Block, bool], list[str]]
|
|
13
|
+
) -> list[str]:
|
|
14
|
+
"""Render a panel block with box-drawing borders."""
|
|
15
|
+
border_color = block.attrs.get("color")
|
|
16
|
+
title = block.attrs.get("title")
|
|
17
|
+
|
|
18
|
+
# Render children content
|
|
19
|
+
content_lines: list[str] = []
|
|
20
|
+
for child in block.children:
|
|
21
|
+
content_lines.extend(render_child(child, color))
|
|
22
|
+
|
|
23
|
+
return render_box(
|
|
24
|
+
content_lines,
|
|
25
|
+
width=block.width,
|
|
26
|
+
color=color,
|
|
27
|
+
title=title,
|
|
28
|
+
border_color=border_color,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Callout type -> (color, icon)
|
|
33
|
+
_CALLOUT_MAP = {
|
|
34
|
+
"info": ("blue", "ℹ"),
|
|
35
|
+
"warning": ("yellow", "⚠"),
|
|
36
|
+
"error": ("red", "✖"),
|
|
37
|
+
"success": ("green", "✔"),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_callout(
|
|
42
|
+
block: Block, color: bool, render_child: Callable[[Block, bool], list[str]]
|
|
43
|
+
) -> list[str]:
|
|
44
|
+
"""Render a callout block by delegating to panel rendering."""
|
|
45
|
+
callout_type = block.attrs.get("type", "info")
|
|
46
|
+
callout_color, icon = _CALLOUT_MAP.get(callout_type, ("blue", "ℹ"))
|
|
47
|
+
|
|
48
|
+
title = f"{icon} {callout_type.capitalize()}"
|
|
49
|
+
|
|
50
|
+
# Create a copy of attrs with title and color set
|
|
51
|
+
patched_attrs = dict(block.attrs)
|
|
52
|
+
patched_attrs["title"] = title
|
|
53
|
+
patched_attrs["color"] = callout_color
|
|
54
|
+
|
|
55
|
+
# Build a proxy block with the patched attrs
|
|
56
|
+
proxy = Block(
|
|
57
|
+
type=BlockType.PANEL,
|
|
58
|
+
children=block.children,
|
|
59
|
+
text=block.text,
|
|
60
|
+
attrs=patched_attrs,
|
|
61
|
+
width=block.width,
|
|
62
|
+
height=block.height,
|
|
63
|
+
)
|
|
64
|
+
return render(proxy, color, render_child)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Blockquote renderer for termrender."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from termrender.blocks import Block
|
|
8
|
+
from termrender.style import style, visual_ljust
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render(
|
|
12
|
+
block: Block, color: bool, render_child: Callable[[Block, bool], list[str]]
|
|
13
|
+
) -> list[str]:
|
|
14
|
+
"""Render a blockquote with a left border bar and optional attribution."""
|
|
15
|
+
w = block.width
|
|
16
|
+
bar = style("│ ", color="gray", enabled=color)
|
|
17
|
+
bar_width = 2 # visual width of "│ "
|
|
18
|
+
inner_w = w - bar_width
|
|
19
|
+
|
|
20
|
+
lines: list[str] = []
|
|
21
|
+
for child in block.children:
|
|
22
|
+
for cl in render_child(child, color):
|
|
23
|
+
padded = visual_ljust(cl, inner_w)
|
|
24
|
+
lines.append(visual_ljust(bar + padded, w))
|
|
25
|
+
|
|
26
|
+
by = block.attrs.get("by")
|
|
27
|
+
if by:
|
|
28
|
+
attr_text = style(f"— {by}", dim=True, enabled=color)
|
|
29
|
+
attr_line = visual_ljust(bar + visual_ljust(attr_text, inner_w), w)
|
|
30
|
+
lines.append(attr_line)
|
|
31
|
+
|
|
32
|
+
return lines
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Paragraph, heading, list, and list-item renderers for termrender."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from termrender.blocks import Block, BlockType, InlineSpan
|
|
6
|
+
from termrender.style import style, visual_len, visual_ljust, wrap_text, render_spans
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _render_wrapped_spans(
|
|
10
|
+
spans: list[InlineSpan],
|
|
11
|
+
width: int,
|
|
12
|
+
color: bool,
|
|
13
|
+
first_prefix: str = "",
|
|
14
|
+
cont_prefix: str = "",
|
|
15
|
+
total_width: int | None = None,
|
|
16
|
+
) -> list[str]:
|
|
17
|
+
"""Wrap spans into styled lines with optional prefixes and padding."""
|
|
18
|
+
if total_width is None:
|
|
19
|
+
total_width = width
|
|
20
|
+
|
|
21
|
+
plain = "".join(span.text for span in spans)
|
|
22
|
+
wrapped = wrap_text(plain, max(width, 1))
|
|
23
|
+
|
|
24
|
+
lines: list[str] = []
|
|
25
|
+
char_offset = 0
|
|
26
|
+
for i, raw_line in enumerate(wrapped):
|
|
27
|
+
line_len = len(raw_line)
|
|
28
|
+
styled = _render_span_slice(spans, char_offset, char_offset + line_len, color)
|
|
29
|
+
prefix = first_prefix if i == 0 else cont_prefix
|
|
30
|
+
lines.append(visual_ljust(prefix + styled, total_width))
|
|
31
|
+
char_offset += line_len
|
|
32
|
+
if char_offset < len(plain) and plain[char_offset] == " ":
|
|
33
|
+
char_offset += 1
|
|
34
|
+
|
|
35
|
+
return lines
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _render_paragraph(block: Block, color: bool) -> list[str]:
|
|
39
|
+
if not block.text:
|
|
40
|
+
return [visual_ljust("", block.width)]
|
|
41
|
+
|
|
42
|
+
return _render_wrapped_spans(block.text, block.width, color)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _render_span_slice(
|
|
46
|
+
spans: list[InlineSpan], start: int, end: int, color: bool
|
|
47
|
+
) -> str:
|
|
48
|
+
"""Render the portion of spans covering character range [start, end)."""
|
|
49
|
+
parts: list[str] = []
|
|
50
|
+
offset = 0
|
|
51
|
+
for span in spans:
|
|
52
|
+
span_start = offset
|
|
53
|
+
span_end = offset + len(span.text)
|
|
54
|
+
# Find overlap with [start, end)
|
|
55
|
+
overlap_start = max(span_start, start)
|
|
56
|
+
overlap_end = min(span_end, end)
|
|
57
|
+
if overlap_start < overlap_end:
|
|
58
|
+
slice_text = span.text[overlap_start - span_start : overlap_end - span_start]
|
|
59
|
+
if span.code:
|
|
60
|
+
slice_text = style(slice_text, dim=True, enabled=color)
|
|
61
|
+
elif span.bold or span.italic:
|
|
62
|
+
slice_text = style(slice_text, bold=span.bold, italic=span.italic, enabled=color)
|
|
63
|
+
parts.append(slice_text)
|
|
64
|
+
offset = span_end
|
|
65
|
+
if offset >= end:
|
|
66
|
+
break
|
|
67
|
+
return "".join(parts)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _render_heading(block: Block, color: bool) -> list[str]:
|
|
71
|
+
level = block.attrs.get("level", 1)
|
|
72
|
+
text = render_spans(block.text, color=False) # plain text first
|
|
73
|
+
|
|
74
|
+
if level == 1:
|
|
75
|
+
styled = style(text, color="blue", bold=True, enabled=color)
|
|
76
|
+
elif level == 2:
|
|
77
|
+
styled = style(text, bold=True, enabled=color)
|
|
78
|
+
elif level == 3:
|
|
79
|
+
styled = style(text, italic=True, enabled=color)
|
|
80
|
+
else:
|
|
81
|
+
styled = style(text, dim=True, enabled=color)
|
|
82
|
+
|
|
83
|
+
return [visual_ljust(styled, block.width)]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _render_list(block: Block, color: bool) -> list[str]:
|
|
87
|
+
if not block.children:
|
|
88
|
+
return [visual_ljust("", block.width)]
|
|
89
|
+
|
|
90
|
+
ordered = block.attrs.get("ordered", False)
|
|
91
|
+
lines: list[str] = []
|
|
92
|
+
|
|
93
|
+
for i, child in enumerate(block.children):
|
|
94
|
+
if child.type == BlockType.LIST_ITEM:
|
|
95
|
+
prefix = f"{i + 1}. " if ordered else "• "
|
|
96
|
+
item_lines = _render_list_item(child, prefix, color)
|
|
97
|
+
lines.extend(item_lines)
|
|
98
|
+
elif child.type == BlockType.LIST:
|
|
99
|
+
# Nested list — indent by 2
|
|
100
|
+
nested = Block(
|
|
101
|
+
type=child.type,
|
|
102
|
+
children=child.children,
|
|
103
|
+
text=child.text,
|
|
104
|
+
attrs=child.attrs,
|
|
105
|
+
width=(child.width or block.width) - 2,
|
|
106
|
+
height=child.height,
|
|
107
|
+
)
|
|
108
|
+
nested_lines = _render_list(nested, color)
|
|
109
|
+
for nl in nested_lines:
|
|
110
|
+
lines.append(visual_ljust(" " + nl.rstrip(), block.width))
|
|
111
|
+
|
|
112
|
+
return lines if lines else [visual_ljust("", block.width)]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _render_list_item(block: Block, prefix: str, color: bool) -> list[str]:
|
|
116
|
+
w = block.width
|
|
117
|
+
indent = " " * len(prefix)
|
|
118
|
+
text_width = w - len(prefix)
|
|
119
|
+
|
|
120
|
+
if not block.text:
|
|
121
|
+
return [visual_ljust(prefix, w)]
|
|
122
|
+
|
|
123
|
+
lines = _render_wrapped_spans(
|
|
124
|
+
block.text, text_width, color,
|
|
125
|
+
first_prefix=prefix, cont_prefix=indent, total_width=w,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Render nested children (e.g., nested lists inside list items)
|
|
129
|
+
for child in block.children:
|
|
130
|
+
if child.type == BlockType.LIST:
|
|
131
|
+
nested = Block(
|
|
132
|
+
type=child.type,
|
|
133
|
+
children=child.children,
|
|
134
|
+
text=child.text,
|
|
135
|
+
attrs=child.attrs,
|
|
136
|
+
width=w - len(prefix),
|
|
137
|
+
height=child.height,
|
|
138
|
+
)
|
|
139
|
+
nested_lines = _render_list(nested, color)
|
|
140
|
+
for nl in nested_lines:
|
|
141
|
+
lines.append(visual_ljust(indent + nl.rstrip(), w))
|
|
142
|
+
|
|
143
|
+
return lines
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def render(block: Block, color: bool) -> list[str]:
|
|
147
|
+
"""Render a text block (paragraph, heading, list, or list item)."""
|
|
148
|
+
if block.type == BlockType.PARAGRAPH:
|
|
149
|
+
return _render_paragraph(block, color)
|
|
150
|
+
elif block.type == BlockType.HEADING:
|
|
151
|
+
return _render_heading(block, color)
|
|
152
|
+
elif block.type == BlockType.LIST:
|
|
153
|
+
return _render_list(block, color)
|
|
154
|
+
elif block.type == BlockType.LIST_ITEM:
|
|
155
|
+
return _render_list_item(block, "• ", color)
|
|
156
|
+
else:
|
|
157
|
+
return [visual_ljust("", block.width or 0)]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Tree rendering with Unicode guide lines (DES-010)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from termrender.blocks import Block, InlineSpan
|
|
8
|
+
from termrender.style import style, visual_ljust, render_spans
|
|
9
|
+
|
|
10
|
+
# Unicode box-drawing guide characters
|
|
11
|
+
BRANCH = "├── "
|
|
12
|
+
LAST_BRANCH = "└── "
|
|
13
|
+
CONTINUATION = "│ "
|
|
14
|
+
BLANK = " "
|
|
15
|
+
|
|
16
|
+
# Status marker patterns
|
|
17
|
+
STATUS_MARKERS = {
|
|
18
|
+
"[x]": ("✔", "green"),
|
|
19
|
+
"[!]": ("⚠", "yellow"),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _detect_indent(lines: list[str]) -> int:
|
|
24
|
+
"""Auto-detect indent level (2 or 4 spaces) from source lines."""
|
|
25
|
+
for line in lines:
|
|
26
|
+
if not line or not line[0] == " ":
|
|
27
|
+
continue
|
|
28
|
+
stripped = line.lstrip(" ")
|
|
29
|
+
indent = len(line) - len(stripped)
|
|
30
|
+
if indent > 0:
|
|
31
|
+
return 2 if indent <= 2 else (indent if indent <= 4 else 4)
|
|
32
|
+
return 2
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_tree(lines: list[str], indent_size: int) -> list[tuple[int, str]]:
|
|
36
|
+
"""Parse indented lines into (depth, label) pairs."""
|
|
37
|
+
result: list[tuple[int, str]] = []
|
|
38
|
+
for line in lines:
|
|
39
|
+
if not line.strip():
|
|
40
|
+
continue
|
|
41
|
+
stripped = line.lstrip(" ")
|
|
42
|
+
spaces = len(line) - len(stripped)
|
|
43
|
+
depth = spaces // indent_size
|
|
44
|
+
result.append((depth, stripped))
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _apply_status_markers(label: str, color: bool) -> str:
|
|
49
|
+
"""Replace status markers like [x] and [!] with styled symbols."""
|
|
50
|
+
for marker, (symbol, clr) in STATUS_MARKERS.items():
|
|
51
|
+
if marker in label:
|
|
52
|
+
styled_symbol = style(symbol, color=clr, enabled=color)
|
|
53
|
+
label = label.replace(marker, styled_symbol)
|
|
54
|
+
return label
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_INLINE_RE = re.compile(
|
|
58
|
+
r'(\*\*(.+?)\*\*)' # **bold**
|
|
59
|
+
r'|(\*(.+?)\*)' # *italic*
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _label_to_spans(label: str) -> list[InlineSpan]:
|
|
64
|
+
"""Convert a label string with markdown inline formatting to InlineSpans."""
|
|
65
|
+
spans: list[InlineSpan] = []
|
|
66
|
+
last_end = 0
|
|
67
|
+
for m in _INLINE_RE.finditer(label):
|
|
68
|
+
# Add any plain text before this match
|
|
69
|
+
if m.start() > last_end:
|
|
70
|
+
spans.append(InlineSpan(text=label[last_end:m.start()]))
|
|
71
|
+
if m.group(2) is not None:
|
|
72
|
+
# **bold**
|
|
73
|
+
spans.append(InlineSpan(text=m.group(2), bold=True))
|
|
74
|
+
elif m.group(4) is not None:
|
|
75
|
+
# *italic*
|
|
76
|
+
spans.append(InlineSpan(text=m.group(4), italic=True))
|
|
77
|
+
last_end = m.end()
|
|
78
|
+
# Trailing plain text
|
|
79
|
+
if last_end < len(label):
|
|
80
|
+
spans.append(InlineSpan(text=label[last_end:]))
|
|
81
|
+
return spans if spans else [InlineSpan(text=label)]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _style_guide(text: str, guide_color: str | None, color: bool) -> str:
|
|
85
|
+
"""Style a guide line character with the tree's color attribute."""
|
|
86
|
+
if guide_color and color:
|
|
87
|
+
return style(text, color=guide_color, enabled=True)
|
|
88
|
+
return text
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def render(block: Block, color: bool) -> list[str]:
|
|
92
|
+
"""Render a tree block with Unicode guide lines."""
|
|
93
|
+
source = block.attrs.get("source", "")
|
|
94
|
+
guide_color = block.attrs.get("color")
|
|
95
|
+
raw_lines = source.split("\n")
|
|
96
|
+
indent_size = _detect_indent(raw_lines)
|
|
97
|
+
nodes = _parse_tree(raw_lines, indent_size)
|
|
98
|
+
width = block.width or 80
|
|
99
|
+
|
|
100
|
+
if not nodes:
|
|
101
|
+
return [visual_ljust("", width)]
|
|
102
|
+
|
|
103
|
+
# For each node, determine its guide prefix based on depth and siblings
|
|
104
|
+
output: list[str] = []
|
|
105
|
+
|
|
106
|
+
# Build sibling relationships: for each node, is it the last among its siblings?
|
|
107
|
+
is_last: list[bool] = []
|
|
108
|
+
for i, (depth, _label) in enumerate(nodes):
|
|
109
|
+
# Look ahead to find if there's another node at the same depth
|
|
110
|
+
# under the same parent
|
|
111
|
+
last = True
|
|
112
|
+
for j in range(i + 1, len(nodes)):
|
|
113
|
+
jdepth, _ = nodes[j]
|
|
114
|
+
if jdepth == depth:
|
|
115
|
+
last = False
|
|
116
|
+
break
|
|
117
|
+
if jdepth < depth:
|
|
118
|
+
# We've gone back up past our parent, so we are last
|
|
119
|
+
break
|
|
120
|
+
is_last.append(last)
|
|
121
|
+
|
|
122
|
+
# Track which depth levels have continuing lines
|
|
123
|
+
# active_levels[d] = True means depth d still has children coming
|
|
124
|
+
for i, (depth, label) in enumerate(nodes):
|
|
125
|
+
# Apply status markers
|
|
126
|
+
label = _apply_status_markers(label, color)
|
|
127
|
+
|
|
128
|
+
# Build the prefix for this line
|
|
129
|
+
if depth == 0:
|
|
130
|
+
# Root-level items get no guide prefix
|
|
131
|
+
styled_label = render_spans(_label_to_spans(label), color)
|
|
132
|
+
line = styled_label
|
|
133
|
+
else:
|
|
134
|
+
# Build prefix from depth 1 to depth-1 (continuation/blank)
|
|
135
|
+
# then the branch character for current depth
|
|
136
|
+
prefix_parts: list[str] = []
|
|
137
|
+
for d in range(1, depth):
|
|
138
|
+
# Check if depth d has more siblings coming after this point
|
|
139
|
+
has_more = False
|
|
140
|
+
for j in range(i + 1, len(nodes)):
|
|
141
|
+
jdepth, _ = nodes[j]
|
|
142
|
+
if jdepth == d:
|
|
143
|
+
has_more = True
|
|
144
|
+
break
|
|
145
|
+
if jdepth < d:
|
|
146
|
+
break
|
|
147
|
+
if has_more:
|
|
148
|
+
prefix_parts.append(_style_guide(CONTINUATION, guide_color, color))
|
|
149
|
+
else:
|
|
150
|
+
prefix_parts.append(BLANK)
|
|
151
|
+
|
|
152
|
+
# Current depth connector
|
|
153
|
+
if is_last[i]:
|
|
154
|
+
prefix_parts.append(_style_guide(LAST_BRANCH, guide_color, color))
|
|
155
|
+
else:
|
|
156
|
+
prefix_parts.append(_style_guide(BRANCH, guide_color, color))
|
|
157
|
+
|
|
158
|
+
prefix = "".join(prefix_parts)
|
|
159
|
+
styled_label = render_spans(_label_to_spans(label), color)
|
|
160
|
+
line = prefix + styled_label
|
|
161
|
+
|
|
162
|
+
output.append(visual_ljust(line, width))
|
|
163
|
+
|
|
164
|
+
return output
|