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.
@@ -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