-
Notifications
You must be signed in to change notification settings - Fork 244
Expand file tree
/
Copy pathtmux_layout.py
More file actions
393 lines (335 loc) · 12.9 KB
/
Copy pathtmux_layout.py
File metadata and controls
393 lines (335 loc) · 12.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
"""Render tmux window layouts as inline SVG that looks like a terminal.
A ``tmux-layout`` directive declares a screen size and a tmux layout; the panes
tile the screen with no gaps, exactly the way tmux splits a window. Each pane's
shell commands are drawn top-left in a fixed-width font and syntax-highlighted
with Pygments, so the diagram reads like the real terminal. This is the
spiritual successor to the old ``aafig`` ASCII-art boxes, but tmux-aware.
Authoring — panes are separated by a ``---`` line, each holding that pane's
commands::
:::{tmux-layout}
:size: 64x18
:layout: even-vertical
echo 'did you know'
echo 'you can inline'
---
echo 'single commands'
---
echo 'for panes'
:::
``:layout:`` is one of tmux's arrangements: ``even-vertical`` (default),
``even-horizontal``, ``main-vertical``, ``main-horizontal``, ``tiled``.
Output is a single inline ``<svg>`` whose colours are CSS custom properties
(``gp-tmux-layout.css``), so it paints with the page, adapts to light/dark via
``body[data-theme]`` with no JavaScript and no second render, and rides
gp-sphinx SPA swaps as live DOM — without the headless-browser dependency the
mermaid pipeline needs.
"""
from __future__ import annotations
import html
import itertools
import typing as t
from docutils import nodes
from pygments.lexers.shell import BashLexer
from pygments.token import STANDARD_TYPES
from sphinx.util import logging
from sphinx.util.docutils import SphinxDirective
if t.TYPE_CHECKING:
from sphinx.application import Sphinx
from sphinx.writers.html5 import HTML5Translator
logger = logging.getLogger(__name__)
#: Pixels per tmux character cell (column width, row height). The viewBox is in
#: these pixels, so at the SVG's intrinsic size one unit is one CSS pixel and
#: the text renders at the page's code font size, like a real code block.
_CW = 8.0
_CH = 17.0
#: Padding (px) between a pane edge and its text.
_PAD = 6.0
#: Approx. font ascent (px) for placing the first baseline below the padding.
_FONT = 12.0
#: Baseline-to-baseline distance (px) for stacked command lines.
_LINE_H = 17.0
#: A simple shell prompt prefixed to each command line. The ``gp`` class is
#: Pygments' Generic.Prompt, so it takes the theme's prompt colour (like the
#: ``$`` in the site's console blocks).
_PROMPT = '<tspan class="gp">❯ </tspan>' # noqa: RUF001
_LAYOUTS = (
"even-vertical",
"even-horizontal",
"main-vertical",
"main-horizontal",
"tiled",
)
_LEXER = BashLexer()
_SVG_IDS = itertools.count(1)
class TmuxLayoutError(ValueError):
"""A tmux-layout directive could not be rendered."""
class Rect(t.NamedTuple):
"""A pane rectangle in character-cell coordinates."""
x: float
y: float
w: float
h: float
def _even(n: int, w: float, h: float, *, vertical: bool) -> list[Rect]:
"""Split the screen into ``n`` equal panes, filling it completely.
>>> [round(r.h, 2) for r in _even(2, 80, 24, vertical=True)]
[12.0, 12.0]
>>> [round(r.w, 2) for r in _even(2, 80, 24, vertical=False)]
[40.0, 40.0]
"""
rects = []
for i in range(n):
if vertical:
y0, y1 = i * h / n, (i + 1) * h / n
rects.append(Rect(0.0, y0, w, y1 - y0))
else:
x0, x1 = i * w / n, (i + 1) * w / n
rects.append(Rect(x0, 0.0, x1 - x0, h))
return rects
def _main(
n: int, w: float, h: float, *, vertical: bool, ratio: float = 0.5
) -> list[Rect]:
"""One main pane plus the rest split evenly beside/below it.
>>> r = _main(3, 80, 24, vertical=True)
>>> (round(r[0].w), round(r[1].x), len(r))
(40, 40, 3)
"""
if n == 1:
return [Rect(0.0, 0.0, w, h)]
if vertical:
mw = w * ratio
rects = [Rect(0.0, 0.0, mw, h)]
rects += [
Rect(mw + r.x, r.y, r.w, r.h)
for r in _even(n - 1, w - mw, h, vertical=True)
]
else:
mh = h * ratio
rects = [Rect(0.0, 0.0, w, mh)]
rects += [
Rect(r.x, mh + r.y, r.w, r.h)
for r in _even(n - 1, w, h - mh, vertical=False)
]
return rects
def _tiled(n: int, w: float, h: float) -> list[Rect]:
"""Tile ``n`` panes into a grid that fills the screen (tmux ``tiled``).
>>> len(_tiled(4, 80, 24)), round(_tiled(4, 80, 24)[0].w)
(4, 40)
"""
rows = cols = 1
while rows * cols < n:
rows += 1
if rows * cols < n:
cols += 1
rects = []
for i in range(n):
r, c = divmod(i, cols)
in_row = cols if r < rows - 1 else n - cols * (rows - 1)
x0, x1 = c * w / in_row, (c + 1) * w / in_row
y0, y1 = r * h / rows, (r + 1) * h / rows
rects.append(Rect(x0, y0, x1 - x0, y1 - y0))
return rects
def arrange(layout: str, n: int, w: int, h: int) -> list[Rect]:
"""Return ``n`` pane rectangles filling a ``w`` x ``h`` screen.
>>> [round(r.h) for r in arrange("even-vertical", 3, 80, 24)]
[8, 8, 8]
>>> len(arrange("tiled", 4, 80, 24))
4
"""
if n < 1:
msg = "a tmux layout needs at least one pane"
raise TmuxLayoutError(msg)
if n == 1:
return [Rect(0.0, 0.0, float(w), float(h))]
if layout == "even-vertical":
return _even(n, w, h, vertical=True)
if layout == "even-horizontal":
return _even(n, w, h, vertical=False)
if layout == "main-vertical":
return _main(n, w, h, vertical=True)
if layout == "main-horizontal":
return _main(n, w, h, vertical=False)
if layout == "tiled":
return _tiled(n, w, h)
msg = f"unknown layout {layout!r}; expected one of {', '.join(_LAYOUTS)}"
raise TmuxLayoutError(msg)
def _highlight(line: str) -> str:
"""Return a shell command as Pygments-classed ``<tspan>``s.
The command name (a shell builtin like ``echo``/``pwd``) is left in the
default text colour, like a freshly typed command; strings, comments, and
the like keep their Pygments classes.
>>> 'class="nb"' in _highlight("echo hello")
False
>>> 'class="s1"' in _highlight("echo 'hi'")
True
>>> "'hi'" in _highlight("echo 'hi'")
True
"""
spans = []
for token, value in _LEXER.get_tokens(line):
text = value.rstrip("\n")
if not text:
continue
css = STANDARD_TYPES.get(token, "")
if css == "nb": # command builtins (echo, pwd) keep the default colour
css = ""
escaped = html.escape(text)
spans.append(f'<tspan class="{css}">{escaped}</tspan>' if css else escaped)
return "".join(spans)
def _clip_id(prefix: str, index: int) -> str:
"""Return a clip-path id for one pane in a rendered SVG.
>>> _clip_id("tmux-layout-1", 0)
'tmux-layout-1-clip-0'
"""
return f"{prefix}-clip-{index}"
def _parse_size(size: str) -> tuple[int, int]:
"""Parse a ``WxH`` screen size.
>>> _parse_size("64x18")
(64, 18)
"""
try:
w_str, h_str = size.lower().split("x", 1)
return int(w_str), int(h_str)
except ValueError as exc:
msg = f"invalid size {size!r}; expected WxH (e.g. 80x24)"
raise TmuxLayoutError(msg) from exc
def render_layout(panes: list[list[str]], layout: str, size: tuple[int, int]) -> str:
"""Render panes (each a list of command lines) to an inline ``<svg>``.
>>> svg = render_layout([["echo a"], ["echo b"]], "even-vertical", (40, 12))
>>> svg.startswith("<svg") and svg.count('class="pane"')
2
"""
w, h = size
rects = arrange(layout, len(panes), w, h)
vb_w, vb_h = w * _CW, h * _CH
svg_id = f"tmux-layout-{next(_SVG_IDS)}"
parts = [
f'<svg class="gp-tmux-layout" viewBox="0 0 {vb_w:g} {vb_h:g}" '
f'width="{vb_w:g}" height="{vb_h:g}" role="img" '
'xmlns="http://www.w3.org/2000/svg">',
]
parts.append("<defs>")
for index, rect in enumerate(rects):
clip_x = rect.x * _CW + 1
clip_y = rect.y * _CH + 1
clip_w = max(rect.w * _CW - 2, 1.0)
clip_h = max(rect.h * _CH - 2, 1.0)
parts.append(
f'<clipPath id="{_clip_id(svg_id, index)}" '
'clipPathUnits="userSpaceOnUse">'
f'<rect x="{clip_x:g}" y="{clip_y:g}" '
f'width="{clip_w:g}" height="{clip_h:g}"/></clipPath>',
)
parts.append("</defs>")
# Pane fills first; the window border and single dividers go on top so a
# shared edge is one line, not two abutting pane strokes.
parts.extend(
f'<rect class="pane" x="{rect.x * _CW:g}" y="{rect.y * _CH:g}" '
f'width="{rect.w * _CW:g}" height="{rect.h * _CH:g}"/>'
for rect in rects
)
for rect in rects:
right, bottom = rect.x + rect.w, rect.y + rect.h
if right < w: # internal vertical divider
parts.append(
f'<line class="divider" x1="{right * _CW:g}" y1="{rect.y * _CH:g}" '
f'x2="{right * _CW:g}" y2="{bottom * _CH:g}"/>',
)
if bottom < h: # internal horizontal divider
parts.append(
f'<line class="divider" x1="{rect.x * _CW:g}" y1="{bottom * _CH:g}" '
f'x2="{(rect.x + rect.w) * _CW:g}" y2="{bottom * _CH:g}"/>',
)
parts.append(
f'<rect class="window" x="0" y="0" width="{vb_w:g}" height="{vb_h:g}"/>',
)
# Pygments colours come from furo's own `.highlight` rules (theme-matched,
# light/dark) via `fill: currentColor`; a <g> ignores its background.
parts.append('<g class="highlight">')
for index, (rect, commands) in enumerate(zip(rects, panes, strict=True)):
tx = rect.x * _CW + _PAD
baseline = rect.y * _CH + _PAD + _FONT
clip_path = _clip_id(svg_id, index)
for i, line in enumerate(commands):
body = _highlight(line)
if not body:
continue
ty = baseline + i * _LINE_H
parts.append(
f'<text class="cmd" clip-path="url(#{clip_path})" '
f'x="{tx:g}" y="{ty:g}" '
f'xml:space="preserve">{_PROMPT}{body}</text>',
)
parts.append("</g></svg>")
return "".join(parts)
def _split_panes(content: list[str]) -> list[list[str]]:
"""Split directive content on ``---`` lines into per-pane command lists.
>>> _split_panes(["echo a", "echo b", "---", "echo c"])
[['echo a', 'echo b'], ['echo c']]
"""
panes: list[list[str]] = [[]]
for line in content:
if line.strip() == "---":
panes.append([])
else:
panes[-1].append(line.rstrip())
return [
[line for line in pane if line.strip()]
for pane in panes
if any(p.strip() for p in pane)
]
class tmux_layout(nodes.General, nodes.Element):
"""Doctree node carrying a rendered tmux-layout SVG."""
class TmuxLayoutDirective(SphinxDirective):
"""Render a declared tmux layout as an inline, terminal-styled SVG."""
has_content = True
required_arguments = 0
optional_arguments = 0
option_spec: t.ClassVar[dict[str, t.Callable[[str], t.Any]]] = {
"size": lambda x: x,
"layout": lambda x: x,
"caption": lambda x: x,
}
def run(self) -> list[nodes.Node]:
"""Return a :class:`tmux_layout` node with the rendered SVG."""
panes = _split_panes(list(self.content))
if not panes:
warning = self.state.document.reporter.warning(
"tmux-layout directive has no panes",
line=self.lineno,
)
return [warning]
layout = self.options.get("layout", "even-vertical")
try:
svg = render_layout(
panes, layout, _parse_size(self.options.get("size", "80x24"))
)
except TmuxLayoutError as exc:
logger.warning("invalid tmux layout: %s", exc, location=self.get_location())
return [
nodes.literal_block("\n".join(self.content), "\n".join(self.content))
]
node = tmux_layout()
node["svg"] = svg
node["caption"] = self.options.get("caption", "")
return [node]
def html_visit_tmux_layout(self: HTML5Translator, node: tmux_layout) -> None:
"""Append the inline SVG (wrapped in a figure) and skip the node."""
caption = node.get("caption", "")
parts = [f'<figure class="gp-tmux-layout-wrapper">{node["svg"]}']
if caption:
parts.append(f"<figcaption>{html.escape(caption)}</figcaption>")
parts.append("</figure>")
self.body.append("".join(parts))
raise nodes.SkipNode
def _depart_tmux_layout(self: HTML5Translator, node: tmux_layout) -> None:
"""No-op; :func:`html_visit_tmux_layout` raises ``SkipNode``."""
def setup(app: Sphinx) -> dict[str, t.Any]:
"""Register the directive, node, and stylesheet."""
app.add_node(tmux_layout, html=(html_visit_tmux_layout, _depart_tmux_layout))
app.add_directive("tmux-layout", TmuxLayoutDirective)
app.add_css_file("css/gp-tmux-layout.css")
return {
"version": "0.1.0",
"parallel_read_safe": True,
"parallel_write_safe": True,
}