termrender 0.5.0__tar.gz → 0.6.1__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.5.0 → termrender-0.6.1}/CHANGELOG.md +72 -0
- {termrender-0.5.0 → termrender-0.6.1}/PKG-INFO +1 -1
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/CLAUDE.md +18 -4
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/__main__.py +130 -12
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/blocks.py +8 -0
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/emit.py +22 -1
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/layout.py +36 -1
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/parser.py +234 -18
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/borders.py +19 -0
- termrender-0.6.1/src/termrender/renderers/charts.py +141 -0
- termrender-0.6.1/src/termrender/renderers/diff.py +58 -0
- termrender-0.6.1/src/termrender/renderers/stat.py +81 -0
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/table.py +7 -0
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/text.py +25 -4
- termrender-0.6.1/src/termrender/renderers/timeline.py +45 -0
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/style.py +7 -0
- termrender-0.6.1/tests/test_charts.py +117 -0
- termrender-0.6.1/tests/test_column_alignment.py +213 -0
- termrender-0.6.1/tests/test_diff.py +58 -0
- termrender-0.6.1/tests/test_inline_badge.py +95 -0
- termrender-0.6.1/tests/test_stat.py +88 -0
- termrender-0.6.1/tests/test_tasklist.py +82 -0
- termrender-0.6.1/tests/test_timeline.py +74 -0
- {termrender-0.5.0 → termrender-0.6.1}/tests/test_variable_colons.py +0 -16
- termrender-0.5.0/tests/test_column_alignment.py +0 -138
- {termrender-0.5.0 → termrender-0.6.1}/.github/workflows/publish.yml +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/.gitignore +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/CLAUDE.md +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/LICENSE +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/README.md +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/design.json +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/pyproject.toml +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/requirements.json +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/__init__.py +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/py.typed +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/CLAUDE.md +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/__init__.py +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/code.py +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/columns.py +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/divider.py +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/mermaid.py +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/panel.py +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/quote.py +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/tree.py +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/tests/__init__.py +0 -0
- {termrender-0.5.0 → termrender-0.6.1}/tests/test_myst_gaps.py +0 -0
|
@@ -1,6 +1,78 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.6.1 (2026-04-08)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- **borders**: Grow render_box to fit overflowing content and titles
|
|
9
|
+
([`dc108c8`](https://github.com/CaptainCrouton89/termrender/commit/dc108c8242763828245569f719abce64b26ddf5b))
|
|
10
|
+
|
|
11
|
+
mermaid-ascii's --maxWidth is non-strict, so a child mermaid block can return lines wider than the
|
|
12
|
+
panel's allocated content area. Previously the side walls floated outward to accommodate the
|
|
13
|
+
content while the top/bottom borders stayed at the requested width, leaving corner glyphs one
|
|
14
|
+
column inside the side walls and producing a visibly jagged box.
|
|
15
|
+
|
|
16
|
+
render_box now measures the widest content line (and the title) and grows its effective width up
|
|
17
|
+
front so all four borders land at the same column. Trade-off: the box may overflow its parent
|
|
18
|
+
allocation, but the box itself is internally consistent.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## v0.6.0 (2026-04-07)
|
|
22
|
+
|
|
23
|
+
### Features
|
|
24
|
+
|
|
25
|
+
- Add diff, charts, stat, timeline, tasklist, and inline badges
|
|
26
|
+
([`e14f615`](https://github.com/CaptainCrouton89/termrender/commit/e14f615ae8d0723405db61c79b0f858d7bf0f863))
|
|
27
|
+
|
|
28
|
+
New block-level directives: - :::diff — colored unified diff with +/- gutters - :::bar — multi-bar
|
|
29
|
+
chart with sub-cell precision via eighth blocks - :::progress — single-line progress bar (auto
|
|
30
|
+
color by ratio) - :::gauge — three-line meter (auto color by load threshold) - :::stat — KPI tile
|
|
31
|
+
with label, value, trend arrow + delta, caption - :::timeline — vertical event list with bullet
|
|
32
|
+
markers and connectors - :::tasklist — checkbox list (also auto-detected from any markdown list
|
|
33
|
+
with [x]/[ ]/[!] markers)
|
|
34
|
+
|
|
35
|
+
New inline role: - :badge[text]{color=green} — colored pill, reuses new InlineSpan fg/bg fields so
|
|
36
|
+
future inline roles drop in trivially.
|
|
37
|
+
|
|
38
|
+
Cross-cutting changes: - InlineSpan gained fg/bg fields; render_spans and span-slicers in text.py
|
|
39
|
+
and table.py honor them. - _merge_plain_spans coalesces mistune's text fragments before role
|
|
40
|
+
expansion (mistune splits on `[`, which would otherwise break :badge[...]). - _render_list_item
|
|
41
|
+
now uses visual_len(prefix) so styled checkbox prefixes don't break indent math. - STAT joins
|
|
42
|
+
PANEL/CALLOUT/CODE in the border-aware width path. - progress and gauge added to
|
|
43
|
+
_SELF_CLOSING_DIRECTIVES (atomic, no body); stat requires an explicit closer so it can hold a
|
|
44
|
+
caption.
|
|
45
|
+
|
|
46
|
+
63 new tests across six test files. All 94 tests pass.
|
|
47
|
+
|
|
48
|
+
- **cli**: Add --watch mode for live re-rendering
|
|
49
|
+
([`4223ad8`](https://github.com/CaptainCrouton89/termrender/commit/4223ad86805b0b3ad45450bd7ca4441a668f0e23))
|
|
50
|
+
|
|
51
|
+
Re-renders the file whenever its mtime changes, with terminal-resize detection and inline error
|
|
52
|
+
display so the watcher survives malformed input. Uses the alternate screen buffer so Ctrl+C
|
|
53
|
+
cleanly restores the prior terminal state.
|
|
54
|
+
|
|
55
|
+
Composes with --tmux: --tmux --watch points the spawned pane at the real file path (skipping the
|
|
56
|
+
tempfile path) so the live loop runs inside the side pane.
|
|
57
|
+
|
|
58
|
+
### Refactoring
|
|
59
|
+
|
|
60
|
+
- **parser**: Require strictly more colons on outer fences
|
|
61
|
+
([`4a501d9`](https://github.com/CaptainCrouton89/termrender/commit/4a501d917db191f758874bb6c3d922c879a763be))
|
|
62
|
+
|
|
63
|
+
Drops the depth-counter that allowed `:::outer ... :::inner ... ::: ... :::` nesting with same colon
|
|
64
|
+
counts. Termrender now matches the standard followed by MyST, Pandoc fenced divs,
|
|
65
|
+
markdown-it-container, and CommonMark fenced code blocks: an opener can only nest inside another
|
|
66
|
+
directive if its colon count is strictly less than the outer's.
|
|
67
|
+
|
|
68
|
+
A closer with a non-matching colon count is treated as body content and falls through to the
|
|
69
|
+
recursive parse(), which is what makes nested directives work in the first place.
|
|
70
|
+
|
|
71
|
+
Fixtures in test_column_alignment.py rewritten to ascending colon counts (7/6/5/4/3 for the
|
|
72
|
+
showpiece, 5/4/3 for columns_tree, 4/3 for panel_tree). test_same_colon_nesting_backward_compat
|
|
73
|
+
deleted — its behavior is no longer supported.
|
|
74
|
+
|
|
75
|
+
|
|
4
76
|
## v0.5.0 (2026-04-06)
|
|
5
77
|
|
|
6
78
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termrender
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
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
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
`layout.py` imports `fix_mermaid_encoding` from `renderers/mermaid.py` — the only reverse dependency from layout into renderers. Reorganizing `renderers/` must account for this import. The two subprocess call sites differ: layout uses `check=True` (non-zero exit raises `CalledProcessError` → caught → raw source fallback); the renderer omits `check` (non-zero exit silently reads `stdout`, which may be empty or partial). See `renderers/CLAUDE.md` for encoding-fix details.
|
|
12
12
|
|
|
13
|
-
## Directive nesting:
|
|
13
|
+
## Directive nesting: outer must have more colons than inner
|
|
14
14
|
|
|
15
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
16
|
|
|
17
|
-
For colon directives,
|
|
17
|
+
For colon directives, closers are paired strictly by colon count. A closer whose colon count differs from the open directive's colon count is treated as body content; the recursive `parse()` call inside `_directive_to_block` re-parses it as an inner directive. This matches the standard rule used by MyST, Pandoc fenced divs, markdown-it-container, and CommonMark fenced code blocks: outer fences must use strictly more colons than the inner fences they wrap, making opener/closer pairing unambiguous.
|
|
18
18
|
|
|
19
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`).
|
|
20
20
|
|
|
@@ -50,9 +50,23 @@ Max recursion depth is 50; exceeding it raises `ValueError`, not `DirectiveError
|
|
|
50
50
|
|
|
51
51
|
## `--tmux`: exit code reflects pane creation, auto-sizing runs a full render, tempfiles leak
|
|
52
52
|
|
|
53
|
-
`__main__.py:
|
|
53
|
+
`__main__.py:357`: after `tmux split-window` succeeds the parent exits `EXIT_OK` immediately — the pane renders asynchronously. Render errors surface only inside the pane. `--check` is silently dropped with `--tmux` (exit at line 357 precedes the `--check` branch at line 368), even though `--tmux` now runs its own `parse()` call (lines 253–266) for fail-fast syntax validation before spawning the pane. The `--check` "ok" message and exit-code contract are not honoured.
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
Tempfile cleanup is embedded as `... | less -R; rm -f <tmpfile>` (line 339); `less` killed abnormally (SIGKILL, closed session) leaks `/tmp/termrender-*.md`. `--tmux --watch` skips tempfile creation entirely — the pane is pointed at the real file path, so no leak.
|
|
56
|
+
|
|
57
|
+
When `--width` is omitted, `--tmux` calls `render(source, width=80, color=False)` (lines 283–290) to measure content width — mermaid subprocesses fire and `TERMRENDER_CJK` mutations apply here. Any exception silently falls back to `pane_width=80`. The pane is capped to `tmux #{pane_width} - 10`; if the tmux query fails, no cap is applied. Minimum enforced pane width is 20.
|
|
58
|
+
|
|
59
|
+
## `--watch`: re-renders on resize as well as file change; errors are inline, not fatal
|
|
60
|
+
|
|
61
|
+
`_watch_loop` (lines 99–169) polls `os.path.getmtime` every 0.2 s and also re-renders when `shutil.get_terminal_size()` changes — so a terminal resize triggers a re-render even with no file edit. `width=None` is passed to `render()` each cycle, so auto-detection always uses current pane width.
|
|
62
|
+
|
|
63
|
+
The loop catches bare `Exception` (line 142) to keep the watcher alive across render errors; errors appear as a one-line message in the pane, not as an exit. `DirectiveError`, `TerminalError`, and `ValueError` (nesting depth) are each caught separately with distinct prefixes for that same reason — the watcher never exits on render failure, only on `KeyboardInterrupt`.
|
|
64
|
+
|
|
65
|
+
The alternate screen buffer (`\033[?1049h` / `\033[?1049l`) is entered on start and restored in a `finally` block, so Ctrl+C cleanly returns to the prior terminal state. `--watch` requires a FILE argument; stdin cannot be watched (polled by path, not fd).
|
|
66
|
+
|
|
67
|
+
## `TERMRENDER_COLOR=1` forces color when stdout is not a tty
|
|
68
|
+
|
|
69
|
+
`use_color` (lines 361–363 and 388–390) is `True` when stdout is a tty OR `TERMRENDER_COLOR == "1"`. The tmux pane command is prefixed with `TERMRENDER_COLOR=1` (lines 334, 337) so color survives the `| less -R` pipe. Setting this env var in scripts achieves the same effect — it overrides the tty check entirely.
|
|
56
70
|
|
|
57
71
|
## `_EMOJI_WIDE_RANGES` must stay sorted by codepoint
|
|
58
72
|
|
|
@@ -74,6 +74,8 @@ examples:
|
|
|
74
74
|
echo '# Hello' | termrender Quick inline render
|
|
75
75
|
|
|
76
76
|
termrender --tmux doc.md Render in a new tmux side pane
|
|
77
|
+
termrender --watch doc.md Live-render in current terminal
|
|
78
|
+
termrender --tmux --watch doc.md Live-render in a new tmux side pane
|
|
77
79
|
termrender <<'EOF'
|
|
78
80
|
:::panel{title="Status" color="green"}
|
|
79
81
|
- All systems operational
|
|
@@ -94,6 +96,79 @@ def _error(msg: str, *, fix: str | None = None, hint: str | None = None,
|
|
|
94
96
|
sys.exit(code)
|
|
95
97
|
|
|
96
98
|
|
|
99
|
+
def _watch_loop(file_path: str, *, color: bool, poll_interval: float = 0.2) -> None:
|
|
100
|
+
"""Re-render `file_path` whenever its mtime changes.
|
|
101
|
+
|
|
102
|
+
Uses the alternate screen buffer so the prior terminal state is restored
|
|
103
|
+
on exit. Width is re-detected from the current terminal each render so
|
|
104
|
+
pane/window resizes are picked up automatically. Render errors are shown
|
|
105
|
+
inline rather than crashing the watcher — fix the file and save again.
|
|
106
|
+
"""
|
|
107
|
+
import time
|
|
108
|
+
|
|
109
|
+
last_mtime: float | None = None
|
|
110
|
+
last_size: tuple[int, int] = (0, 0)
|
|
111
|
+
|
|
112
|
+
def _draw(body: str, status: str) -> None:
|
|
113
|
+
# \033[?25l hide cursor, \033[2J clear, \033[H home
|
|
114
|
+
sys.stdout.write("\033[?25l\033[2J\033[H")
|
|
115
|
+
sys.stdout.write(body)
|
|
116
|
+
if not body.endswith("\n"):
|
|
117
|
+
sys.stdout.write("\n")
|
|
118
|
+
# Status line at the bottom — dim if color is enabled
|
|
119
|
+
if color:
|
|
120
|
+
sys.stdout.write(f"\033[2m{status}\033[0m\n")
|
|
121
|
+
else:
|
|
122
|
+
sys.stdout.write(f"{status}\n")
|
|
123
|
+
sys.stdout.flush()
|
|
124
|
+
|
|
125
|
+
def _render_now() -> None:
|
|
126
|
+
try:
|
|
127
|
+
with open(file_path, "r") as f:
|
|
128
|
+
source = f.read()
|
|
129
|
+
except FileNotFoundError:
|
|
130
|
+
body = f"termrender: file not found: {file_path}\n"
|
|
131
|
+
except OSError as e:
|
|
132
|
+
body = f"termrender: cannot read {file_path}: {e}\n"
|
|
133
|
+
else:
|
|
134
|
+
try:
|
|
135
|
+
body = render(source, width=None, color=color)
|
|
136
|
+
except DirectiveError as e:
|
|
137
|
+
body = f"termrender: syntax error: {e}\n"
|
|
138
|
+
except TerminalError as e:
|
|
139
|
+
body = f"termrender: terminal error: {e}\n"
|
|
140
|
+
except ValueError as e:
|
|
141
|
+
body = f"termrender: nesting error: {e}\n"
|
|
142
|
+
except Exception as e: # noqa: BLE001 — keep watcher alive
|
|
143
|
+
body = f"termrender: render error: {e}\n"
|
|
144
|
+
_draw(body, f"watching {file_path} — Ctrl+C to exit")
|
|
145
|
+
|
|
146
|
+
# Enter alternate screen buffer
|
|
147
|
+
sys.stdout.write("\033[?1049h")
|
|
148
|
+
sys.stdout.flush()
|
|
149
|
+
try:
|
|
150
|
+
while True:
|
|
151
|
+
try:
|
|
152
|
+
mtime = os.path.getmtime(file_path)
|
|
153
|
+
except FileNotFoundError:
|
|
154
|
+
mtime = None
|
|
155
|
+
# Re-render on file change OR terminal resize
|
|
156
|
+
import shutil as _shutil
|
|
157
|
+
size = _shutil.get_terminal_size()
|
|
158
|
+
size_tuple = (size.columns, size.lines)
|
|
159
|
+
if mtime != last_mtime or size_tuple != last_size:
|
|
160
|
+
last_mtime = mtime
|
|
161
|
+
last_size = size_tuple
|
|
162
|
+
_render_now()
|
|
163
|
+
time.sleep(poll_interval)
|
|
164
|
+
except KeyboardInterrupt:
|
|
165
|
+
pass
|
|
166
|
+
finally:
|
|
167
|
+
# Show cursor, leave alternate screen buffer
|
|
168
|
+
sys.stdout.write("\033[?25h\033[?1049l")
|
|
169
|
+
sys.stdout.flush()
|
|
170
|
+
|
|
171
|
+
|
|
97
172
|
def main() -> None:
|
|
98
173
|
parser = argparse.ArgumentParser(
|
|
99
174
|
prog="termrender",
|
|
@@ -136,6 +211,11 @@ def main() -> None:
|
|
|
136
211
|
action="store_true",
|
|
137
212
|
help="open rendered output in a new tmux side pane (requires tmux)",
|
|
138
213
|
)
|
|
214
|
+
parser.add_argument(
|
|
215
|
+
"--watch",
|
|
216
|
+
action="store_true",
|
|
217
|
+
help="re-render whenever FILE changes on disk (requires a file argument)",
|
|
218
|
+
)
|
|
139
219
|
parser.add_argument(
|
|
140
220
|
"-V", "--version",
|
|
141
221
|
action="version",
|
|
@@ -143,6 +223,14 @@ def main() -> None:
|
|
|
143
223
|
)
|
|
144
224
|
args = parser.parse_args()
|
|
145
225
|
|
|
226
|
+
# --watch needs a real file path to poll; stdin can't be watched.
|
|
227
|
+
if args.watch and args.file is None:
|
|
228
|
+
_error(
|
|
229
|
+
"--watch requires a FILE argument",
|
|
230
|
+
fix="pass a markdown file path; stdin cannot be watched",
|
|
231
|
+
hint="termrender --watch doc.md",
|
|
232
|
+
)
|
|
233
|
+
|
|
146
234
|
# Determine input source
|
|
147
235
|
infile = args.file if args.file is not None else sys.stdin
|
|
148
236
|
if infile is sys.stdin and sys.stdin.isatty():
|
|
@@ -213,23 +301,43 @@ def main() -> None:
|
|
|
213
301
|
pass
|
|
214
302
|
pane_width = max(pane_width, 20) # absolute minimum
|
|
215
303
|
|
|
216
|
-
#
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
304
|
+
# Watch mode points the new pane at the user's real file so edits
|
|
305
|
+
# propagate; non-watch mode snapshots source into a tempfile.
|
|
306
|
+
tmpfile: str | None = None
|
|
307
|
+
if args.watch:
|
|
308
|
+
# args.file is guaranteed non-None by the earlier --watch check.
|
|
309
|
+
source_path = args.file.name
|
|
310
|
+
else:
|
|
311
|
+
with tempfile.NamedTemporaryFile(
|
|
312
|
+
mode="w", suffix=".md", prefix="termrender-", delete=False,
|
|
313
|
+
) as f:
|
|
314
|
+
f.write(source)
|
|
315
|
+
tmpfile = f.name
|
|
316
|
+
source_path = tmpfile
|
|
222
317
|
|
|
223
318
|
# Rebuild command without --tmux to avoid recursion
|
|
224
|
-
cmd_parts = ["termrender", shlex.quote(
|
|
319
|
+
cmd_parts = ["termrender", shlex.quote(source_path)]
|
|
225
320
|
if args.no_color:
|
|
226
321
|
cmd_parts.append("--no-color")
|
|
227
322
|
if args.cjk:
|
|
228
323
|
cmd_parts.append("--cjk")
|
|
229
|
-
|
|
324
|
+
if args.watch:
|
|
325
|
+
# No -w: watcher re-detects pane width per render so resizes
|
|
326
|
+
# pick up automatically.
|
|
327
|
+
cmd_parts.append("--watch")
|
|
328
|
+
else:
|
|
329
|
+
cmd_parts.extend(["-w", str(pane_width)])
|
|
230
330
|
|
|
231
|
-
|
|
232
|
-
|
|
331
|
+
if args.watch:
|
|
332
|
+
# Watch mode owns the pane (alternate screen buffer); no less,
|
|
333
|
+
# no tempfile cleanup needed.
|
|
334
|
+
pane_cmd = "TERMRENDER_COLOR=1 " + " ".join(cmd_parts)
|
|
335
|
+
else:
|
|
336
|
+
# TERMRENDER_COLOR=1 forces color on despite stdout piping to less
|
|
337
|
+
pane_cmd = (
|
|
338
|
+
"TERMRENDER_COLOR=1 " + " ".join(cmd_parts)
|
|
339
|
+
+ " | less -R; rm -f " + shlex.quote(source_path)
|
|
340
|
+
)
|
|
233
341
|
|
|
234
342
|
try:
|
|
235
343
|
subprocess.run(
|
|
@@ -237,15 +345,25 @@ def main() -> None:
|
|
|
237
345
|
check=True,
|
|
238
346
|
)
|
|
239
347
|
except FileNotFoundError:
|
|
240
|
-
|
|
348
|
+
if tmpfile:
|
|
349
|
+
os.unlink(tmpfile)
|
|
241
350
|
_error("tmux not found", fix="install tmux or omit --tmux")
|
|
242
351
|
except subprocess.CalledProcessError:
|
|
243
|
-
|
|
352
|
+
if tmpfile:
|
|
353
|
+
os.unlink(tmpfile)
|
|
244
354
|
_error("failed to create tmux pane",
|
|
245
355
|
hint="check that tmux is running and has space for a new pane")
|
|
246
356
|
|
|
247
357
|
sys.exit(EXIT_OK)
|
|
248
358
|
|
|
359
|
+
# --watch: live-render in the current terminal
|
|
360
|
+
if args.watch:
|
|
361
|
+
use_color = not args.no_color and (
|
|
362
|
+
sys.stdout.isatty() or os.environ.get("TERMRENDER_COLOR") == "1"
|
|
363
|
+
)
|
|
364
|
+
_watch_loop(args.file.name, color=use_color)
|
|
365
|
+
sys.exit(EXIT_OK)
|
|
366
|
+
|
|
249
367
|
# --check: validate only, no rendering
|
|
250
368
|
if args.check:
|
|
251
369
|
try:
|
|
@@ -25,6 +25,12 @@ class BlockType(Enum):
|
|
|
25
25
|
TABLE = "table"
|
|
26
26
|
LIST = "list"
|
|
27
27
|
LIST_ITEM = "list_item"
|
|
28
|
+
DIFF = "diff"
|
|
29
|
+
BAR = "bar"
|
|
30
|
+
PROGRESS = "progress"
|
|
31
|
+
GAUGE = "gauge"
|
|
32
|
+
STAT = "stat"
|
|
33
|
+
TIMELINE = "timeline"
|
|
28
34
|
|
|
29
35
|
|
|
30
36
|
@dataclass
|
|
@@ -35,6 +41,8 @@ class InlineSpan:
|
|
|
35
41
|
bold: bool = False
|
|
36
42
|
italic: bool = False
|
|
37
43
|
code: bool = False
|
|
44
|
+
fg: str | None = None
|
|
45
|
+
bg: str | None = None
|
|
38
46
|
|
|
39
47
|
|
|
40
48
|
@dataclass
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from termrender.blocks import Block, BlockType
|
|
6
|
-
from termrender.renderers import
|
|
6
|
+
from termrender.renderers import (
|
|
7
|
+
panel, columns, tree, code, text, divider, quote, mermaid, table,
|
|
8
|
+
diff, charts, stat, timeline,
|
|
9
|
+
)
|
|
7
10
|
|
|
8
11
|
|
|
9
12
|
def emit_block(block: Block, color: bool) -> list[str]:
|
|
@@ -45,6 +48,24 @@ def emit_block(block: Block, color: bool) -> list[str]:
|
|
|
45
48
|
case BlockType.DIVIDER:
|
|
46
49
|
return divider.render(block, color)
|
|
47
50
|
|
|
51
|
+
case BlockType.DIFF:
|
|
52
|
+
return diff.render(block, color)
|
|
53
|
+
|
|
54
|
+
case BlockType.BAR:
|
|
55
|
+
return charts.render_bar(block, color)
|
|
56
|
+
|
|
57
|
+
case BlockType.PROGRESS:
|
|
58
|
+
return charts.render_progress(block, color)
|
|
59
|
+
|
|
60
|
+
case BlockType.GAUGE:
|
|
61
|
+
return charts.render_gauge(block, color)
|
|
62
|
+
|
|
63
|
+
case BlockType.STAT:
|
|
64
|
+
return stat.render(block, color, render_child=emit_block)
|
|
65
|
+
|
|
66
|
+
case BlockType.TIMELINE:
|
|
67
|
+
return timeline.render(block, color)
|
|
68
|
+
|
|
48
69
|
case _:
|
|
49
70
|
return []
|
|
50
71
|
|
|
@@ -17,7 +17,7 @@ def resolve_width(block: Block, available: int) -> None:
|
|
|
17
17
|
block.width = available
|
|
18
18
|
|
|
19
19
|
bt = block.type
|
|
20
|
-
if bt in (BlockType.PANEL, BlockType.CALLOUT, BlockType.CODE):
|
|
20
|
+
if bt in (BlockType.PANEL, BlockType.CALLOUT, BlockType.CODE, BlockType.STAT):
|
|
21
21
|
border_overhead = visual_len("│") * 2 + 2 # left border + left pad + right pad + right border
|
|
22
22
|
inner = max(available - border_overhead, 1)
|
|
23
23
|
for child in block.children:
|
|
@@ -160,6 +160,41 @@ def resolve_height(block: Block) -> None:
|
|
|
160
160
|
elif bt == BlockType.QUOTE:
|
|
161
161
|
block.height = sum(c.height or 0 for c in block.children) + (1 if block.attrs.get("author") or block.attrs.get("by") else 0)
|
|
162
162
|
|
|
163
|
+
elif bt == BlockType.DIFF:
|
|
164
|
+
source = block.attrs.get("source", "")
|
|
165
|
+
lines = source.split("\n") if source else [""]
|
|
166
|
+
# Drop pure-blank trailing line that comes from terminating newline
|
|
167
|
+
if lines and lines[-1] == "":
|
|
168
|
+
lines = lines[:-1]
|
|
169
|
+
block.height = max(len(lines), 1) + 2 # top/bottom border
|
|
170
|
+
|
|
171
|
+
elif bt == BlockType.BAR:
|
|
172
|
+
items = block.attrs.get("items", [])
|
|
173
|
+
title_h = 1 if block.attrs.get("title") else 0
|
|
174
|
+
block.height = max(len(items), 1) + title_h
|
|
175
|
+
|
|
176
|
+
elif bt == BlockType.PROGRESS:
|
|
177
|
+
block.height = 1
|
|
178
|
+
|
|
179
|
+
elif bt == BlockType.GAUGE:
|
|
180
|
+
# label line + bar line + value line
|
|
181
|
+
block.height = 3
|
|
182
|
+
|
|
183
|
+
elif bt == BlockType.STAT:
|
|
184
|
+
# top border + label + value + delta + caption lines + bottom border
|
|
185
|
+
caption_h = sum(c.height or 0 for c in block.children)
|
|
186
|
+
delta_h = 1 if block.attrs.get("delta") else 0
|
|
187
|
+
block.height = 2 + 1 + 1 + delta_h + caption_h # borders + label + value + delta + caption
|
|
188
|
+
|
|
189
|
+
elif bt == BlockType.TIMELINE:
|
|
190
|
+
entries = block.attrs.get("entries", [])
|
|
191
|
+
title_h = 1 if block.attrs.get("title") else 0
|
|
192
|
+
# Each entry takes 1 line + 1 connector line between entries (none after last)
|
|
193
|
+
if entries:
|
|
194
|
+
block.height = title_h + len(entries) * 2 - 1
|
|
195
|
+
else:
|
|
196
|
+
block.height = max(title_h, 1)
|
|
197
|
+
|
|
163
198
|
else:
|
|
164
199
|
block.height = sum(c.height or 0 for c in block.children)
|
|
165
200
|
|