termrender 0.3.0__tar.gz → 0.4.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.
Files changed (36) hide show
  1. {termrender-0.3.0 → termrender-0.4.0}/.gitignore +3 -0
  2. {termrender-0.3.0 → termrender-0.4.0}/CHANGELOG.md +22 -0
  3. {termrender-0.3.0 → termrender-0.4.0}/PKG-INFO +1 -1
  4. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/CLAUDE.md +3 -1
  5. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/__main__.py +23 -2
  6. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/parser.py +97 -23
  7. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/renderers/borders.py +12 -2
  8. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/renderers/panel.py +15 -1
  9. termrender-0.4.0/src/termrender/renderers/table.py +158 -0
  10. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/renderers/text.py +17 -6
  11. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/style.py +27 -1
  12. termrender-0.4.0/tests/test_myst_gaps.py +114 -0
  13. termrender-0.4.0/tests/test_variable_colons.py +162 -0
  14. termrender-0.3.0/src/termrender/renderers/table.py +0 -95
  15. {termrender-0.3.0 → termrender-0.4.0}/.github/workflows/publish.yml +0 -0
  16. {termrender-0.3.0 → termrender-0.4.0}/CLAUDE.md +0 -0
  17. {termrender-0.3.0 → termrender-0.4.0}/LICENSE +0 -0
  18. {termrender-0.3.0 → termrender-0.4.0}/README.md +0 -0
  19. {termrender-0.3.0 → termrender-0.4.0}/design.json +0 -0
  20. {termrender-0.3.0 → termrender-0.4.0}/pyproject.toml +0 -0
  21. {termrender-0.3.0 → termrender-0.4.0}/requirements.json +0 -0
  22. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/__init__.py +0 -0
  23. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/blocks.py +0 -0
  24. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/emit.py +0 -0
  25. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/layout.py +0 -0
  26. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/py.typed +0 -0
  27. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/renderers/CLAUDE.md +0 -0
  28. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/renderers/__init__.py +0 -0
  29. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/renderers/code.py +0 -0
  30. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/renderers/columns.py +0 -0
  31. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/renderers/divider.py +0 -0
  32. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/renderers/mermaid.py +0 -0
  33. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/renderers/quote.py +0 -0
  34. {termrender-0.3.0 → termrender-0.4.0}/src/termrender/renderers/tree.py +0 -0
  35. {termrender-0.3.0 → termrender-0.4.0}/tests/__init__.py +0 -0
  36. {termrender-0.3.0 → termrender-0.4.0}/tests/test_column_alignment.py +0 -0
@@ -7,3 +7,6 @@ build/
7
7
  .eggs/
8
8
  *.egg
9
9
  .sisyphus/
10
+
11
+ # Sisyphus
12
+ .sisyphus
@@ -1,6 +1,28 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.4.0 (2026-04-05)
5
+
6
+ ### Features
7
+
8
+ - **parser**: Variable colon counts, backtick fence directives, and gloam-inspired theming
9
+ ([`47fac7f`](https://github.com/CaptainCrouton89/termrender/commit/47fac7fcf13d33e5d9986d3f9ca42ddaf5e7207d))
10
+
11
+ Parser changes: - Support 3+ colon openers/closers with stack-based matching - Backtick fence
12
+ directive syntax (```{name}) via mistune AST interception - Option line stripping (:key: value)
13
+ into directive attrs
14
+
15
+ CLI changes: - Syntax validation before tmux pane creation (no orphan panes on bad input) - TTY
16
+ auto-detect for color (disabled when piping, forced in tmux subprocess)
17
+
18
+ Theming (gloam-inspired defaults): - Headings: depth-based colored fg + dim tinted bg
19
+ (yellow→green→cyan→blue→magenta) - Inline code: cyan (aqua) - Panel borders: dim gray with yellow
20
+ bold titles - Table borders: blue dim, headers: yellow bold on dim-blue bg - Background color
21
+ support added to style()
22
+
23
+ 24 new tests across two test files.
24
+
25
+
4
26
  ## v0.3.0 (2026-04-05)
5
27
 
6
28
  ### Documentation
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 0.3.0
3
+ Version: 0.4.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 tracks nested directives of the same type 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.
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
- pane_cmd = " ".join(cmd_parts) + " | less -R; rm -f " + shlex.quote(tmpfile)
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
- output = render(source, width=args.width, color=not args.no_color)
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}",
@@ -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 :::name{attrs}
39
+ # Directive opener: :::name or ::::name etc. (3+ colons)
40
40
  _DIRECTIVE_OPEN = re.compile(
41
- r"^:::(\w+)(?:\{([^}]*)\})?\s*$"
41
+ r"^(:{3,})(\w+)(?:\{([^}]*)\})?\s*$"
42
42
  )
43
- # Directive closer: exactly ::: on its own line
44
- _DIRECTIVE_CLOSE = re.compile(r"^:::\s*$")
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(1) in _SELF_CLOSING_DIRECTIVES:
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 _convert_ast(nodes: list[dict]) -> list[Block]:
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
- if info == "mermaid":
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": m_open.group(1),
260
- "attrs_raw": m_open.group(2),
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 ("divider",):
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 and include line in body
276
- stack[-1]["depth"] += 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 ':::' closer with no open directive"
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
- if stack[-1]["depth"] > 1:
293
- # Closing a nested directive
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
- # Find the line number where this directive was opened
393
+ colons = ":" * unclosed["colon_count"]
326
394
  name = unclosed["name"]
327
395
  raise DirectiveError(
328
- f"unclosed directive ':::{name}' — missing closing ':::'"
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
- top_raw = "┌" + title_part + "─" * fill_count + "┐"
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
- top = style(top_raw, **style_kw)
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
- border_color = block.attrs.get("color")
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,158 @@
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 wr in wrapped_rows:
155
+ lines.extend(render_multiline_row(wr, is_header=False))
156
+ lines.append(separator("└", "┴", "┘"))
157
+
158
+ 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, dim=True, enabled=color)
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
- if level == 1:
75
- styled = style(text, color="blue", bold=True, enabled=color)
76
- elif level == 2:
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, dim=True, enabled=color)
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