termrender 0.3.0__tar.gz → 0.5.0__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.
- {termrender-0.3.0 → termrender-0.5.0}/.gitignore +3 -0
- {termrender-0.3.0 → termrender-0.5.0}/CHANGELOG.md +30 -0
- {termrender-0.3.0 → termrender-0.5.0}/PKG-INFO +1 -1
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/CLAUDE.md +3 -1
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/__main__.py +23 -2
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/layout.py +2 -1
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/parser.py +97 -23
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/renderers/borders.py +12 -2
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/renderers/panel.py +15 -1
- termrender-0.5.0/src/termrender/renderers/table.py +160 -0
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/renderers/text.py +17 -6
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/style.py +27 -1
- termrender-0.5.0/tests/test_myst_gaps.py +114 -0
- termrender-0.5.0/tests/test_variable_colons.py +162 -0
- termrender-0.3.0/src/termrender/renderers/table.py +0 -95
- {termrender-0.3.0 → termrender-0.5.0}/.github/workflows/publish.yml +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/CLAUDE.md +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/LICENSE +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/README.md +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/design.json +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/pyproject.toml +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/requirements.json +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/__init__.py +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/blocks.py +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/emit.py +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/py.typed +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/renderers/CLAUDE.md +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/renderers/__init__.py +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/renderers/code.py +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/renderers/columns.py +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/renderers/divider.py +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/renderers/mermaid.py +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/renderers/quote.py +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/src/termrender/renderers/tree.py +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/tests/__init__.py +0 -0
- {termrender-0.3.0 → termrender-0.5.0}/tests/test_column_alignment.py +0 -0
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.5.0 (2026-04-06)
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
- **table**: Render horizontal separator lines between data rows
|
|
9
|
+
([`3e4c74a`](https://github.com/CaptainCrouton89/termrender/commit/3e4c74a10d63470f2eb2ec096bb47cf41f0b7f70))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
## v0.4.0 (2026-04-05)
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
- **parser**: Variable colon counts, backtick fence directives, and gloam-inspired theming
|
|
17
|
+
([`47fac7f`](https://github.com/CaptainCrouton89/termrender/commit/47fac7fcf13d33e5d9986d3f9ca42ddaf5e7207d))
|
|
18
|
+
|
|
19
|
+
Parser changes: - Support 3+ colon openers/closers with stack-based matching - Backtick fence
|
|
20
|
+
directive syntax (```{name}) via mistune AST interception - Option line stripping (:key: value)
|
|
21
|
+
into directive attrs
|
|
22
|
+
|
|
23
|
+
CLI changes: - Syntax validation before tmux pane creation (no orphan panes on bad input) - TTY
|
|
24
|
+
auto-detect for color (disabled when piping, forced in tmux subprocess)
|
|
25
|
+
|
|
26
|
+
Theming (gloam-inspired defaults): - Headings: depth-based colored fg + dim tinted bg
|
|
27
|
+
(yellow→green→cyan→blue→magenta) - Inline code: cyan (aqua) - Panel borders: dim gray with yellow
|
|
28
|
+
bold titles - Table borders: blue dim, headers: yellow bold on dim-blue bg - Background color
|
|
29
|
+
support added to style()
|
|
30
|
+
|
|
31
|
+
24 new tests across two test files.
|
|
32
|
+
|
|
33
|
+
|
|
4
34
|
## v0.3.0 (2026-04-05)
|
|
5
35
|
|
|
6
36
|
### Documentation
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termrender
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
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
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
|
|
13
13
|
## Directive nesting: depth counter, not stack entries
|
|
14
14
|
|
|
15
|
-
The parser
|
|
15
|
+
The parser handles two directive syntaxes through different passes: colon directives (`:::name`) are extracted in pass 1 via regex in `_split_directives`, while backtick fence directives (`` ```{name} ``) are resolved in pass 2 via mistune's AST walk in `_convert_ast`. Both paths funnel through `_directive_to_block` for block construction.
|
|
16
|
+
|
|
17
|
+
For colon directives, the parser tracks nesting with a `depth` integer inside the top stack entry, not separate stack entries (parser.py:241–306). A closer `:::` only pops the stack when `depth == 1`; otherwise it decrements depth and treats the closer as body content. Consequence: innermost closers appear verbatim in the body of the parent and are re-parsed on the recursive `parse()` call at line 351.
|
|
16
18
|
|
|
17
19
|
Max recursion depth is 50; exceeding it raises `ValueError`, not `DirectiveError`. Both the render path and `--check` path in `__main__.py` catch `ValueError` and map it to exit code 2 (`EXIT_SYNTAX`).
|
|
18
20
|
|
|
@@ -160,6 +160,23 @@ def main() -> None:
|
|
|
160
160
|
|
|
161
161
|
# --tmux: render in a new tmux side pane, sized to fit
|
|
162
162
|
if args.tmux:
|
|
163
|
+
# Validate syntax before creating pane — fail fast to caller's terminal
|
|
164
|
+
try:
|
|
165
|
+
from termrender.parser import parse as _parse
|
|
166
|
+
_parse(source)
|
|
167
|
+
except DirectiveError as e:
|
|
168
|
+
_error(
|
|
169
|
+
f"syntax error: {e}",
|
|
170
|
+
fix="check directive openers have matching ::: closers and attribute syntax is key=\"value\"",
|
|
171
|
+
code=EXIT_SYNTAX,
|
|
172
|
+
)
|
|
173
|
+
except ValueError as e:
|
|
174
|
+
_error(
|
|
175
|
+
f"nesting error: {e}",
|
|
176
|
+
fix="reduce directive nesting depth (max 50 levels)",
|
|
177
|
+
code=EXIT_SYNTAX,
|
|
178
|
+
)
|
|
179
|
+
|
|
163
180
|
import shlex
|
|
164
181
|
import subprocess
|
|
165
182
|
import tempfile
|
|
@@ -211,7 +228,8 @@ def main() -> None:
|
|
|
211
228
|
cmd_parts.append("--cjk")
|
|
212
229
|
cmd_parts.extend(["-w", str(pane_width)])
|
|
213
230
|
|
|
214
|
-
|
|
231
|
+
# TERMRENDER_COLOR=1 forces color on despite stdout piping to less
|
|
232
|
+
pane_cmd = "TERMRENDER_COLOR=1 " + " ".join(cmd_parts) + " | less -R; rm -f " + shlex.quote(tmpfile)
|
|
215
233
|
|
|
216
234
|
try:
|
|
217
235
|
subprocess.run(
|
|
@@ -249,7 +267,10 @@ def main() -> None:
|
|
|
249
267
|
sys.exit(EXIT_OK)
|
|
250
268
|
|
|
251
269
|
try:
|
|
252
|
-
|
|
270
|
+
use_color = not args.no_color and (
|
|
271
|
+
sys.stdout.isatty() or os.environ.get("TERMRENDER_COLOR") == "1"
|
|
272
|
+
)
|
|
273
|
+
output = render(source, width=args.width, color=use_color)
|
|
253
274
|
except TerminalError as e:
|
|
254
275
|
_error(
|
|
255
276
|
f"terminal error: {e}",
|
|
@@ -136,7 +136,8 @@ def resolve_height(block: Block) -> None:
|
|
|
136
136
|
max(len(wrap_text(r[i], col_widths[i])) for i in range(n_cols))
|
|
137
137
|
for r in rr
|
|
138
138
|
) if rr else 0
|
|
139
|
-
|
|
139
|
+
row_seps = max(len(rr) - 1, 0) # horizontal lines between data rows
|
|
140
|
+
block.height = header_h + data_h + 3 + row_seps # top border + header sep + bottom border + row seps
|
|
140
141
|
|
|
141
142
|
elif bt == BlockType.MERMAID:
|
|
142
143
|
source = block.attrs.get("source", "") or _plain_text(block.text)
|
|
@@ -36,12 +36,12 @@ def _sanitize_text(text: str) -> str:
|
|
|
36
36
|
"""Strip non-SGR ANSI escape sequences from text."""
|
|
37
37
|
return _UNSAFE_ANSI_RE.sub('', text)
|
|
38
38
|
|
|
39
|
-
# Directive opener: :::name or
|
|
39
|
+
# Directive opener: :::name or ::::name etc. (3+ colons)
|
|
40
40
|
_DIRECTIVE_OPEN = re.compile(
|
|
41
|
-
r"
|
|
41
|
+
r"^(:{3,})(\w+)(?:\{([^}]*)\})?\s*$"
|
|
42
42
|
)
|
|
43
|
-
# Directive closer:
|
|
44
|
-
_DIRECTIVE_CLOSE = re.compile(r"
|
|
43
|
+
# Directive closer: 3+ colons on its own line
|
|
44
|
+
_DIRECTIVE_CLOSE = re.compile(r"^(:{3,})\s*$")
|
|
45
45
|
|
|
46
46
|
# Attribute parser: key=value or key="quoted value"
|
|
47
47
|
_ATTR_PAIR = re.compile(
|
|
@@ -61,6 +61,14 @@ _DIRECTIVE_TO_BLOCK: dict[str, BlockType] = {
|
|
|
61
61
|
|
|
62
62
|
_SELF_CLOSING_DIRECTIVES = frozenset({"divider"})
|
|
63
63
|
|
|
64
|
+
# MyST backtick fence directive: ```{name} optional-argument
|
|
65
|
+
_BACKTICK_DIRECTIVE_RE = re.compile(r"^\{(\w[\w-]*)\}(.*)")
|
|
66
|
+
|
|
67
|
+
# MyST option line: :key: value — intentionally requires a value after the key
|
|
68
|
+
# (the \s+(.+) part). Flag-style options like :nosandbox: (no value) won't match
|
|
69
|
+
# and will be treated as body content.
|
|
70
|
+
_OPTION_LINE_RE = re.compile(r"^:(\w[\w-]*):\s+(.+)$")
|
|
71
|
+
|
|
64
72
|
_mistune_md = mistune.create_markdown(renderer="ast", plugins=["table"])
|
|
65
73
|
|
|
66
74
|
|
|
@@ -71,7 +79,7 @@ def _any_self_closing_before(lines: list[str], close_idx: int) -> bool:
|
|
|
71
79
|
if not line:
|
|
72
80
|
continue
|
|
73
81
|
m = _DIRECTIVE_OPEN.match(lines[j])
|
|
74
|
-
if m and m.group(
|
|
82
|
+
if m and m.group(2) in _SELF_CLOSING_DIRECTIVES:
|
|
75
83
|
return True
|
|
76
84
|
return False
|
|
77
85
|
return False
|
|
@@ -117,7 +125,40 @@ def _convert_inline(nodes: list[dict]) -> list[InlineSpan]:
|
|
|
117
125
|
return spans
|
|
118
126
|
|
|
119
127
|
|
|
120
|
-
def
|
|
128
|
+
def _strip_options(body: str) -> tuple[dict[str, str], str]:
|
|
129
|
+
"""Strip MyST option lines from the start of a directive body.
|
|
130
|
+
|
|
131
|
+
Option lines have the form `:key: value` and appear at the start of the body.
|
|
132
|
+
Blank lines between option lines are allowed. Scanning stops at the first
|
|
133
|
+
non-option, non-blank line.
|
|
134
|
+
|
|
135
|
+
Returns (options_dict, remaining_body).
|
|
136
|
+
"""
|
|
137
|
+
if not body or not body.lstrip("\n").startswith(":"):
|
|
138
|
+
return {}, body
|
|
139
|
+
lines = body.split("\n")
|
|
140
|
+
options: dict[str, str] = {}
|
|
141
|
+
last_option_idx = -1
|
|
142
|
+
for i, line in enumerate(lines):
|
|
143
|
+
stripped = line.strip()
|
|
144
|
+
if not stripped:
|
|
145
|
+
# blank lines are OK between options
|
|
146
|
+
continue
|
|
147
|
+
m = _OPTION_LINE_RE.match(stripped)
|
|
148
|
+
if m:
|
|
149
|
+
options[m.group(1)] = m.group(2)
|
|
150
|
+
last_option_idx = i
|
|
151
|
+
else:
|
|
152
|
+
break
|
|
153
|
+
if last_option_idx == -1:
|
|
154
|
+
return {}, body
|
|
155
|
+
remaining = "\n".join(lines[last_option_idx + 1:])
|
|
156
|
+
# Strip leading blank lines from remaining body
|
|
157
|
+
remaining = remaining.lstrip("\n")
|
|
158
|
+
return options, remaining
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _convert_ast(nodes: list[dict], _depth: int = 0) -> list[Block]:
|
|
121
162
|
"""Convert mistune AST nodes into Block tree."""
|
|
122
163
|
blocks: list[Block] = []
|
|
123
164
|
for node in nodes:
|
|
@@ -142,7 +183,24 @@ def _convert_ast(nodes: list[dict]) -> list[Block]:
|
|
|
142
183
|
elif ntype == "block_code":
|
|
143
184
|
raw = node.get("raw", "")
|
|
144
185
|
info = node.get("attrs", {}).get("info", "")
|
|
145
|
-
|
|
186
|
+
# MyST backtick fence directive: ```{name} optional-arg
|
|
187
|
+
m_directive = _BACKTICK_DIRECTIVE_RE.match(info) if info else None
|
|
188
|
+
if m_directive:
|
|
189
|
+
dir_name = m_directive.group(1)
|
|
190
|
+
arg_text = m_directive.group(2).strip()
|
|
191
|
+
if dir_name == "mermaid":
|
|
192
|
+
options, body = _strip_options(raw)
|
|
193
|
+
attrs = dict(options)
|
|
194
|
+
if arg_text:
|
|
195
|
+
attrs["argument"] = arg_text
|
|
196
|
+
attrs["source"] = body
|
|
197
|
+
blocks.append(Block(type=BlockType.MERMAID, attrs=attrs))
|
|
198
|
+
else:
|
|
199
|
+
attrs: dict[str, Any] = {}
|
|
200
|
+
if arg_text:
|
|
201
|
+
attrs["argument"] = arg_text
|
|
202
|
+
blocks.append(_directive_to_block(dir_name, attrs, raw, _depth=_depth))
|
|
203
|
+
elif info == "mermaid":
|
|
146
204
|
blocks.append(Block(
|
|
147
205
|
type=BlockType.MERMAID,
|
|
148
206
|
attrs={"source": raw},
|
|
@@ -166,7 +224,7 @@ def _convert_ast(nodes: list[dict]) -> list[Block]:
|
|
|
166
224
|
if child["type"] == "block_text":
|
|
167
225
|
item_spans.extend(_convert_inline(child.get("children", [])))
|
|
168
226
|
else:
|
|
169
|
-
sub_blocks.extend(_convert_ast([child]))
|
|
227
|
+
sub_blocks.extend(_convert_ast([child], _depth=_depth))
|
|
170
228
|
items.append(Block(
|
|
171
229
|
type=BlockType.LIST_ITEM,
|
|
172
230
|
text=item_spans,
|
|
@@ -209,23 +267,23 @@ def _convert_ast(nodes: list[dict]) -> list[Block]:
|
|
|
209
267
|
blocks.append(Block(type=BlockType.DIVIDER))
|
|
210
268
|
|
|
211
269
|
elif ntype == "block_quote":
|
|
212
|
-
children = _convert_ast(node.get("children", []))
|
|
270
|
+
children = _convert_ast(node.get("children", []), _depth=_depth)
|
|
213
271
|
blocks.append(Block(type=BlockType.QUOTE, children=children))
|
|
214
272
|
|
|
215
273
|
else:
|
|
216
274
|
# Unknown block type - try to extract any content
|
|
217
275
|
if "children" in node:
|
|
218
|
-
blocks.extend(_convert_ast(node["children"]))
|
|
276
|
+
blocks.extend(_convert_ast(node["children"], _depth=_depth))
|
|
219
277
|
|
|
220
278
|
return blocks
|
|
221
279
|
|
|
222
280
|
|
|
223
|
-
def _parse_markdown(source: str) -> list[Block]:
|
|
281
|
+
def _parse_markdown(source: str, _depth: int = 0) -> list[Block]:
|
|
224
282
|
"""Parse a markdown string via mistune and convert to Block list."""
|
|
225
283
|
if not source.strip():
|
|
226
284
|
return []
|
|
227
285
|
ast_nodes = _mistune_md(source)
|
|
228
|
-
return _convert_ast(ast_nodes)
|
|
286
|
+
return _convert_ast(ast_nodes, _depth=_depth)
|
|
229
287
|
|
|
230
288
|
|
|
231
289
|
def _split_directives(source: str) -> list[dict]:
|
|
@@ -247,6 +305,9 @@ def _split_directives(source: str) -> list[dict]:
|
|
|
247
305
|
# Check for directive opener
|
|
248
306
|
m_open = _DIRECTIVE_OPEN.match(line)
|
|
249
307
|
if m_open:
|
|
308
|
+
colons = m_open.group(1)
|
|
309
|
+
name = m_open.group(2)
|
|
310
|
+
attrs_raw = m_open.group(3)
|
|
250
311
|
if not stack:
|
|
251
312
|
# Top-level directive opening — flush accumulated markdown
|
|
252
313
|
if current_md_lines:
|
|
@@ -256,13 +317,14 @@ def _split_directives(source: str) -> list[dict]:
|
|
|
256
317
|
})
|
|
257
318
|
current_md_lines = []
|
|
258
319
|
entry = {
|
|
259
|
-
"name":
|
|
260
|
-
"attrs_raw":
|
|
320
|
+
"name": name,
|
|
321
|
+
"attrs_raw": attrs_raw,
|
|
261
322
|
"body_lines": [],
|
|
262
323
|
"depth": 1,
|
|
324
|
+
"colon_count": len(colons),
|
|
263
325
|
}
|
|
264
326
|
# Self-closing directives (no body content expected)
|
|
265
|
-
if entry["name"] in
|
|
327
|
+
if entry["name"] in _SELF_CLOSING_DIRECTIVES:
|
|
266
328
|
segments.append({
|
|
267
329
|
"type": "directive",
|
|
268
330
|
"name": entry["name"],
|
|
@@ -272,8 +334,9 @@ def _split_directives(source: str) -> list[dict]:
|
|
|
272
334
|
else:
|
|
273
335
|
stack.append(entry)
|
|
274
336
|
else:
|
|
275
|
-
# Nested directive — track depth
|
|
276
|
-
stack[-1]["
|
|
337
|
+
# Nested directive — track depth only if colon count matches
|
|
338
|
+
if len(colons) == stack[-1]["colon_count"]:
|
|
339
|
+
stack[-1]["depth"] += 1
|
|
277
340
|
stack[-1]["body_lines"].append(line)
|
|
278
341
|
i += 1
|
|
279
342
|
continue
|
|
@@ -282,15 +345,20 @@ def _split_directives(source: str) -> list[dict]:
|
|
|
282
345
|
m_close = _DIRECTIVE_CLOSE.match(line)
|
|
283
346
|
if m_close and not stack:
|
|
284
347
|
if not _any_self_closing_before(lines, i):
|
|
348
|
+
close_colons = m_close.group(1)
|
|
285
349
|
raise DirectiveError(
|
|
286
|
-
f"line {i + 1}: stray '
|
|
350
|
+
f"line {i + 1}: stray '{close_colons}' closer with no open directive"
|
|
287
351
|
)
|
|
288
352
|
# Stray closer after a self-closing directive like divider — skip
|
|
289
353
|
i += 1
|
|
290
354
|
continue
|
|
291
355
|
if m_close and stack:
|
|
292
|
-
|
|
293
|
-
|
|
356
|
+
close_colon_count = len(m_close.group(1))
|
|
357
|
+
if close_colon_count != stack[-1]["colon_count"]:
|
|
358
|
+
# Different colon count — treat as body content
|
|
359
|
+
stack[-1]["body_lines"].append(line)
|
|
360
|
+
elif stack[-1]["depth"] > 1:
|
|
361
|
+
# Closing a nested directive with same colon count
|
|
294
362
|
stack[-1]["depth"] -= 1
|
|
295
363
|
stack[-1]["body_lines"].append(line)
|
|
296
364
|
else:
|
|
@@ -322,10 +390,10 @@ def _split_directives(source: str) -> list[dict]:
|
|
|
322
390
|
# If stack is not empty, the source has unclosed directives
|
|
323
391
|
if stack:
|
|
324
392
|
unclosed = stack[-1]
|
|
325
|
-
|
|
393
|
+
colons = ":" * unclosed["colon_count"]
|
|
326
394
|
name = unclosed["name"]
|
|
327
395
|
raise DirectiveError(
|
|
328
|
-
f"unclosed directive '
|
|
396
|
+
f"unclosed directive '{colons}{name}' — missing closing '{colons}'"
|
|
329
397
|
)
|
|
330
398
|
|
|
331
399
|
return segments
|
|
@@ -336,6 +404,12 @@ _MAX_PARSE_DEPTH = 50
|
|
|
336
404
|
|
|
337
405
|
def _directive_to_block(name: str, attrs: dict[str, Any], body: str, _depth: int = 0) -> Block:
|
|
338
406
|
"""Convert a parsed directive into a Block."""
|
|
407
|
+
# Strip option lines from body; inline attrs take precedence over options
|
|
408
|
+
options, body = _strip_options(body)
|
|
409
|
+
for key, value in options.items():
|
|
410
|
+
if key not in attrs:
|
|
411
|
+
attrs[key] = value
|
|
412
|
+
|
|
339
413
|
block_type = _DIRECTIVE_TO_BLOCK.get(name, BlockType.PANEL)
|
|
340
414
|
|
|
341
415
|
# Tree and Code directives: store raw body, don't parse as markdown
|
|
@@ -368,7 +442,7 @@ def parse(source: str, _depth: int = 0) -> Block:
|
|
|
368
442
|
|
|
369
443
|
for seg in segments:
|
|
370
444
|
if seg["type"] == "markdown":
|
|
371
|
-
children.extend(_parse_markdown(seg["content"]))
|
|
445
|
+
children.extend(_parse_markdown(seg["content"], _depth=_depth))
|
|
372
446
|
else:
|
|
373
447
|
children.append(_directive_to_block(
|
|
374
448
|
seg["name"], seg["attrs"], seg["body"], _depth=_depth,
|
|
@@ -11,6 +11,7 @@ def render_box(
|
|
|
11
11
|
color: bool,
|
|
12
12
|
title: str | None = None,
|
|
13
13
|
border_color: str | None = None,
|
|
14
|
+
title_color: str | None = None,
|
|
14
15
|
dim: bool = False,
|
|
15
16
|
) -> list[str]:
|
|
16
17
|
"""Render content lines inside a box-drawing border.
|
|
@@ -21,6 +22,7 @@ def render_box(
|
|
|
21
22
|
color: Whether ANSI styling is enabled.
|
|
22
23
|
title: Optional title to display in the top border.
|
|
23
24
|
border_color: Color name for the border (used by panels).
|
|
25
|
+
title_color: Color for the title text (defaults to border_color).
|
|
24
26
|
dim: Whether to dim the border (used by code blocks).
|
|
25
27
|
"""
|
|
26
28
|
# Calculate border character widths dynamically
|
|
@@ -46,11 +48,19 @@ def render_box(
|
|
|
46
48
|
title_visual = visual_len(title_part)
|
|
47
49
|
remaining = inner_w - title_visual
|
|
48
50
|
fill_count = max(0, remaining // dash_v)
|
|
49
|
-
|
|
51
|
+
if title_color and color:
|
|
52
|
+
# Style title text separately from border chrome
|
|
53
|
+
styled_title = style(title, color=title_color, bold=True)
|
|
54
|
+
border_prefix = style("┌─ ", **style_kw)
|
|
55
|
+
border_suffix = style(" " + "─" * fill_count + "┐", **style_kw)
|
|
56
|
+
top = border_prefix + styled_title + border_suffix
|
|
57
|
+
else:
|
|
58
|
+
top_raw = "┌" + title_part + "─" * fill_count + "┐"
|
|
59
|
+
top = style(top_raw, **style_kw)
|
|
50
60
|
else:
|
|
51
61
|
fill_count = max(0, inner_w // dash_v)
|
|
52
62
|
top_raw = "┌" + "─" * fill_count + "┐"
|
|
53
|
-
|
|
63
|
+
top = style(top_raw, **style_kw)
|
|
54
64
|
top = visual_ljust(top, width)
|
|
55
65
|
|
|
56
66
|
# Bottom border
|
|
@@ -12,9 +12,21 @@ def render(
|
|
|
12
12
|
block: Block, color: bool, render_child: Callable[[Block, bool], list[str]]
|
|
13
13
|
) -> list[str]:
|
|
14
14
|
"""Render a panel block with box-drawing borders."""
|
|
15
|
-
|
|
15
|
+
explicit_color = block.attrs.get("color")
|
|
16
16
|
title = block.attrs.get("title")
|
|
17
17
|
|
|
18
|
+
# Gloam-inspired defaults: dim gray borders, yellow bold title
|
|
19
|
+
# Explicit color= attr overrides both (border + title match)
|
|
20
|
+
if explicit_color:
|
|
21
|
+
border_color = explicit_color
|
|
22
|
+
title_color = None # title inherits border color
|
|
23
|
+
elif color:
|
|
24
|
+
border_color = None # dim only (gray)
|
|
25
|
+
title_color = "yellow"
|
|
26
|
+
else:
|
|
27
|
+
border_color = None
|
|
28
|
+
title_color = None
|
|
29
|
+
|
|
18
30
|
# Render children content
|
|
19
31
|
content_lines: list[str] = []
|
|
20
32
|
for child in block.children:
|
|
@@ -26,6 +38,8 @@ def render(
|
|
|
26
38
|
color=color,
|
|
27
39
|
title=title,
|
|
28
40
|
border_color=border_color,
|
|
41
|
+
title_color=title_color,
|
|
42
|
+
dim=color and not explicit_color,
|
|
29
43
|
)
|
|
30
44
|
|
|
31
45
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Table renderer for termrender."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from termrender.blocks import Block, InlineSpan
|
|
6
|
+
from termrender.style import style, visual_len, visual_ljust, visual_center, render_spans, wrap_text
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _align_cell(text: str, width: int, align: str | None) -> str:
|
|
10
|
+
if align == "center":
|
|
11
|
+
return visual_center(text, width)
|
|
12
|
+
elif align == "right":
|
|
13
|
+
vl = visual_len(text)
|
|
14
|
+
if vl < width:
|
|
15
|
+
return " " * (width - vl) + text
|
|
16
|
+
return text
|
|
17
|
+
else:
|
|
18
|
+
return visual_ljust(text, width)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _render_span_slice(
|
|
22
|
+
spans: list[InlineSpan], start: int, end: int, color: bool
|
|
23
|
+
) -> str:
|
|
24
|
+
"""Render the portion of spans covering character range [start, end)."""
|
|
25
|
+
parts: list[str] = []
|
|
26
|
+
offset = 0
|
|
27
|
+
for span in spans:
|
|
28
|
+
span_start = offset
|
|
29
|
+
span_end = offset + len(span.text)
|
|
30
|
+
overlap_start = max(span_start, start)
|
|
31
|
+
overlap_end = min(span_end, end)
|
|
32
|
+
if overlap_start < overlap_end:
|
|
33
|
+
slice_text = span.text[overlap_start - span_start : overlap_end - span_start]
|
|
34
|
+
if span.code:
|
|
35
|
+
slice_text = style(slice_text, color="cyan", enabled=color)
|
|
36
|
+
elif span.bold or span.italic:
|
|
37
|
+
slice_text = style(slice_text, bold=span.bold, italic=span.italic, enabled=color)
|
|
38
|
+
parts.append(slice_text)
|
|
39
|
+
offset = span_end
|
|
40
|
+
if offset >= end:
|
|
41
|
+
break
|
|
42
|
+
return "".join(parts)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _wrap_cell_colored(
|
|
46
|
+
spans: list[InlineSpan], width: int, color: bool
|
|
47
|
+
) -> list[str]:
|
|
48
|
+
"""Wrap cell content to width, preserving inline styling."""
|
|
49
|
+
plain = render_spans(spans, False)
|
|
50
|
+
wrapped = wrap_text(plain, max(width, 1))
|
|
51
|
+
if not color:
|
|
52
|
+
return wrapped
|
|
53
|
+
|
|
54
|
+
lines: list[str] = []
|
|
55
|
+
offset = 0
|
|
56
|
+
for raw_line in wrapped:
|
|
57
|
+
line_len = len(raw_line)
|
|
58
|
+
styled = _render_span_slice(spans, offset, offset + line_len, color)
|
|
59
|
+
lines.append(styled)
|
|
60
|
+
offset += line_len
|
|
61
|
+
if offset < len(plain) and plain[offset] == " ":
|
|
62
|
+
offset += 1
|
|
63
|
+
return lines
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def render(block: Block, color: bool) -> list[str]:
|
|
67
|
+
headers: list[list] = block.attrs.get("headers", [])
|
|
68
|
+
rows: list[list[list]] = block.attrs.get("rows", [])
|
|
69
|
+
aligns: list[str | None] = block.attrs.get("aligns", [])
|
|
70
|
+
w = block.width or 80
|
|
71
|
+
|
|
72
|
+
# Default theme: blue borders, yellow headers on dim_blue bg (gloam-inspired)
|
|
73
|
+
border_color = block.attrs.get("border_color", "blue")
|
|
74
|
+
header_fg = block.attrs.get("header_color", "yellow")
|
|
75
|
+
header_bg = block.attrs.get("header_bg", "dim_blue")
|
|
76
|
+
|
|
77
|
+
def border(text: str) -> str:
|
|
78
|
+
"""Style border characters."""
|
|
79
|
+
return style(text, color=border_color, dim=True, enabled=color)
|
|
80
|
+
|
|
81
|
+
n_cols = max(len(headers), max((len(r) for r in rows), default=0))
|
|
82
|
+
if n_cols == 0:
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
# Render all cells to plain text for width calculation
|
|
86
|
+
rendered_headers = [render_spans(headers[i], False) if i < len(headers) else "" for i in range(n_cols)]
|
|
87
|
+
rendered_rows = [
|
|
88
|
+
[render_spans(row[i], False) if i < len(row) else "" for i in range(n_cols)]
|
|
89
|
+
for row in rows
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
# Calculate natural column widths (minimum 3)
|
|
93
|
+
col_widths = [
|
|
94
|
+
max(3, visual_len(rendered_headers[i]), *(visual_len(r[i]) for r in rendered_rows))
|
|
95
|
+
for i in range(n_cols)
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
# Total width: borders + padding (each cell has 1 space padding on each side)
|
|
99
|
+
# Layout: │ cell │ cell │ => n_cols + 1 borders + n_cols * 2 padding
|
|
100
|
+
total = sum(col_widths) + n_cols * 2 + (n_cols + 1)
|
|
101
|
+
|
|
102
|
+
# Distribute proportionally if overflow
|
|
103
|
+
if total > w:
|
|
104
|
+
available = w - n_cols * 2 - (n_cols + 1)
|
|
105
|
+
available = max(available, n_cols * 3)
|
|
106
|
+
total_natural = sum(col_widths)
|
|
107
|
+
if total_natural > 0:
|
|
108
|
+
col_widths = [
|
|
109
|
+
max(3, round(cw / total_natural * available))
|
|
110
|
+
for cw in col_widths
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
# Wrap cell content to column widths with inline styling
|
|
114
|
+
header_spans = [headers[i] if i < len(headers) else [] for i in range(n_cols)]
|
|
115
|
+
wrapped_headers = [_wrap_cell_colored(header_spans[i], col_widths[i], color) for i in range(n_cols)]
|
|
116
|
+
|
|
117
|
+
row_spans = [
|
|
118
|
+
[row[i] if i < len(row) else [] for i in range(n_cols)]
|
|
119
|
+
for row in rows
|
|
120
|
+
]
|
|
121
|
+
wrapped_rows = [
|
|
122
|
+
[_wrap_cell_colored(rs[i], col_widths[i], color) for i in range(n_cols)]
|
|
123
|
+
for rs in row_spans
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
def render_multiline_row(wrapped_cells: list[list[str]], is_header: bool) -> list[str]:
|
|
127
|
+
row_height = max((len(c) for c in wrapped_cells), default=1)
|
|
128
|
+
out: list[str] = []
|
|
129
|
+
for line_idx in range(row_height):
|
|
130
|
+
parts: list[str] = []
|
|
131
|
+
for i, cell_lines in enumerate(wrapped_cells):
|
|
132
|
+
text = cell_lines[line_idx] if line_idx < len(cell_lines) else ""
|
|
133
|
+
align = aligns[i] if i < len(aligns) else None
|
|
134
|
+
padded = _align_cell(text, col_widths[i], align)
|
|
135
|
+
if is_header and color:
|
|
136
|
+
padded = style(" " + padded + " ", bold=True, color=header_fg, bg=header_bg)
|
|
137
|
+
else:
|
|
138
|
+
padded = " " + padded + " "
|
|
139
|
+
parts.append(padded)
|
|
140
|
+
line = border("│") + border("│").join(parts) + border("│")
|
|
141
|
+
out.append(visual_ljust(line, w))
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
def separator(left: str, mid: str, right: str) -> str:
|
|
145
|
+
segs = ["─" * (col_widths[i] + 2) for i in range(n_cols)]
|
|
146
|
+
raw = left + mid.join(segs) + right
|
|
147
|
+
line = style(raw, color=border_color, dim=True, enabled=color)
|
|
148
|
+
return visual_ljust(line, w)
|
|
149
|
+
|
|
150
|
+
lines: list[str] = []
|
|
151
|
+
lines.append(separator("┌", "┬", "┐"))
|
|
152
|
+
lines.extend(render_multiline_row(wrapped_headers, is_header=True))
|
|
153
|
+
lines.append(separator("├", "┼", "┤"))
|
|
154
|
+
for idx, wr in enumerate(wrapped_rows):
|
|
155
|
+
if idx > 0:
|
|
156
|
+
lines.append(separator("├", "┼", "┤"))
|
|
157
|
+
lines.extend(render_multiline_row(wr, is_header=False))
|
|
158
|
+
lines.append(separator("└", "┴", "┘"))
|
|
159
|
+
|
|
160
|
+
return lines
|
|
@@ -57,7 +57,7 @@ def _render_span_slice(
|
|
|
57
57
|
if overlap_start < overlap_end:
|
|
58
58
|
slice_text = span.text[overlap_start - span_start : overlap_end - span_start]
|
|
59
59
|
if span.code:
|
|
60
|
-
slice_text = style(slice_text,
|
|
60
|
+
slice_text = style(slice_text, color="cyan", enabled=color)
|
|
61
61
|
elif span.bold or span.italic:
|
|
62
62
|
slice_text = style(slice_text, bold=span.bold, italic=span.italic, enabled=color)
|
|
63
63
|
parts.append(slice_text)
|
|
@@ -67,16 +67,27 @@ def _render_span_slice(
|
|
|
67
67
|
return "".join(parts)
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
# Gloam-inspired heading colors: colored fg + dim tinted bg, by depth
|
|
71
|
+
_HEADING_STYLES: dict[int, dict[str, str]] = {
|
|
72
|
+
1: {"color": "yellow", "bg": "dim_yellow"},
|
|
73
|
+
2: {"color": "green", "bg": "dim_green"},
|
|
74
|
+
3: {"color": "cyan", "bg": "dim_cyan"},
|
|
75
|
+
4: {"color": "blue", "bg": "dim_blue"},
|
|
76
|
+
5: {"color": "magenta", "bg": "dim_magenta"},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
70
80
|
def _render_heading(block: Block, color: bool) -> list[str]:
|
|
71
81
|
level = block.attrs.get("level", 1)
|
|
72
82
|
text = render_spans(block.text, color=False) # plain text first
|
|
73
83
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
84
|
+
heading_style = _HEADING_STYLES.get(level)
|
|
85
|
+
if heading_style and color:
|
|
86
|
+
# Pad text to full width BEFORE styling so bg extends across the line
|
|
87
|
+
padded = visual_ljust(text, block.width)
|
|
88
|
+
styled = style(padded, bold=True, enabled=True, **heading_style)
|
|
89
|
+
elif level <= 2:
|
|
77
90
|
styled = style(text, bold=True, enabled=color)
|
|
78
|
-
elif level == 3:
|
|
79
|
-
styled = style(text, italic=True, enabled=color)
|
|
80
91
|
else:
|
|
81
92
|
styled = style(text, dim=True, enabled=color)
|
|
82
93
|
|
|
@@ -97,6 +97,24 @@ COLOR_MAP: dict[str, str] = {
|
|
|
97
97
|
'gray': '\x1b[90m',
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
BG_COLOR_MAP: dict[str, str] = {
|
|
101
|
+
'red': '\x1b[41m',
|
|
102
|
+
'green': '\x1b[42m',
|
|
103
|
+
'yellow': '\x1b[43m',
|
|
104
|
+
'blue': '\x1b[44m',
|
|
105
|
+
'magenta': '\x1b[45m',
|
|
106
|
+
'cyan': '\x1b[46m',
|
|
107
|
+
'white': '\x1b[47m',
|
|
108
|
+
'gray': '\x1b[100m',
|
|
109
|
+
# Dim background variants — use bright-black (dark gray) range
|
|
110
|
+
'dim_red': '\x1b[48;5;52m',
|
|
111
|
+
'dim_green': '\x1b[48;5;22m',
|
|
112
|
+
'dim_yellow': '\x1b[48;5;58m',
|
|
113
|
+
'dim_blue': '\x1b[48;5;17m',
|
|
114
|
+
'dim_magenta': '\x1b[48;5;53m',
|
|
115
|
+
'dim_cyan': '\x1b[48;5;23m',
|
|
116
|
+
}
|
|
117
|
+
|
|
100
118
|
|
|
101
119
|
def resolve_color(name: str | None) -> str:
|
|
102
120
|
if name is None:
|
|
@@ -104,9 +122,16 @@ def resolve_color(name: str | None) -> str:
|
|
|
104
122
|
return COLOR_MAP.get(name, '')
|
|
105
123
|
|
|
106
124
|
|
|
125
|
+
def resolve_bg_color(name: str | None) -> str:
|
|
126
|
+
if name is None:
|
|
127
|
+
return ''
|
|
128
|
+
return BG_COLOR_MAP.get(name, '')
|
|
129
|
+
|
|
130
|
+
|
|
107
131
|
def style(
|
|
108
132
|
text: str,
|
|
109
133
|
color: str | None = None,
|
|
134
|
+
bg: str | None = None,
|
|
110
135
|
bold: bool = False,
|
|
111
136
|
italic: bool = False,
|
|
112
137
|
dim: bool = False,
|
|
@@ -115,6 +140,7 @@ def style(
|
|
|
115
140
|
if not enabled:
|
|
116
141
|
return text
|
|
117
142
|
prefix = resolve_color(color)
|
|
143
|
+
prefix += resolve_bg_color(bg)
|
|
118
144
|
if bold:
|
|
119
145
|
prefix += BOLD
|
|
120
146
|
if italic:
|
|
@@ -246,7 +272,7 @@ def render_spans(spans: list[InlineSpan], color: bool) -> str:
|
|
|
246
272
|
for span in spans:
|
|
247
273
|
text = span.text
|
|
248
274
|
if span.code:
|
|
249
|
-
text = style(text,
|
|
275
|
+
text = style(text, color="cyan", enabled=color)
|
|
250
276
|
elif span.bold or span.italic:
|
|
251
277
|
text = style(text, bold=span.bold, italic=span.italic, enabled=color)
|
|
252
278
|
parts.append(text)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Tests for MyST Markdown syntax support in the parser."""
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from termrender.blocks import BlockType
|
|
6
|
+
from termrender.parser import parse, _strip_options
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestBacktickFenceDirective(unittest.TestCase):
|
|
10
|
+
"""Feature 1: Backtick fence directive syntax."""
|
|
11
|
+
|
|
12
|
+
def test_basic_backtick_fence_directive(self):
|
|
13
|
+
"""```{panel}\ncontent\n``` → BlockType.PANEL"""
|
|
14
|
+
doc = parse("```{panel}\ncontent\n```")
|
|
15
|
+
self.assertEqual(len(doc.children), 1)
|
|
16
|
+
self.assertEqual(doc.children[0].type, BlockType.PANEL)
|
|
17
|
+
|
|
18
|
+
def test_backtick_fence_with_option_lines(self):
|
|
19
|
+
"""```{panel}\n:title: Hello\ncontent\n``` → panel with title attr"""
|
|
20
|
+
doc = parse("```{panel}\n:title: Hello\ncontent\n```")
|
|
21
|
+
panel = doc.children[0]
|
|
22
|
+
self.assertEqual(panel.type, BlockType.PANEL)
|
|
23
|
+
self.assertEqual(panel.attrs["title"], "Hello")
|
|
24
|
+
|
|
25
|
+
def test_backtick_mermaid_directive(self):
|
|
26
|
+
"""```{mermaid}\ngraph LR\nA-->B\n``` → BlockType.MERMAID"""
|
|
27
|
+
doc = parse("```{mermaid}\ngraph LR\nA-->B\n```")
|
|
28
|
+
self.assertEqual(len(doc.children), 1)
|
|
29
|
+
block = doc.children[0]
|
|
30
|
+
self.assertEqual(block.type, BlockType.MERMAID)
|
|
31
|
+
self.assertIn("graph LR", block.attrs["source"])
|
|
32
|
+
|
|
33
|
+
def test_bare_mermaid_still_works(self):
|
|
34
|
+
"""```mermaid\ngraph LR\n``` → BlockType.MERMAID (backward compat)"""
|
|
35
|
+
doc = parse("```mermaid\ngraph LR\nA-->B\n```")
|
|
36
|
+
self.assertEqual(doc.children[0].type, BlockType.MERMAID)
|
|
37
|
+
|
|
38
|
+
def test_empty_body_backtick_directive(self):
|
|
39
|
+
"""```{panel}\n``` → panel with no children"""
|
|
40
|
+
doc = parse("```{panel}\n```")
|
|
41
|
+
panel = doc.children[0]
|
|
42
|
+
self.assertEqual(panel.type, BlockType.PANEL)
|
|
43
|
+
|
|
44
|
+
def test_backtick_fence_with_argument(self):
|
|
45
|
+
"""```{code-block} python\nprint("hi")\n``` → attrs["argument"] = "python" """
|
|
46
|
+
doc = parse('```{code-block} python\nprint("hi")\n```')
|
|
47
|
+
block = doc.children[0]
|
|
48
|
+
self.assertEqual(block.attrs.get("argument"), "python")
|
|
49
|
+
|
|
50
|
+
def test_four_backtick_fence(self):
|
|
51
|
+
"""````{panel}\ncontent\n```` → works via mistune"""
|
|
52
|
+
doc = parse("````{panel}\ncontent\n````")
|
|
53
|
+
self.assertEqual(len(doc.children), 1)
|
|
54
|
+
self.assertEqual(doc.children[0].type, BlockType.PANEL)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestDirectiveOptionLines(unittest.TestCase):
|
|
58
|
+
"""Feature 2: Directive option lines."""
|
|
59
|
+
|
|
60
|
+
def test_colon_directive_with_options(self):
|
|
61
|
+
""":::panel\n:title: Hi\n:color: blue\ncontent\n::: → attrs have title and color"""
|
|
62
|
+
doc = parse(":::panel\n:title: Hi\n:color: blue\ncontent\n:::")
|
|
63
|
+
panel = doc.children[0]
|
|
64
|
+
self.assertEqual(panel.attrs["title"], "Hi")
|
|
65
|
+
self.assertEqual(panel.attrs["color"], "blue")
|
|
66
|
+
|
|
67
|
+
def test_options_dont_override_inline_attrs(self):
|
|
68
|
+
""":::panel{title="Inline"}\n:title: Option\n::: → title is "Inline" """
|
|
69
|
+
doc = parse(':::panel{title="Inline"}\n:title: Option\n:::')
|
|
70
|
+
panel = doc.children[0]
|
|
71
|
+
self.assertEqual(panel.attrs["title"], "Inline")
|
|
72
|
+
|
|
73
|
+
def test_body_after_options_preserved(self):
|
|
74
|
+
"""Body content after option lines is preserved correctly."""
|
|
75
|
+
doc = parse(":::panel\n:title: Hi\nHello world\n:::")
|
|
76
|
+
panel = doc.children[0]
|
|
77
|
+
self.assertEqual(panel.attrs["title"], "Hi")
|
|
78
|
+
# Body should have been parsed and contain a paragraph with "Hello world"
|
|
79
|
+
self.assertTrue(len(panel.children) > 0)
|
|
80
|
+
|
|
81
|
+
def test_non_option_lines_not_eaten(self):
|
|
82
|
+
""":not-an-option without trailing colon-space should not be eaten."""
|
|
83
|
+
doc = parse(":::panel\n:not-an-option\ncontent\n:::")
|
|
84
|
+
panel = doc.children[0]
|
|
85
|
+
# ":not-an-option" doesn't match `:key: value` pattern, so no options
|
|
86
|
+
self.assertNotIn("not-an-option", panel.attrs)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TestStripOptions(unittest.TestCase):
|
|
90
|
+
"""Unit tests for _strip_options."""
|
|
91
|
+
|
|
92
|
+
def test_empty_body(self):
|
|
93
|
+
opts, body = _strip_options("")
|
|
94
|
+
self.assertEqual(opts, {})
|
|
95
|
+
self.assertEqual(body, "")
|
|
96
|
+
|
|
97
|
+
def test_no_options(self):
|
|
98
|
+
opts, body = _strip_options("just content\nmore content")
|
|
99
|
+
self.assertEqual(opts, {})
|
|
100
|
+
self.assertEqual(body, "just content\nmore content")
|
|
101
|
+
|
|
102
|
+
def test_options_with_blank_lines(self):
|
|
103
|
+
opts, body = _strip_options(":title: Hi\n\n:color: blue\ncontent")
|
|
104
|
+
self.assertEqual(opts, {"title": "Hi", "color": "blue"})
|
|
105
|
+
self.assertEqual(body, "content")
|
|
106
|
+
|
|
107
|
+
def test_option_key_with_hyphens(self):
|
|
108
|
+
opts, body = _strip_options(":my-key: value\ncontent")
|
|
109
|
+
self.assertEqual(opts, {"my-key": "value"})
|
|
110
|
+
self.assertEqual(body, "content")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
unittest.main()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from termrender.parser import parse, DirectiveError
|
|
4
|
+
from termrender.blocks import BlockType
|
|
5
|
+
from termrender import render
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestVariableColons(unittest.TestCase):
|
|
9
|
+
"""Tests for variable colon counts (3+) in directive markers."""
|
|
10
|
+
|
|
11
|
+
def test_basic_four_colon_directive(self):
|
|
12
|
+
"""::::panel parses correctly with 4 colons."""
|
|
13
|
+
doc = parse("::::panel\ncontent\n::::")
|
|
14
|
+
self.assertEqual(len(doc.children), 1)
|
|
15
|
+
self.assertEqual(doc.children[0].type, BlockType.PANEL)
|
|
16
|
+
|
|
17
|
+
def test_five_colon_directive(self):
|
|
18
|
+
""":::::panel parses correctly with 5 colons."""
|
|
19
|
+
doc = parse(":::::panel\ncontent\n:::::")
|
|
20
|
+
self.assertEqual(len(doc.children), 1)
|
|
21
|
+
self.assertEqual(doc.children[0].type, BlockType.PANEL)
|
|
22
|
+
|
|
23
|
+
def test_mixed_nesting(self):
|
|
24
|
+
"""Outer ::::panel containing inner :::col — both parse correctly."""
|
|
25
|
+
source = (
|
|
26
|
+
"::::panel\n"
|
|
27
|
+
":::col\n"
|
|
28
|
+
"hello\n"
|
|
29
|
+
":::\n"
|
|
30
|
+
"::::"
|
|
31
|
+
)
|
|
32
|
+
doc = parse(source)
|
|
33
|
+
self.assertEqual(len(doc.children), 1)
|
|
34
|
+
panel = doc.children[0]
|
|
35
|
+
self.assertEqual(panel.type, BlockType.PANEL)
|
|
36
|
+
# The inner :::col should be parsed recursively
|
|
37
|
+
col_children = [c for c in panel.children if c.type == BlockType.COL]
|
|
38
|
+
self.assertEqual(len(col_children), 1)
|
|
39
|
+
|
|
40
|
+
def test_same_colon_nesting_backward_compat(self):
|
|
41
|
+
"""Existing :::panel > :::col nesting still works via depth counter."""
|
|
42
|
+
source = (
|
|
43
|
+
":::panel\n"
|
|
44
|
+
":::col\n"
|
|
45
|
+
"inner\n"
|
|
46
|
+
":::\n"
|
|
47
|
+
":::"
|
|
48
|
+
)
|
|
49
|
+
doc = parse(source)
|
|
50
|
+
self.assertEqual(len(doc.children), 1)
|
|
51
|
+
panel = doc.children[0]
|
|
52
|
+
self.assertEqual(panel.type, BlockType.PANEL)
|
|
53
|
+
col_children = [c for c in panel.children if c.type == BlockType.COL]
|
|
54
|
+
self.assertEqual(len(col_children), 1)
|
|
55
|
+
|
|
56
|
+
def test_closer_mismatch_raises_error(self):
|
|
57
|
+
"""::::panel closed by ::: raises DirectiveError."""
|
|
58
|
+
source = "::::panel\ncontent\n:::"
|
|
59
|
+
with self.assertRaises(DirectiveError) as ctx:
|
|
60
|
+
parse(source)
|
|
61
|
+
self.assertIn("::::panel", str(ctx.exception))
|
|
62
|
+
self.assertIn("::::", str(ctx.exception))
|
|
63
|
+
|
|
64
|
+
def test_self_closing_with_variable_colons(self):
|
|
65
|
+
"""::::divider{label="x"} works as self-closing."""
|
|
66
|
+
doc = parse('::::divider{label="x"}')
|
|
67
|
+
self.assertEqual(len(doc.children), 1)
|
|
68
|
+
self.assertEqual(doc.children[0].type, BlockType.DIVIDER)
|
|
69
|
+
self.assertEqual(doc.children[0].attrs.get("label"), "x")
|
|
70
|
+
|
|
71
|
+
def test_self_closing_with_explicit_closer(self):
|
|
72
|
+
"""::::divider{label="x"} followed by :::: is fine."""
|
|
73
|
+
doc = parse('::::divider{label="x"}\n::::')
|
|
74
|
+
dividers = [c for c in doc.children if c.type == BlockType.DIVIDER]
|
|
75
|
+
self.assertEqual(len(dividers), 1)
|
|
76
|
+
|
|
77
|
+
def test_deep_nesting_different_colon_counts(self):
|
|
78
|
+
"""4+ levels each with different colon counts."""
|
|
79
|
+
source = (
|
|
80
|
+
"::::::panel{title=\"outer\"}\n"
|
|
81
|
+
":::::panel{title=\"mid\"}\n"
|
|
82
|
+
"::::panel{title=\"inner\"}\n"
|
|
83
|
+
":::callout{type=\"info\"}\n"
|
|
84
|
+
"deep content\n"
|
|
85
|
+
":::\n"
|
|
86
|
+
"::::\n"
|
|
87
|
+
":::::\n"
|
|
88
|
+
"::::::"
|
|
89
|
+
)
|
|
90
|
+
doc = parse(source)
|
|
91
|
+
self.assertEqual(len(doc.children), 1)
|
|
92
|
+
outer = doc.children[0]
|
|
93
|
+
self.assertEqual(outer.type, BlockType.PANEL)
|
|
94
|
+
self.assertEqual(outer.attrs.get("title"), "outer")
|
|
95
|
+
|
|
96
|
+
# Drill down
|
|
97
|
+
mid = [c for c in outer.children if c.type == BlockType.PANEL]
|
|
98
|
+
self.assertEqual(len(mid), 1)
|
|
99
|
+
self.assertEqual(mid[0].attrs.get("title"), "mid")
|
|
100
|
+
|
|
101
|
+
inner = [c for c in mid[0].children if c.type == BlockType.PANEL]
|
|
102
|
+
self.assertEqual(len(inner), 1)
|
|
103
|
+
self.assertEqual(inner[0].attrs.get("title"), "inner")
|
|
104
|
+
|
|
105
|
+
callout = [c for c in inner[0].children if c.type == BlockType.CALLOUT]
|
|
106
|
+
self.assertEqual(len(callout), 1)
|
|
107
|
+
|
|
108
|
+
def test_render_integration_four_colons(self):
|
|
109
|
+
"""::::panel renders identically to :::panel."""
|
|
110
|
+
source_3 = ":::panel{title=\"Test\"}\ncontent\n:::"
|
|
111
|
+
source_4 = "::::panel{title=\"Test\"}\ncontent\n::::"
|
|
112
|
+
output_3 = render(source_3, width=60, color=False)
|
|
113
|
+
output_4 = render(source_4, width=60, color=False)
|
|
114
|
+
self.assertEqual(output_3, output_4)
|
|
115
|
+
|
|
116
|
+
def test_three_colon_backward_compat(self):
|
|
117
|
+
"""Existing ::: syntax is fully backward compatible."""
|
|
118
|
+
source = (
|
|
119
|
+
":::panel{title=\"Hello\"}\n"
|
|
120
|
+
"Some **bold** text\n"
|
|
121
|
+
":::"
|
|
122
|
+
)
|
|
123
|
+
doc = parse(source)
|
|
124
|
+
self.assertEqual(len(doc.children), 1)
|
|
125
|
+
self.assertEqual(doc.children[0].type, BlockType.PANEL)
|
|
126
|
+
self.assertEqual(doc.children[0].attrs.get("title"), "Hello")
|
|
127
|
+
|
|
128
|
+
def test_four_colon_with_attrs(self):
|
|
129
|
+
"""::::panel{title="X" color="red"} parses attrs correctly."""
|
|
130
|
+
doc = parse('::::panel{title="X" color="red"}\nbody\n::::')
|
|
131
|
+
panel = doc.children[0]
|
|
132
|
+
self.assertEqual(panel.attrs["title"], "X")
|
|
133
|
+
self.assertEqual(panel.attrs["color"], "red")
|
|
134
|
+
|
|
135
|
+
def test_stray_closer_different_colons(self):
|
|
136
|
+
"""Stray :::: closer with no open directive raises DirectiveError."""
|
|
137
|
+
with self.assertRaises(DirectiveError) as ctx:
|
|
138
|
+
parse("::::")
|
|
139
|
+
self.assertIn("::::", str(ctx.exception))
|
|
140
|
+
|
|
141
|
+
def test_mixed_nesting_columns(self):
|
|
142
|
+
"""Real-world pattern: ::::columns with :::col children."""
|
|
143
|
+
source = (
|
|
144
|
+
"::::columns\n"
|
|
145
|
+
":::col{width=\"50%\"}\n"
|
|
146
|
+
"left side\n"
|
|
147
|
+
":::\n"
|
|
148
|
+
":::col{width=\"50%\"}\n"
|
|
149
|
+
"right side\n"
|
|
150
|
+
":::\n"
|
|
151
|
+
"::::"
|
|
152
|
+
)
|
|
153
|
+
doc = parse(source)
|
|
154
|
+
self.assertEqual(len(doc.children), 1)
|
|
155
|
+
columns = doc.children[0]
|
|
156
|
+
self.assertEqual(columns.type, BlockType.COLUMNS)
|
|
157
|
+
cols = [c for c in columns.children if c.type == BlockType.COL]
|
|
158
|
+
self.assertEqual(len(cols), 2)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
unittest.main()
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
"""Table renderer for termrender."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from termrender.blocks import Block
|
|
6
|
-
from termrender.style import style, visual_len, visual_ljust, visual_center, render_spans, wrap_text
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def _align_cell(text: str, width: int, align: str | None) -> str:
|
|
10
|
-
if align == "center":
|
|
11
|
-
return visual_center(text, width)
|
|
12
|
-
elif align == "right":
|
|
13
|
-
vl = visual_len(text)
|
|
14
|
-
if vl < width:
|
|
15
|
-
return " " * (width - vl) + text
|
|
16
|
-
return text
|
|
17
|
-
else:
|
|
18
|
-
return visual_ljust(text, width)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def render(block: Block, color: bool) -> list[str]:
|
|
22
|
-
headers: list[list] = block.attrs.get("headers", [])
|
|
23
|
-
rows: list[list[list]] = block.attrs.get("rows", [])
|
|
24
|
-
aligns: list[str | None] = block.attrs.get("aligns", [])
|
|
25
|
-
w = block.width or 80
|
|
26
|
-
|
|
27
|
-
n_cols = max(len(headers), max((len(r) for r in rows), default=0))
|
|
28
|
-
if n_cols == 0:
|
|
29
|
-
return []
|
|
30
|
-
|
|
31
|
-
# Render all cells to plain text for width calculation
|
|
32
|
-
rendered_headers = [render_spans(headers[i], False) if i < len(headers) else "" for i in range(n_cols)]
|
|
33
|
-
rendered_rows = [
|
|
34
|
-
[render_spans(row[i], False) if i < len(row) else "" for i in range(n_cols)]
|
|
35
|
-
for row in rows
|
|
36
|
-
]
|
|
37
|
-
|
|
38
|
-
# Calculate natural column widths (minimum 3)
|
|
39
|
-
col_widths = [
|
|
40
|
-
max(3, visual_len(rendered_headers[i]), *(visual_len(r[i]) for r in rendered_rows))
|
|
41
|
-
for i in range(n_cols)
|
|
42
|
-
]
|
|
43
|
-
|
|
44
|
-
# Total width: borders + padding (each cell has 1 space padding on each side)
|
|
45
|
-
# Layout: │ cell │ cell │ => n_cols + 1 borders + n_cols * 2 padding
|
|
46
|
-
total = sum(col_widths) + n_cols * 2 + (n_cols + 1)
|
|
47
|
-
|
|
48
|
-
# Distribute proportionally if overflow
|
|
49
|
-
if total > w:
|
|
50
|
-
available = w - n_cols * 2 - (n_cols + 1)
|
|
51
|
-
available = max(available, n_cols * 3)
|
|
52
|
-
total_natural = sum(col_widths)
|
|
53
|
-
if total_natural > 0:
|
|
54
|
-
col_widths = [
|
|
55
|
-
max(3, round(cw / total_natural * available))
|
|
56
|
-
for cw in col_widths
|
|
57
|
-
]
|
|
58
|
-
|
|
59
|
-
# Wrap cell content to column widths
|
|
60
|
-
wrapped_headers = [wrap_text(rendered_headers[i], col_widths[i]) for i in range(n_cols)]
|
|
61
|
-
wrapped_rows = [
|
|
62
|
-
[wrap_text(row[i], col_widths[i]) for i in range(n_cols)]
|
|
63
|
-
for row in rendered_rows
|
|
64
|
-
]
|
|
65
|
-
|
|
66
|
-
def render_multiline_row(wrapped_cells: list[list[str]], bold: bool) -> list[str]:
|
|
67
|
-
row_height = max((len(c) for c in wrapped_cells), default=1)
|
|
68
|
-
out: list[str] = []
|
|
69
|
-
for line_idx in range(row_height):
|
|
70
|
-
parts: list[str] = []
|
|
71
|
-
for i, cell_lines in enumerate(wrapped_cells):
|
|
72
|
-
text = cell_lines[line_idx] if line_idx < len(cell_lines) else ""
|
|
73
|
-
align = aligns[i] if i < len(aligns) else None
|
|
74
|
-
padded = _align_cell(text, col_widths[i], align)
|
|
75
|
-
if bold and color:
|
|
76
|
-
padded = style(padded, bold=True)
|
|
77
|
-
parts.append(" " + padded + " ")
|
|
78
|
-
line = "│" + "│".join(parts) + "│"
|
|
79
|
-
out.append(visual_ljust(line, w))
|
|
80
|
-
return out
|
|
81
|
-
|
|
82
|
-
def separator(left: str, mid: str, right: str) -> str:
|
|
83
|
-
segs = ["─" * (col_widths[i] + 2) for i in range(n_cols)]
|
|
84
|
-
line = left + mid.join(segs) + right
|
|
85
|
-
return visual_ljust(line, w)
|
|
86
|
-
|
|
87
|
-
lines: list[str] = []
|
|
88
|
-
lines.append(separator("┌", "┬", "┐"))
|
|
89
|
-
lines.extend(render_multiline_row(wrapped_headers, bold=True))
|
|
90
|
-
lines.append(separator("├", "┼", "┤"))
|
|
91
|
-
for wr in wrapped_rows:
|
|
92
|
-
lines.extend(render_multiline_row(wr, bold=False))
|
|
93
|
-
lines.append(separator("└", "┴", "┘"))
|
|
94
|
-
|
|
95
|
-
return 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
|
|
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
|
|
File without changes
|
|
File without changes
|