From 8fc3a16df2cc1da11b46af88d576a18c7536b23b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 05:08:43 -0500 Subject: [PATCH 01/20] docs(workspace-builders): lead with the concept, not the keys why: The intro framed the feature as a set of config "keys" to set, which is the implementation surface, not what a workspace builder means to the reader. Users meet the YAML knobs last, not first. what: - Open by defining what a workspace builder is and that it works out of the box, so most readers can stop after the first paragraph - Use progressive disclosure: default -> builder options (pane_readiness) -> swap builders -> write your own in Python - Name the cost/benefit of the prompt wait explicitly - Fold the narrative cross-reference onto "write your own" and drop the redundant standalone pointer below the table --- docs/configuration/workspace-builders.md | 28 ++++++++++++++++-------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/docs/configuration/workspace-builders.md b/docs/configuration/workspace-builders.md index 5354d66900..aa24e68a6e 100644 --- a/docs/configuration/workspace-builders.md +++ b/docs/configuration/workspace-builders.md @@ -5,20 +5,30 @@ ```{versionadded} 1.72.0 ``` -Most workspaces never need these keys. By default tmuxp builds your session with -its built-in *classic* builder and waits for a pane's shell prompt only when that -shell is zsh — existing workspace files keep working unchanged. Set the keys below -to swap in a different builder or to tune the prompt wait. **Omit a key (or remove -it) to restore the default.** +A *workspace builder* is the part of tmuxp that turns a workspace configuration into a +live tmux session — it creates the session, lays out its windows and panes, and runs +their commands. You usually never have to think about it: tmuxp ships with a built-in +*classic* builder, and your YAML or JSON workspace files load through it out of the +box, just as they always have. **Everything on this page is optional; leave a setting +out to fall back to the default.** + +Workspaces with special needs can reach for a builder's options to fine-tune how a +session loads. The classic builder, for instance, can wait for a pane's shell prompt +before sending its layout and commands — by default only when that shell is zsh (the +`pane_readiness` option). Waiting makes a session a little slower to load, but +guarantees the workspace is fully prepped before you attach. + +You can also send a workspace through a different or custom builder instead of the +classic one, and tune its options the same way. For the braver cases, you can subclass +the classic builder or write your own in Python on top of libtmux — see +{ref}`custom-workspace-builders` for writing, packaging, testing, and the trust +boundary that comes with running builder code. | Key | Type | Default | Purpose | | --- | --- | --- | --- | | `workspace_builder` | string | `classic` | Which builder turns the workspace into a session. | | `workspace_builder_paths` | string or list of strings | _(none)_ | Trusted directories to import a builder from. | -| `workspace_builder_options` | mapping | _(all defaults)_ | Builder-behavior knobs, such as `pane_readiness`. | - -For the narrative — writing a builder, packaging one, the trust boundary, and -testing — see {ref}`custom-workspace-builders`. +| `workspace_builder_options` | mapping | _(all defaults)_ | Builder-behavior settings, such as `pane_readiness`. | (workspace-builder-key)= From 4f225e634bc236daf4e90f999a3d324e02c0aeec Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 05:10:44 -0500 Subject: [PATCH 02/20] docs(workspace-builders): link libtmux to its homepage why: The intro now points advanced readers at libtmux as the layer a custom builder drives, but left it as bare text with no way to get there. what: - Link the first libtmux mention to https://libtmux.git-pull.com/, matching the plain-link style used in topics/library-vs-cli.md --- docs/configuration/workspace-builders.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/configuration/workspace-builders.md b/docs/configuration/workspace-builders.md index aa24e68a6e..5b4349f335 100644 --- a/docs/configuration/workspace-builders.md +++ b/docs/configuration/workspace-builders.md @@ -20,7 +20,8 @@ guarantees the workspace is fully prepped before you attach. You can also send a workspace through a different or custom builder instead of the classic one, and tune its options the same way. For the braver cases, you can subclass -the classic builder or write your own in Python on top of libtmux — see +the classic builder or write your own in Python on top of +[libtmux](https://libtmux.git-pull.com/) — see {ref}`custom-workspace-builders` for writing, packaging, testing, and the trust boundary that comes with running builder code. From 58be2b97896471251848496290b91098e76520d9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 05:13:44 -0500 Subject: [PATCH 03/20] docs(workspace-builders): warm the section prose to match the intro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: After the intro was rewritten to address the reader directly and lead with the concept, the per-setting sections still read as terse reference blurbs ("Selects the builder", "A catalog of..."), and one still framed pane_readiness as a "key". what: - Rewrite the workspace_builder, workspace_builder_paths, and workspace_builder_options leads in second person, present tense - Drop the remaining "key" framing in favor of naming the setting - Leave the resolver order list, value table, alias list, and error text untouched — reference precision stays as-is --- docs/configuration/workspace-builders.md | 26 +++++++++++++----------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/configuration/workspace-builders.md b/docs/configuration/workspace-builders.md index 5b4349f335..47311bc9c0 100644 --- a/docs/configuration/workspace-builders.md +++ b/docs/configuration/workspace-builders.md @@ -35,8 +35,9 @@ boundary that comes with running builder code. ## `workspace_builder` -Selects the builder. The default, `classic`, is tmuxp's built-in builder. A value -is resolved in this order: +This is where you name the builder. Leave it out — or set `classic` — and you get +tmuxp's built-in builder, with nothing imported. When you name something else, tmuxp +works out what you mean from the shape of the value, in this order: 1. absent or empty → the built-in classic builder (nothing is imported); 2. contains `:` → a `module:attr` object reference; @@ -60,11 +61,12 @@ See {ref}`custom-workspace-builders` for selecting and packaging builders, and ## `workspace_builder_paths` -Directories to import a builder from when it lives outside tmuxp's environment — -for example, a script in your config directory. Accepts a single string or a list -of strings. tmuxp expands `~` and environment variables, resolves relative entries -against the workspace file's directory, and requires each entry to be an existing -directory; the paths are added to `sys.path` only for the import and build. +When your builder lives outside tmuxp's environment — say, a script sitting in your +config directory — this tells tmuxp where to find it. Give it a single directory or a +list of them. tmuxp expands `~` and environment variables, reads relative entries +against the workspace file's own directory, and expects each one to be a directory +that already exists. The paths join `sys.path` only for the import and build, not for +the rest of your session. ```yaml workspace_builder: my_local_builder:CustomBuilder @@ -81,10 +83,10 @@ workspace files you trust. See the security note in {ref}`custom-workspace-build ## `workspace_builder_options` -A catalog of builder-behavior settings, independent of which builder you use. -Today it holds a single key, `pane_readiness`, which controls whether tmuxp waits -for a pane's shell prompt before sending its layout and commands — a guard against -a zsh prompt-redraw artifact: +This holds builder-behavior settings, whichever builder you use. For now there's just +one, `pane_readiness`, which decides whether tmuxp waits for a pane's shell prompt +before it sends that pane's layout and commands — a guard against a zsh prompt-redraw +artifact: ```yaml workspace_builder_options: @@ -105,7 +107,7 @@ workspace_builder_options: invalid pane_readiness value: 'sometimes'; expected one of: auto, always/true/on/yes/1, never/false/off/no/0 ``` -Panes that run a custom `shell` or `window_shell` never wait, regardless of policy. +A pane that runs a custom `shell` or `window_shell` never waits, whatever you set here. See {class}`~tmuxp.workspace.options.PaneReadiness` and {class}`~tmuxp.workspace.options.WorkspaceBuilderOptions` for the parsing rules. From 25826074a662ca54696482368c8b39ad04ffbdbb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 05:18:20 -0500 Subject: [PATCH 04/20] ai(rules) Add docs/AGENTS.md documentation voice guide why: The docs/ prose voice (concept before configuration, reader addressed directly, advanced parts marked opt-in) lived only in one page's git history. what: - Add docs/AGENTS.md describing the docs/ prose voice and audience, scoped as a complement to the root AGENTS.md - Exclude AGENTS.md from the Sphinx build so it is not an orphan doc --- docs/AGENTS.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++ docs/conf.py | 3 ++ 2 files changed, 83 insertions(+) create mode 100644 docs/AGENTS.md diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000000..16961d0df4 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,80 @@ +# Documentation voice + +This file covers the *voice* of prose under `docs/` — how to frame a +feature page so a reader meets the idea before its configuration. It +complements the repository-root `AGENTS.md`, which already governs code +blocks, shell-command formatting, changelog conventions, and MyST +roles. When the two overlap, the root file wins; this one only answers +the question it leaves open: how should the prose sound? + +## Who you are writing for + +The default reader runs tmuxp and writes workspace files in YAML or +JSON. They are fluent in tmux itself — servers, sessions, windows, +panes, layouts, the shell and its prompt — but you cannot assume they +read Python, know tmuxp's internals, or have heard of its builder +architecture, entry points, or `sys.path`. + +A second, smaller reader writes Python: custom builders, plugins, code +against libtmux. Serve them too, but mark their material as opt-in +("for the braver cases", "advanced") so the default reader knows they +can stop. Never make the common case pay a comprehension tax for the +advanced one. + +## Voice + +- **Second person, present tense, active.** "You name the builder", not + "The builder is selected". Address the reader who is doing the thing. +- **Concept before configuration.** Open by saying what the thing *is* + and what it does for the reader. The YAML surface — the keys, the + flags — is the last detail they need, not the first. A page that + opens with "set these keys" has buried the idea under its mechanics. +- **Say when they can stop.** Lead with the default and the + reassurance: most readers never touch this, it works out of the box, + everything here is optional. Let a skimmer leave after one sentence. +- **Progressive disclosure.** Order by how many readers need it: + default → the one option a few will tune → swapping the whole thing + → writing your own. Each step is for a smaller audience than the last. +- **Name the trade-off.** If an option costs something — load time, a + slower attach — say so, and say what it buys ("a little slower, but + the workspace is fully prepped before you attach"). State it; don't + sell it. +- **Frame by concept, not by mechanism.** Don't call a feature "the + keys" or "the flags" in prose; that names the implementation surface, + which is the reader's last concern. Name the concept. The mechanics + vocabulary — a `Key` / `Type` / `Default` table — is correct in a + reference table, and only there. + +## What stays precise + +Warm the framing, never the facts. Resolution-order lists, value +tables, exact error strings, and class or function cross-references +carry meaning in their exact form — leave them alone. The friendly +voice belongs in the sentences *around* a precise block, introducing +it, not inside it paraphrasing it into vagueness. + +## Cross-references + +Point the advanced reader at the deep-dive rather than inlining it, and +put the link where their interest peaks — on the phrase that made them +curious ("write your own") — not as a standalone footnote the eye +skips. Use the MyST roles listed in the root `AGENTS.md`. + +## A page that does this + +`docs/configuration/workspace-builders.md` is the worked example: +a concept-first intro, an out-of-the-box reassurance, sections ordered +by shrinking audience, an honest trade-off on the prompt wait, and +precise reference tables left precise. Read it before reshaping another +page. + +## Before you commit + +- Does the page open with what the feature *is*, or with how to + configure it? +- Can a reader who needs only the default stop after the first + paragraph? +- Is anything framed as "the keys/flags" that should be named by + concept instead? +- Are the advanced and Python-only parts clearly marked opt-in? +- Did you leave every table, error string, and cross-reference exact? diff --git a/docs/conf.py b/docs/conf.py index 4afd921ad2..8584c036c7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,5 +48,8 @@ aafig_format={"latex": "pdf", "html": "gif"}, aafig_default_options={"scale": 0.75, "aspect": 0.5, "proportional": True}, rediraffe_redirects="redirects.txt", + # AGENTS.md is agent guidance, not a site page; keep Sphinx from + # treating it as an orphan document. + exclude_patterns=["_build", "AGENTS.md"], ) globals().update(conf) From e536244b613d53f608840caa367d8c2162fd6fb9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 05:20:58 -0500 Subject: [PATCH 05/20] ai(rules[claude]) Link docs/`CLAUDE.md` -> `AGENTS.md` why: Claude Code reads CLAUDE.md; the symlink lets it pick up the same docs/ voice guidance without a second copy to keep in sync. what: - Symlink docs/CLAUDE.md -> AGENTS.md, mirroring the repository root - Exclude CLAUDE.md from the Sphinx build alongside AGENTS.md --- docs/CLAUDE.md | 1 + docs/conf.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) create mode 120000 docs/CLAUDE.md diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/docs/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 8584c036c7..5be4d35a38 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,8 +48,8 @@ aafig_format={"latex": "pdf", "html": "gif"}, aafig_default_options={"scale": 0.75, "aspect": 0.5, "proportional": True}, rediraffe_redirects="redirects.txt", - # AGENTS.md is agent guidance, not a site page; keep Sphinx from - # treating it as an orphan document. - exclude_patterns=["_build", "AGENTS.md"], + # AGENTS.md (+ its CLAUDE.md symlink) is agent guidance, not a site + # page; keep Sphinx from treating it as an orphan document. + exclude_patterns=["_build", "AGENTS.md", "CLAUDE.md"], ) globals().update(conf) From 59ed40befcdb54d341241e49157d736debf6573e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 05:50:23 -0500 Subject: [PATCH 06/20] docs(ext): inline mermaid diagrams at build time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Diagrams must paint with the page — instant, no layout shift, no staggered pop-in — and must survive gp-sphinx SPA swaps. Client-side mermaid.run() loses on every count: async render means CLS and a flash of source, and spa-nav.js takes its View-Transition snapshot before the SVG exists. Rendering to inline SVG at build time satisfies all of it with zero per-page JavaScript, and the swapped .article-container then carries finished SVG through SPA navigation with no re-render. what: - Add docs/_ext/mermaid_inline.py: a `mermaid` directive that defers to an HTML write-phase visitor, which shells to mmdc, content-hash-caches per theme, normalizes the SVG (unique id replacing mermaid's fixed "my-svg", explicit width/height from the viewBox, stripped max-width), and inlines light+dark variants; degrades to a text fallback with a one-time warning when the renderer is absent - Add docs/_static/css/gp-diagram.css: dual-SVG light/dark toggle on body[data-theme], intrinsic sizing, wide-diagram scroll wrapper - Wire docs/conf.py: register the extension, route plain mermaid fences via myst_fence_as_directive, exclude node_modules and the render cache from the Sphinx source tree - Pin the renderer locally (docs/package.json + pnpm lock/workspace); gitignore docs/node_modules and docs/_mermaid_cache - Add tests/test_docs_mermaid.py: NamedTuple-parametrized, test_id-first coverage of the normalizer, digest determinism, dual-theme emission, cache idempotency, missing-renderer fallback, and the setup() contract --- .gitignore | 4 + docs/_ext/mermaid_inline.py | 364 +++++ docs/_static/css/gp-diagram.css | 72 + docs/conf.py | 17 +- docs/package.json | 8 + docs/pnpm-lock.yaml | 2281 +++++++++++++++++++++++++++++++ docs/pnpm-workspace.yaml | 4 + tests/test_docs_mermaid.py | 295 ++++ 8 files changed, 3042 insertions(+), 3 deletions(-) create mode 100644 docs/_ext/mermaid_inline.py create mode 100644 docs/_static/css/gp-diagram.css create mode 100644 docs/package.json create mode 100644 docs/pnpm-lock.yaml create mode 100644 docs/pnpm-workspace.yaml create mode 100644 tests/test_docs_mermaid.py diff --git a/.gitignore b/.gitignore index 9dadb843bd..192e58cfc8 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,10 @@ coverage.xml # Sphinx documentation docs/_build/ +# Mermaid diagram tooling (docs/_ext/mermaid_inline.py) +docs/node_modules/ +docs/_mermaid_cache/ + # PyBuilder target/ diff --git a/docs/_ext/mermaid_inline.py b/docs/_ext/mermaid_inline.py new file mode 100644 index 0000000000..33a6df540a --- /dev/null +++ b/docs/_ext/mermaid_inline.py @@ -0,0 +1,364 @@ +"""Build-time mermaid rendering for Sphinx, producing inline SVG. + +Renders fenced ``mermaid`` blocks to inline ```` at build time via +``mmdc`` (`@mermaid-js/mermaid-cli`_), so diagrams paint with the page: there is +no client-side mermaid runtime, no asynchronous pop-in, and no layout shift. The +finished SVG ships inside ``.article-container``, so it also rides through +gp-sphinx's SPA navigation as live DOM with zero re-initialisation. + +Each diagram is rendered twice — a light and a dark variant — and both are +inlined, toggled by CSS on ``body[data-theme]`` (see ``gp-diagram.css``). Mermaid +bakes literal colours into an id-scoped, ``!important`` ``
' + html.escape(source) + "
", + ) + raise nodes.SkipNode from None + + digest = _diagram_digest(source, "") + light = _normalize_svg(light, svg_id=_svg_element_id(digest, _THEME_LIGHT)) + dark = _normalize_svg(dark, svg_id=_svg_element_id(digest, _THEME_DARK)) + + caption: str = node.get("caption", "") + alt = node.get("alt", "") or caption + aria = f' aria-label="{html.escape(alt, quote=True)}"' if alt else "" + ids: list[str] = node.get("ids", []) + fig_id = f' id="{ids[0]}"' if ids else "" + + parts = [ + f'
', + f'', + ( + '' + ), + ] + if caption: + parts.append(f"
{html.escape(caption)}
") + parts.append("
") + self.body.append("".join(parts)) + raise nodes.SkipNode + + +def _depart_mermaid_inline(self: HTML5Translator, node: mermaid_inline) -> None: + """No-op; :func:`html_visit_mermaid_inline` raises ``SkipNode``.""" + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the directive, node, config values, and stylesheet.""" + app.add_node( + mermaid_inline, + html=(html_visit_mermaid_inline, _depart_mermaid_inline), + ) + app.add_directive("mermaid", MermaidDirective) + app.add_config_value("mermaid_inline_cmd", "", "env") + app.add_config_value("mermaid_inline_puppeteer_config", "", "env") + app.add_css_file("css/gp-diagram.css") + return { + "version": "0.1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/_static/css/gp-diagram.css b/docs/_static/css/gp-diagram.css new file mode 100644 index 0000000000..c032e4332a --- /dev/null +++ b/docs/_static/css/gp-diagram.css @@ -0,0 +1,72 @@ +/* + * Build-time mermaid diagrams (docs/_ext/mermaid_inline.py). + * + * Each diagram is inlined twice — a light and a dark SVG — and this stylesheet + * shows exactly one. Mermaid bakes literal colours into an id-scoped, + * !important " + 'a' + '' + "" +) + + +class NormalizeCase(t.NamedTuple): + """A ``_normalize_svg`` scenario and its expected substrings.""" + + test_id: str + raw_svg: str + svg_id: str + expect_contains: tuple[str, ...] + expect_absent: tuple[str, ...] + + +_NORMALIZE_CASES: list[NormalizeCase] = [ + NormalizeCase( + test_id="width-and-height-from-viewbox", + raw_svg=( + '' + ), + svg_id="mermaid-deadbeef-light", + expect_contains=( + 'width="200"', + 'height="80"', + 'id="mermaid-deadbeef-light"', + "#mermaid-deadbeef-light{", + ), + expect_absent=("my-svg", "max-width", 'width="100%"'), + ), + NormalizeCase( + test_id="marker-references-rewritten", + raw_svg=( + '' + '' + ), + svg_id="mermaid-abc-dark", + expect_contains=("url(#mermaid-abc-dark_end)", 'width="10"', 'height="10"'), + expect_absent=("my-svg",), + ), + NormalizeCase( + test_id="decimal-viewbox-dimensions", + raw_svg=( + '' + ), + svg_id="mermaid-x-light", + expect_contains=('width="1141.25"', 'height="94"'), + expect_absent=("max-width",), + ), +] + + +@pytest.mark.parametrize( + "case", + _NORMALIZE_CASES, + ids=[c.test_id for c in _NORMALIZE_CASES], +) +def test_normalize_svg(case: NormalizeCase) -> None: + """``_normalize_svg`` rewrites the id, sets size, and drops ``max-width``.""" + out = mi._normalize_svg(case.raw_svg, svg_id=case.svg_id) + for needle in case.expect_contains: + assert needle in out, f"{case.test_id}: expected {needle!r}" + for needle in case.expect_absent: + assert needle not in out, f"{case.test_id}: unexpected {needle!r}" + + +class DigestCase(t.NamedTuple): + """Two ``_diagram_digest`` inputs and whether their hashes should match.""" + + test_id: str + a_source: str + a_theme: str + b_source: str + b_theme: str + expect_equal: bool + + +_DIGEST_CASES: list[DigestCase] = [ + DigestCase( + "identical-inputs-match", + "flowchart LR a-->b", + "default", + "flowchart LR a-->b", + "default", + True, + ), + DigestCase( + "theme-differs", + "flowchart LR a-->b", + "default", + "flowchart LR a-->b", + "dark", + False, + ), + DigestCase( + "source-differs", + "flowchart LR a-->b", + "default", + "flowchart LR a-->c", + "default", + False, + ), +] + + +@pytest.mark.parametrize( + "case", + _DIGEST_CASES, + ids=[c.test_id for c in _DIGEST_CASES], +) +def test_diagram_digest(case: DigestCase) -> None: + """``_diagram_digest`` is stable per input and varies by theme and source.""" + a = mi._diagram_digest(case.a_source, case.a_theme) + b = mi._diagram_digest(case.b_source, case.b_theme) + assert (a == b) is case.expect_equal + assert len(a) == 40 + + +def test_svg_element_id_is_themed_and_unique() -> None: + """``_svg_element_id`` yields distinct, theme-suffixed ids per variant.""" + digest = mi._diagram_digest("flowchart LR a-->b", "") + light = mi._svg_element_id(digest, "light") + dark = mi._svg_element_id(digest, "dark") + assert light != dark + assert light.startswith("mermaid-") + assert light.endswith("-light") + + +def _make_translator(tmp_path: Path) -> types.SimpleNamespace: + """Return a minimal stand-in for the HTML translator the visitor needs.""" + config = types.SimpleNamespace( + mermaid_inline_cmd="", + mermaid_inline_puppeteer_config="", + ) + app = types.SimpleNamespace(confdir=str(tmp_path), config=config) + builder = types.SimpleNamespace(app=app) + return types.SimpleNamespace(builder=builder, body=[]) + + +def test_visitor_emits_dual_themed_svg( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """The visitor inlines one light and one dark SVG with shared geometry.""" + seen: list[str] = [] + + def fake_render_cached(app: object, source: str, theme: str) -> str: + seen.append(theme) + return _FAKE_MMDC_SVG + + monkeypatch.setattr(mi, "_render_cached", fake_render_cached) + translator = _make_translator(tmp_path) + node = mi.mermaid_inline() + node["mermaid_source"] = "flowchart LR a-->b" + node["caption"] = "How it flows" + node["alt"] = "" + + with pytest.raises(nodes.SkipNode): + mi.html_visit_mermaid_inline(t.cast("t.Any", translator), node) + + html = "".join(translator.body) + assert seen == [mi._THEME_LIGHT, mi._THEME_DARK] + assert html.count("gp-diagram--light") == 1 + assert html.count("gp-diagram--dark") == 1 + assert "
How it flows
" in html + assert "my-svg" not in html + # Both variants normalized to identical geometry -> shift-free toggle. + assert html.count('viewBox="0 0 200 80"') == 2 + + +def test_visitor_falls_back_when_renderer_missing( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """A missing renderer degrades to a text fallback and warns once.""" + monkeypatch.setattr(mi, "_render_warned", False) + + def boom(app: object, source: str, theme: str) -> str: + msg = "no mmdc" + raise mi.MermaidRendererMissing(msg) + + monkeypatch.setattr(mi, "_render_cached", boom) + translator = _make_translator(tmp_path) + node = mi.mermaid_inline() + node["mermaid_source"] = "flowchart LR a-->b" + node["caption"] = "" + node["alt"] = "" + + with caplog.at_level(logging.WARNING), pytest.raises(nodes.SkipNode): + mi.html_visit_mermaid_inline(t.cast("t.Any", translator), node) + + html = "".join(translator.body) + assert 'class="gp-diagram-fallback"' in html + assert "flowchart LR a-->b" in html + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any("mermaid render unavailable" in r.getMessage() for r in warnings) + + +def test_render_cache_is_idempotent( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """``_render_cached`` invokes mmdc once, then serves from the disk cache.""" + calls = {"n": 0} + + def fake_render(app: object, source: str, theme: str) -> str: + calls["n"] += 1 + return _FAKE_MMDC_SVG + + monkeypatch.setattr(mi, "_render", fake_render) + config = types.SimpleNamespace( + mermaid_inline_cmd="", + mermaid_inline_puppeteer_config="", + ) + app = types.SimpleNamespace(confdir=str(tmp_path), config=config) + + first = mi._render_cached(t.cast("t.Any", app), "flowchart LR a-->b", "default") + second = mi._render_cached(t.cast("t.Any", app), "flowchart LR a-->b", "default") + + assert first == second == _FAKE_MMDC_SVG + assert calls["n"] == 1 + assert list((tmp_path / "_mermaid_cache").glob("*.svg")) + + +def test_setup_registers_components() -> None: + """``setup`` registers the node, directive, config values, css, and is safe.""" + recorded: dict[str, list[t.Any]] = { + "nodes": [], + "directives": [], + "config": [], + "css": [], + } + + def add_node(node: object, **kwargs: object) -> None: + recorded["nodes"].append(node) + + def add_directive(name: str, cls: object) -> None: + recorded["directives"].append((name, cls)) + + def add_config_value(name: str, default: object, rebuild: object) -> None: + recorded["config"].append(name) + + def add_css_file(name: str, **kwargs: object) -> None: + recorded["css"].append(name) + + app = types.SimpleNamespace( + add_node=add_node, + add_directive=add_directive, + add_config_value=add_config_value, + add_css_file=add_css_file, + ) + meta = mi.setup(t.cast("t.Any", app)) + + assert meta["parallel_read_safe"] is True + assert meta["parallel_write_safe"] is True + assert mi.mermaid_inline in recorded["nodes"] + assert ("mermaid", mi.MermaidDirective) in recorded["directives"] + assert "mermaid_inline_cmd" in recorded["config"] + assert "mermaid_inline_puppeteer_config" in recorded["config"] + assert "css/gp-diagram.css" in recorded["css"] From 16dcd34d180bcbe7de64f2bc0c05715c686b95ee Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 05:52:34 -0500 Subject: [PATCH 07/20] docs(workspace-builders): add flow diagram why: The concept-first intro tells the reader a workspace builder "turns a workspace configuration into a live tmux session". A small pipeline diagram makes that sentence visual right where it is stated, before the reader reaches the reference table. what: - Add a left-to-right mermaid flowchart after the opening paragraph: `tmuxp load ` -> Workspace Builder -> Attach tmux session, with edge labels echoing the intro vocabulary --- docs/configuration/workspace-builders.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/configuration/workspace-builders.md b/docs/configuration/workspace-builders.md index 47311bc9c0..ce7349e21e 100644 --- a/docs/configuration/workspace-builders.md +++ b/docs/configuration/workspace-builders.md @@ -12,6 +12,14 @@ their commands. You usually never have to think about it: tmuxp ships with a bui box, just as they always have. **Everything on this page is optional; leave a setting out to fall back to the default.** +:::{mermaid} +:caption: How a workspace file becomes a live tmux session. + +flowchart LR + load["tmuxp load <workspace-file>"] -->|reads workspace config| builder["Workspace Builder"] + builder -->|"creates session, windows, panes"| attach["Attach tmux session"] +::: + Workspaces with special needs can reach for a builder's options to fine-tune how a session loads. The classic builder, for instance, can wait for a pane's shell prompt before sending its layout and commands — by default only when that shell is zsh (the From ec9151402c5dc244cf7b8554b471bc209b30c79b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 05:54:30 -0500 Subject: [PATCH 08/20] docs(workspace-builders): make diagram vertical why: The left-to-right flowchart rendered ~1141px wide and scaled down hard in the content column, shrinking its labels. A top-down layout is ~276px wide, fits the column at full size, and reads as a vertical load -> build -> attach pipeline. what: - Switch the mermaid flowchart from LR to TD --- docs/configuration/workspace-builders.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/workspace-builders.md b/docs/configuration/workspace-builders.md index ce7349e21e..094f6df5c0 100644 --- a/docs/configuration/workspace-builders.md +++ b/docs/configuration/workspace-builders.md @@ -15,7 +15,7 @@ out to fall back to the default.** :::{mermaid} :caption: How a workspace file becomes a live tmux session. -flowchart LR +flowchart TD load["tmuxp load <workspace-file>"] -->|reads workspace config| builder["Workspace Builder"] builder -->|"creates session, windows, panes"| attach["Attach tmux session"] ::: From 66f3064c749d1c2f806e810a93a2c0a40b145c34 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 05:56:41 -0500 Subject: [PATCH 09/20] docs(workspace-builders): widen diagram nodes why: At mermaid's default wrappingWidth (200px) the "tmuxp load " node wrapped onto two lines. Raising it keeps node labels on a single line so each box sizes to its own text. what: - Set flowchart.wrappingWidth=500 via in-source mermaid frontmatter on the workspace-builders fence --- docs/configuration/workspace-builders.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/configuration/workspace-builders.md b/docs/configuration/workspace-builders.md index 094f6df5c0..2a599efe16 100644 --- a/docs/configuration/workspace-builders.md +++ b/docs/configuration/workspace-builders.md @@ -15,6 +15,11 @@ out to fall back to the default.** :::{mermaid} :caption: How a workspace file becomes a live tmux session. +--- +config: + flowchart: + wrappingWidth: 500 +--- flowchart TD load["tmuxp load <workspace-file>"] -->|reads workspace config| builder["Workspace Builder"] builder -->|"creates session, windows, panes"| attach["Attach tmux session"] From 16ebd9678de40939578ed14224774ec696de5ea1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 05:59:29 -0500 Subject: [PATCH 10/20] ci(docs): provision the mermaid diagram toolchain why: Diagrams render at build time via mmdc + a headless Chrome (docs/_ext/mermaid_inline.py). The docs publish job had only uv and just, so it would silently degrade every diagram to a text fallback and ship docs without them. what: - Set up Node.js and pnpm in the docs build job - Cache ~/.cache/puppeteer keyed on docs/pnpm-lock.yaml - Install the docs JS deps and provision the version-matched Chrome via `pnpm -C docs rebuild puppeteer` before building --- .github/workflows/docs.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4ccd9a8621..c589d98ef8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -56,6 +56,38 @@ jobs: if: env.PUBLISH == 'true' uses: extractions/setup-just@v4 + - name: Set up Node.js + if: env.PUBLISH == 'true' + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Set up pnpm + if: env.PUBLISH == 'true' + uses: pnpm/action-setup@v4 + with: + version: 10 + run_install: false + + - name: Cache Puppeteer browser + if: env.PUBLISH == 'true' + uses: actions/cache@v5 + with: + path: ~/.cache/puppeteer + key: puppeteer-${{ runner.os }}-${{ hashFiles('docs/pnpm-lock.yaml') }} + restore-keys: | + puppeteer-${{ runner.os }}- + + # Diagrams (docs/_ext/mermaid_inline.py) are rendered at build time by + # mmdc, which drives a headless Chrome. Without it the build still + # succeeds but degrades diagrams to a text fallback, so provision both. + - name: Install diagram toolchain (mermaid-cli + Chrome) + if: env.PUBLISH == 'true' + run: | + pnpm -C docs install --frozen-lockfile + pnpm -C docs rebuild puppeteer + pnpm -C docs exec mmdc --version + - name: Print python versions if: env.PUBLISH == 'true' run: | From d73661bc4a13819285052ea85c2d5800dd68d441 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 06:03:53 -0500 Subject: [PATCH 11/20] docs(ext): theme diagrams with the gp-furo palette why: Diagrams rendered with mermaid's stock default/dark presets, whose purple boxes clashed with the site brand. Mapping mermaid's base-theme variables to gp-furo's tokens makes diagrams match the docs in both light and dark mode, with no extra page weight. what: - Add light/dark _PALETTES from gp-furo-tokens: brand-blue borders, near-background fills, foreground text, and the furo system font stack - Render via `mmdc -c` (theme=base + themeVariables) instead of the -t default/dark presets; bump the cache version to re-render - Test that each palette defines the required flowchart colour variables --- docs/_ext/mermaid_inline.py | 51 +++++++++++++++++++++++++++++++++---- tests/test_docs_mermaid.py | 34 +++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/docs/_ext/mermaid_inline.py b/docs/_ext/mermaid_inline.py index 33a6df540a..2e06566d51 100644 --- a/docs/_ext/mermaid_inline.py +++ b/docs/_ext/mermaid_inline.py @@ -47,12 +47,47 @@ logger = logging.getLogger(__name__) #: Bump to invalidate the on-disk render cache when render arguments change. -_RENDER_VERSION = "mmdc11-svg-v1" +_RENDER_VERSION = "mmdc11-furo-svg-v1" -#: mermaid theme presets used for the two inlined variants. -_THEME_LIGHT = "default" +#: Logical names for the two inlined variants (cache key, SVG id, CSS class). +_THEME_LIGHT = "light" _THEME_DARK = "dark" +#: System font stack matching gp-furo's body font (gp-furo-tokens --font-stack). +_FONT_STACK = ( + "-apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif" +) + +#: mermaid ``base``-theme variables mapped to gp-furo's light/dark colour tokens +#: (gp-furo-tokens: light.ts / dark.ts) so diagrams match the site palette. +#: Colour does not affect layout, so both variants share geometry. +_PALETTES: dict[str, dict[str, str]] = { + _THEME_LIGHT: { + "primaryColor": "#f8f9fb", + "primaryBorderColor": "#0a4bff", + "primaryTextColor": "#000000", + "lineColor": "#6b6f76", + "textColor": "#000000", + "background": "#ffffff", + "edgeLabelBackground": "#f8f9fb", + "secondaryColor": "#ffffff", + "tertiaryColor": "#f8f9fb", + "fontFamily": _FONT_STACK, + }, + _THEME_DARK: { + "primaryColor": "#1a1c1e", + "primaryBorderColor": "#3d94ff", + "primaryTextColor": "#cfd0d0", + "lineColor": "#81868d", + "textColor": "#cfd0d0", + "background": "#131416", + "edgeLabelBackground": "#1a1c1e", + "secondaryColor": "#131416", + "tertiaryColor": "#1a1c1e", + "fontFamily": _FONT_STACK, + }, +} + #: mermaid hardcodes this id on every rendered SVG and scopes its CSS and #: arrowhead markers to it; it is rewritten per diagram+theme to avoid #: duplicate-id collisions when both variants are inlined on one page. @@ -240,7 +275,13 @@ def _render(app: Sphinx, source: str, theme: str) -> str: tmpdir = pathlib.Path(td) in_file = tmpdir / "diagram.mmd" out_file = tmpdir / "diagram.svg" + config_file = tmpdir / "config.json" in_file.write_text(source, encoding="utf-8") + config: dict[str, t.Any] = { + "theme": "base", + "themeVariables": _PALETTES[theme], + } + config_file.write_text(json.dumps(config), encoding="utf-8") argv = [ *mmdc, "-i", @@ -249,8 +290,8 @@ def _render(app: Sphinx, source: str, theme: str) -> str: str(out_file), "-b", "transparent", - "-t", - theme, + "-c", + str(config_file), "-p", str(_puppeteer_config_file(app, tmpdir)), ] diff --git a/tests/test_docs_mermaid.py b/tests/test_docs_mermaid.py index 076cf89a69..db92683a97 100644 --- a/tests/test_docs_mermaid.py +++ b/tests/test_docs_mermaid.py @@ -160,6 +160,40 @@ def test_svg_element_id_is_themed_and_unique() -> None: assert light.endswith("-light") +class PaletteCase(t.NamedTuple): + """A theme name whose furo palette must define the flowchart variables.""" + + test_id: str + theme: str + + +_PALETTE_CASES: list[PaletteCase] = [ + PaletteCase(test_id=f"{theme}-palette", theme=theme) + for theme in (mi._THEME_LIGHT, mi._THEME_DARK) +] + +_REQUIRED_PALETTE_KEYS = ( + "primaryColor", + "primaryBorderColor", + "primaryTextColor", + "lineColor", + "textColor", +) + + +@pytest.mark.parametrize( + "case", + _PALETTE_CASES, + ids=[c.test_id for c in _PALETTE_CASES], +) +def test_palette_defines_flowchart_colors(case: PaletteCase) -> None: + """Each theme palette defines the mermaid colour variables as hex values.""" + palette = mi._PALETTES[case.theme] + for key in _REQUIRED_PALETTE_KEYS: + assert key in palette, f"{case.test_id}: missing {key}" + assert palette[key].startswith("#"), f"{case.test_id}: {key} not hex" + + def _make_translator(tmp_path: Path) -> types.SimpleNamespace: """Return a minimal stand-in for the HTML translator the visitor needs.""" config = types.SimpleNamespace( From 7664c3bd5a89510de2cb1d1720688aeb830e6e23 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 06:06:51 -0500 Subject: [PATCH 12/20] docs(ext): discover Chrome cross-platform why: _discover_chrome matched only puppeteer's Linux cache layout (chrome-linux64), so on macOS or Windows the build could not find an installed Chrome and would degrade diagrams to a text fallback. what: - Add _chrome_glob(platform): the puppeteer-cache glob per OS (linux, macOS .app bundle, win64), doctested - Resolve via _chrome_glob(sys.platform) in _discover_chrome --- docs/_ext/mermaid_inline.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/_ext/mermaid_inline.py b/docs/_ext/mermaid_inline.py index 2e06566d51..c61e5678ce 100644 --- a/docs/_ext/mermaid_inline.py +++ b/docs/_ext/mermaid_inline.py @@ -32,6 +32,7 @@ import re import shutil import subprocess +import sys import tempfile import typing as t @@ -210,17 +211,39 @@ def run(self) -> list[nodes.Node]: return [node] +def _chrome_glob(platform: str) -> str: + """Return the puppeteer-cache glob for Chrome on the given ``sys.platform``. + + Puppeteer lays the binary out per platform under ``chrome//``. + + >>> _chrome_glob("linux") + '*/chrome-linux64/chrome' + >>> _chrome_glob("win32") + '*/chrome-win64/chrome.exe' + >>> _chrome_glob("darwin").startswith("*/chrome-mac-") + True + """ + if platform.startswith("win"): + return "*/chrome-win64/chrome.exe" + if platform == "darwin": + return ( + "*/chrome-mac-*/Google Chrome for Testing.app" + "/Contents/MacOS/Google Chrome for Testing" + ) + return "*/chrome-linux64/chrome" + + def _discover_chrome() -> str | None: """Return a Chrome installed by ``puppeteer browsers install``, if any. Puppeteer's automatic resolution can miss the cached browser (its cache dir is computed relative to the install location); pointing ``executablePath`` at - the discovered binary sidesteps that. Linux/WSL layout only. + the discovered binary sidesteps that. """ cache = pathlib.Path.home() / ".cache" / "puppeteer" / "chrome" if not cache.is_dir(): return None - candidates = sorted(cache.glob("*/chrome-linux64/chrome")) + candidates = sorted(cache.glob(_chrome_glob(sys.platform))) return str(candidates[-1]) if candidates else None From 3af464e9ba217490ac22fdb190eae8813a0659c8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 06:10:24 -0500 Subject: [PATCH 13/20] docs(ext): style code nodes and pad edge labels why: The diagram read uniformly: the literal command `tmuxp load ` looked like the prose concept nodes, and the edge-label background boxes hugged their text with no breathing room. what: - Inject theme-agnostic themeCSS into every rendered diagram: nodes tagged `:::cmd` render in the furo monospace stack so commands read as code (over the existing code-like fill), and edge labels gain padding; white-space:normal keeps a padded label wrapping instead of overflowing the box mermaid measured for it - Tag the `tmuxp load` node with `:::cmd` on the workspace-builders page - Bump the render cache version to re-render --- docs/_ext/mermaid_inline.py | 17 ++++++++++++++++- docs/configuration/workspace-builders.md | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/_ext/mermaid_inline.py b/docs/_ext/mermaid_inline.py index c61e5678ce..559d680abc 100644 --- a/docs/_ext/mermaid_inline.py +++ b/docs/_ext/mermaid_inline.py @@ -48,7 +48,7 @@ logger = logging.getLogger(__name__) #: Bump to invalidate the on-disk render cache when render arguments change. -_RENDER_VERSION = "mmdc11-furo-svg-v1" +_RENDER_VERSION = "mmdc11-furo-svg-v2" #: Logical names for the two inlined variants (cache key, SVG id, CSS class). _THEME_LIGHT = "light" @@ -89,6 +89,20 @@ }, } +#: Extra CSS injected into every rendered SVG (theme-agnostic). Nodes tagged +#: ``:::cmd`` render in a monospace font so commands read as code; edge labels +#: get padding. ``white-space: normal`` lets a padded label keep wrapping +#: instead of overflowing the box mermaid measured for it. +_MONO_STACK = "'SFMono-Regular', Menlo, Consolas, Monaco, 'Liberation Mono', monospace" +_THEME_CSS = ( + ".cmd .nodeLabel { font-family: " + _MONO_STACK + " !important; }" + " .edgeLabels .labelBkg {" + " padding: 3px 10px !important;" + " white-space: normal !important;" + " border-radius: 4px;" + " }" +) + #: mermaid hardcodes this id on every rendered SVG and scopes its CSS and #: arrowhead markers to it; it is rewritten per diagram+theme to avoid #: duplicate-id collisions when both variants are inlined on one page. @@ -303,6 +317,7 @@ def _render(app: Sphinx, source: str, theme: str) -> str: config: dict[str, t.Any] = { "theme": "base", "themeVariables": _PALETTES[theme], + "themeCSS": _THEME_CSS, } config_file.write_text(json.dumps(config), encoding="utf-8") argv = [ diff --git a/docs/configuration/workspace-builders.md b/docs/configuration/workspace-builders.md index 2a599efe16..b1b5576bf5 100644 --- a/docs/configuration/workspace-builders.md +++ b/docs/configuration/workspace-builders.md @@ -21,7 +21,7 @@ config: wrappingWidth: 500 --- flowchart TD - load["tmuxp load <workspace-file>"] -->|reads workspace config| builder["Workspace Builder"] + load["tmuxp load <workspace-file>"]:::cmd -->|reads workspace config| builder["Workspace Builder"] builder -->|"creates session, windows, panes"| attach["Attach tmux session"] ::: From 64cf22d7d167fa13b371068de5121cae997c4dd4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 06:14:17 -0500 Subject: [PATCH 14/20] docs(ext): fix label centering and double bg why: On the rendered page (not in standalone mmdc output) the :::cmd label fell left because the build's headless Chrome lacks the exact monospace face, so the measured box is wider than the on-page text; and each edge label showed two stacked boxes once padded, because mermaid backs every edge label with three nested coloured elements. what: - Replace the themeCSS constant with _theme_css(theme): center node labels, and collapse mermaid's .labelBkg / .edgeLabel / rect backgrounds into a single padded chip on .edgeLabel p in the theme's label colour - Bump the render cache version to re-render --- docs/_ext/mermaid_inline.py | 49 ++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/docs/_ext/mermaid_inline.py b/docs/_ext/mermaid_inline.py index 559d680abc..a4ca7607c4 100644 --- a/docs/_ext/mermaid_inline.py +++ b/docs/_ext/mermaid_inline.py @@ -48,7 +48,7 @@ logger = logging.getLogger(__name__) #: Bump to invalidate the on-disk render cache when render arguments change. -_RENDER_VERSION = "mmdc11-furo-svg-v2" +_RENDER_VERSION = "mmdc11-furo-svg-v3" #: Logical names for the two inlined variants (cache key, SVG id, CSS class). _THEME_LIGHT = "light" @@ -89,19 +89,40 @@ }, } -#: Extra CSS injected into every rendered SVG (theme-agnostic). Nodes tagged -#: ``:::cmd`` render in a monospace font so commands read as code; edge labels -#: get padding. ``white-space: normal`` lets a padded label keep wrapping -#: instead of overflowing the box mermaid measured for it. +#: Monospace stack matching gp-furo's ``--font-stack--monospace``. _MONO_STACK = "'SFMono-Regular', Menlo, Consolas, Monaco, 'Liberation Mono', monospace" -_THEME_CSS = ( - ".cmd .nodeLabel { font-family: " + _MONO_STACK + " !important; }" - " .edgeLabels .labelBkg {" - " padding: 3px 10px !important;" - " white-space: normal !important;" - " border-radius: 4px;" - " }" -) + + +def _theme_css(theme: str) -> str: + """Return the CSS injected into a rendered SVG for the given theme. + + Renders ``:::cmd`` nodes in a monospace font so commands read as code; + centres node labels (the build's headless Chrome may lack the exact mono + face, so the measured box can be wider than the on-page text, which would + otherwise fall left); and collapses mermaid's three stacked edge-label + backgrounds into one padded chip in the theme's label colour. ``white-space: + normal`` lets a padded label wrap instead of overflowing its measured box. + + >>> "monospace" in _theme_css("light") + True + >>> "#f8f9fb" in _theme_css("light") + True + """ + bg = _PALETTES[theme]["edgeLabelBackground"] + return ( + ".cmd .nodeLabel { font-family: " + _MONO_STACK + " !important; }" + " .nodeLabel, .nodeLabel p { text-align: center !important; }" + " .edgeLabel rect { opacity: 0 !important; }" + " .labelBkg { background: transparent !important; }" + " .edgeLabel { background: transparent !important; }" + " .edgeLabel p {" + " background: " + bg + " !important;" + " padding: 3px 10px !important;" + " border-radius: 4px !important;" + " white-space: normal !important;" + " }" + ) + #: mermaid hardcodes this id on every rendered SVG and scopes its CSS and #: arrowhead markers to it; it is rewritten per diagram+theme to avoid @@ -317,7 +338,7 @@ def _render(app: Sphinx, source: str, theme: str) -> str: config: dict[str, t.Any] = { "theme": "base", "themeVariables": _PALETTES[theme], - "themeCSS": _THEME_CSS, + "themeCSS": _theme_css(theme), } config_file.write_text(json.dumps(config), encoding="utf-8") argv = [ From f6eb318ddc30c0bc95992e959b6986a0932df486 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 06:18:57 -0500 Subject: [PATCH 15/20] docs(ext): center code labels, robust cache key why: The :::cmd label rendered left-of-centre on browsers whose monospace face is narrower than the build's headless Chrome (e.g. macOS SF Mono): the shrink-to-fit table-cell sits at the left of the wider box mermaid measured. CSS that resizes the label to compensate (width/flex) corrupts mermaid's build-time measurement pass and ballooned the diagram to ~15000px tall. Separately, the render cache keyed only on source+theme, so a themeCSS/palette change served a stale SVG from docs/_mermaid_cache (which rm -rf docs/_build does not clear). what: - Center node labels with `display: table` + `margin: 0 auto`: shrink-to-fit positioning that re-centres without changing the measured size; verified centered even when the label renders far narrower than its box - Fold the full mermaid config (themeVariables + themeCSS) into the cache digest via a new `extra` arg so any styling change busts the cache; extract _mermaid_config(); _render now takes the config JSON - Update the cache-idempotency test for the new _render signature/theme --- docs/_ext/mermaid_inline.py | 70 ++++++++++++++++++++++++++----------- tests/test_docs_mermaid.py | 10 ++++-- 2 files changed, 57 insertions(+), 23 deletions(-) diff --git a/docs/_ext/mermaid_inline.py b/docs/_ext/mermaid_inline.py index a4ca7607c4..033b4aa7e4 100644 --- a/docs/_ext/mermaid_inline.py +++ b/docs/_ext/mermaid_inline.py @@ -48,7 +48,7 @@ logger = logging.getLogger(__name__) #: Bump to invalidate the on-disk render cache when render arguments change. -_RENDER_VERSION = "mmdc11-furo-svg-v3" +_RENDER_VERSION = "mmdc11-furo-svg-v4" #: Logical names for the two inlined variants (cache key, SVG id, CSS class). _THEME_LIGHT = "light" @@ -97,11 +97,17 @@ def _theme_css(theme: str) -> str: """Return the CSS injected into a rendered SVG for the given theme. Renders ``:::cmd`` nodes in a monospace font so commands read as code; - centres node labels (the build's headless Chrome may lack the exact mono - face, so the measured box can be wider than the on-page text, which would - otherwise fall left); and collapses mermaid's three stacked edge-label - backgrounds into one padded chip in the theme's label colour. ``white-space: - normal`` lets a padded label wrap instead of overflowing its measured box. + centres each node label in its box; and collapses mermaid's three stacked + edge-label backgrounds into one padded chip in the theme's label colour. + + Centering uses ``display: table`` + ``margin: 0 auto`` rather than a width + or flex rule: the build's headless Chrome may render the font at a different + width than the visitor's browser, leaving the shrink-to-fit ``table-cell`` + narrower than the measured box (so it falls left), but anything that + *resizes* the label corrupts mermaid's build-time measurement pass. A table + with auto margins re-centres without changing the measured size. + ``white-space: normal`` lets a padded edge label wrap instead of + overflowing its measured box. >>> "monospace" in _theme_css("light") True @@ -112,6 +118,10 @@ def _theme_css(theme: str) -> str: return ( ".cmd .nodeLabel { font-family: " + _MONO_STACK + " !important; }" " .nodeLabel, .nodeLabel p { text-align: center !important; }" + " .node foreignObject > div {" + " display: table !important;" + " margin: 0 auto !important;" + " }" " .edgeLabel rect { opacity: 0 !important; }" " .labelBkg { background: transparent !important; }" " .edgeLabel { background: transparent !important; }" @@ -151,21 +161,31 @@ class mermaid_inline(nodes.General, nodes.Element): """Doctree node carrying mermaid source until the HTML write phase.""" -def _diagram_digest(source: str, theme: str, *, version: str = _RENDER_VERSION) -> str: +def _diagram_digest( + source: str, + theme: str, + *, + version: str = _RENDER_VERSION, + extra: str = "", +) -> str: """Return a stable content hash that keys the render cache. - The hash covers the render version, the theme, and the source, so light and - dark variants cache separately and a render-argument change busts the cache. + The hash covers the render version, the theme, the source, and ``extra`` + (the full mermaid config JSON) — so light and dark variants cache + separately and any styling change, not just a source change, busts the + cache. >>> a = _diagram_digest("flowchart LR a-->b", "default") >>> a == _diagram_digest("flowchart LR a-->b", "default") True >>> a != _diagram_digest("flowchart LR a-->b", "dark") True + >>> a != _diagram_digest("flowchart LR a-->b", "default", extra="{}") + True >>> len(a) 40 """ - payload = f"{version}\x00{theme}\x00{source}" + payload = f"{version}\x00{theme}\x00{extra}\x00{source}" return hashlib.sha1(payload.encode("utf-8")).hexdigest() @@ -320,8 +340,22 @@ def _puppeteer_config_file(app: Sphinx, tmpdir: pathlib.Path) -> pathlib.Path: return out -def _render(app: Sphinx, source: str, theme: str) -> str: - """Render ``source`` to an SVG string via ``mmdc`` for the given theme.""" +def _mermaid_config(theme: str) -> dict[str, t.Any]: + """Return the mermaid ``-c`` config: base theme + furo palette + themeCSS. + + >>> cfg = _mermaid_config("light") + >>> cfg["theme"], "themeVariables" in cfg, "themeCSS" in cfg + ('base', True, True) + """ + return { + "theme": "base", + "themeVariables": _PALETTES[theme], + "themeCSS": _theme_css(theme), + } + + +def _render(app: Sphinx, source: str, config_json: str) -> str: + """Render ``source`` to an SVG string via ``mmdc`` using ``config_json``.""" mmdc = _resolve_mmdc(app) if mmdc is None: msg = ( @@ -335,12 +369,7 @@ def _render(app: Sphinx, source: str, theme: str) -> str: out_file = tmpdir / "diagram.svg" config_file = tmpdir / "config.json" in_file.write_text(source, encoding="utf-8") - config: dict[str, t.Any] = { - "theme": "base", - "themeVariables": _PALETTES[theme], - "themeCSS": _theme_css(theme), - } - config_file.write_text(json.dumps(config), encoding="utf-8") + config_file.write_text(config_json, encoding="utf-8") argv = [ *mmdc, "-i", @@ -380,12 +409,13 @@ def _render_cached(app: Sphinx, source: str, theme: str) -> str: The cache lives outside ``_build`` (under the confdir) so it survives the ``rm -rf docs/_build`` that precedes a full build. """ - digest = _diagram_digest(source, theme) + config_json = json.dumps(_mermaid_config(theme), sort_keys=True) + digest = _diagram_digest(source, theme, extra=config_json) cache_dir = pathlib.Path(app.confdir) / "_mermaid_cache" cache_file = cache_dir / f"{digest}.svg" if cache_file.is_file(): return cache_file.read_text(encoding="utf-8") - svg = _render(app, source, theme) + svg = _render(app, source, config_json) cache_dir.mkdir(parents=True, exist_ok=True) cache_file.write_text(svg, encoding="utf-8") return svg diff --git a/tests/test_docs_mermaid.py b/tests/test_docs_mermaid.py index db92683a97..930f34e177 100644 --- a/tests/test_docs_mermaid.py +++ b/tests/test_docs_mermaid.py @@ -272,7 +272,7 @@ def test_render_cache_is_idempotent( """``_render_cached`` invokes mmdc once, then serves from the disk cache.""" calls = {"n": 0} - def fake_render(app: object, source: str, theme: str) -> str: + def fake_render(app: object, source: str, config_json: str) -> str: calls["n"] += 1 return _FAKE_MMDC_SVG @@ -283,8 +283,12 @@ def fake_render(app: object, source: str, theme: str) -> str: ) app = types.SimpleNamespace(confdir=str(tmp_path), config=config) - first = mi._render_cached(t.cast("t.Any", app), "flowchart LR a-->b", "default") - second = mi._render_cached(t.cast("t.Any", app), "flowchart LR a-->b", "default") + first = mi._render_cached( + t.cast("t.Any", app), "flowchart LR a-->b", mi._THEME_LIGHT + ) + second = mi._render_cached( + t.cast("t.Any", app), "flowchart LR a-->b", mi._THEME_LIGHT + ) assert first == second == _FAKE_MMDC_SVG assert calls["n"] == 1 From 5f26fc7bf173fbb7837e9899ee9c62daacc49725 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 06:23:32 -0500 Subject: [PATCH 16/20] docs(examples): convert pane diagrams to mermaid why: The example pane layouts used aafig ASCII art, which renders outside the build-time mermaid pipeline and ignores the site theme. mermaid's block-beta diagrams map directly onto tmux pane grids and inherit the furo palette, light/dark variants, and SPA handling. what: - Replace the four `.. aafig::` blocks (short-hand, 2/3/4 panes) with ```mermaid block-beta diagrams reproducing each layout - aafig is still used by docs/about_tmux.md, so the extension stays --- docs/configuration/examples.md | 71 ++++++++++------------------------ 1 file changed, 21 insertions(+), 50 deletions(-) diff --git a/docs/configuration/examples.md b/docs/configuration/examples.md index 2d463e4ba5..2c5aa6835c 100644 --- a/docs/configuration/examples.md +++ b/docs/configuration/examples.md @@ -9,20 +9,12 @@ punctual. ::::{sidebar} short hand -```{eval-rst} -.. aafig:: - :textual: - - +-------------------+ - | 'did you know' | - | 'you can inline' | - +-------------------+ - | 'single commands' | - | | - +-------------------+ - | 'for panes' | - | | - +-------------------+ +```mermaid +block-beta + columns 1 + p1["'did you know'
'you can inline'"] + p2["'single commands'"] + p3["'for panes'"] ``` :::: @@ -75,18 +67,11 @@ Note `''` counts as an empty carriage return. ::::{sidebar} 2 pane -```{eval-rst} -.. aafig:: - - +-----------------+ - | $ pwd | - | | - | | - +-----------------+ - | $ pwd | - | | - | | - +-----------------+ +```mermaid +block-beta + columns 1 + a["$ pwd"] + b["$ pwd"] ``` :::: @@ -113,18 +98,11 @@ Note `''` counts as an empty carriage return. ::::{sidebar} 3 panes -```{eval-rst} -.. aafig:: - - +-----------------+ - | $ pwd | - | | - | | - +--------+--------+ - | $ pwd | $ pwd | - | | | - | | | - +--------+--------+ +```mermaid +block-beta + columns 2 + top["$ pwd"]:2 + bl["$ pwd"] br["$ pwd"] ``` :::: @@ -151,18 +129,11 @@ Note `''` counts as an empty carriage return. ::::{sidebar} 4 panes -```{eval-rst} -.. aafig:: - - +--------+--------+ - | $ pwd | $ pwd | - | | | - | | | - +--------+--------+ - | $ pwd | $ pwd | - | | | - | | | - +--------+--------+ +```mermaid +block-beta + columns 2 + a["$ pwd"] b["$ pwd"] + c["$ pwd"] d["$ pwd"] ``` :::: From 16fec0c2b6af460d4493c9c388915eab2db53d1a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 06:26:27 -0500 Subject: [PATCH 17/20] docs(ext): size block diagrams from root viewBox why: block-beta diagrams render with a negative viewBox origin (e.g. `viewBox="-5 -97 148 194"`) and carry inner-element viewBoxes. The size regex assumed `viewBox="0 0 ..."` and matched an inner `0 0 10 10`, so the root SVG was sized 10x10 and the diagram rendered as a tiny icon. what: - Anchor the viewBox regex to the root `` tag and accept a negative min-x/min-y, taking width/height from the 3rd/4th numbers - Add a normalizer test case + doctest for the block-diagram viewBox --- docs/_ext/mermaid_inline.py | 17 ++++++++++++++++- tests/test_docs_mermaid.py | 10 ++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/_ext/mermaid_inline.py b/docs/_ext/mermaid_inline.py index 033b4aa7e4..190f9e16da 100644 --- a/docs/_ext/mermaid_inline.py +++ b/docs/_ext/mermaid_inline.py @@ -139,7 +139,11 @@ def _theme_css(theme: str) -> str: #: duplicate-id collisions when both variants are inlined on one page. _MERMAID_DEFAULT_ID = "my-svg" -_VIEWBOX_RE = re.compile(r'viewBox="0 0 ([\d.]+) ([\d.]+)"') +# Width/height are the 3rd/4th viewBox numbers of the ROOT tag. Anchoring +# to ```` (no ``>`` in between) avoids matching an inner element's +# viewBox, and the min-x/min-y may be negative (block diagrams use e.g. +# ``viewBox="-5 -97 148 194"``). +_VIEWBOX_RE = re.compile(r']*?\bviewBox="-?[\d.]+ -?[\d.]+ ([\d.]+) ([\d.]+)"') #: Guards the "renderer missing/failed" warning so it fires once, not per node. _render_warned = False @@ -227,6 +231,17 @@ def _normalize_svg(svg: str, *, svg_id: str) -> str: False >>> 'id="mermaid-abc-light"' in out and "url(#mermaid-abc-light_end)" in out True + + Block diagrams use a negative viewBox origin and carry inner viewBoxes; + the root's width/height (3rd/4th numbers) win, not an inner ``0 0 10 10``: + + >>> block = ( + ... '' + ... '' + ... ) + >>> out = _normalize_svg(block, svg_id="x") + >>> 'width="148"' in out and 'height="194"' in out + True """ svg = svg.replace(_MERMAID_DEFAULT_ID, svg_id) match = _VIEWBOX_RE.search(svg) diff --git a/tests/test_docs_mermaid.py b/tests/test_docs_mermaid.py index 930f34e177..b0c0c86738 100644 --- a/tests/test_docs_mermaid.py +++ b/tests/test_docs_mermaid.py @@ -81,6 +81,16 @@ class NormalizeCase(t.NamedTuple): expect_contains=('width="1141.25"', 'height="94"'), expect_absent=("max-width",), ), + NormalizeCase( + test_id="block-negative-viewbox-origin", + raw_svg=( + '' + '' + ), + svg_id="mermaid-blk-light", + expect_contains=('width="148"', 'height="194"'), + expect_absent=('width="100%"', 'width="10"'), + ), ] From 1dc51ed5bad6a1c401ae95fe2f9475c6c40c06b8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 06:28:51 -0500 Subject: [PATCH 18/20] docs(ext): tile block diagrams contiguously why: block-beta panes rendered with gaps, so a tmux pane layout read as separate boxes rather than a single tiled window. what: - Set block.padding=0 in the mermaid config so block panes share dividers and fill a contiguous rectangle, like a tmux window - Other diagram types ignore the block key --- docs/_ext/mermaid_inline.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/_ext/mermaid_inline.py b/docs/_ext/mermaid_inline.py index 190f9e16da..6a1f86e736 100644 --- a/docs/_ext/mermaid_inline.py +++ b/docs/_ext/mermaid_inline.py @@ -358,12 +358,16 @@ def _puppeteer_config_file(app: Sphinx, tmpdir: pathlib.Path) -> pathlib.Path: def _mermaid_config(theme: str) -> dict[str, t.Any]: """Return the mermaid ``-c`` config: base theme + furo palette + themeCSS. + ``block.padding: 0`` tiles block-beta panes contiguously, like a tmux + window; other diagram types ignore the ``block`` key. + >>> cfg = _mermaid_config("light") - >>> cfg["theme"], "themeVariables" in cfg, "themeCSS" in cfg - ('base', True, True) + >>> cfg["theme"], "themeVariables" in cfg, cfg["block"]["padding"] + ('base', True, 0) """ return { "theme": "base", + "block": {"padding": 0}, "themeVariables": _PALETTES[theme], "themeCSS": _theme_css(theme), } From 7df69dad868997a7eb561c18b628ac227b5ba826 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 06:41:02 -0500 Subject: [PATCH 19/20] docs(ext): add tmux-layout directive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The example pane layouts used aafig ASCII art, then block-beta — neither shows real tmux geometry or matches the site's code styling. A purpose-built renderer draws faithful tmux layouts that read like the terminal, with no headless-browser dependency. what: - Add docs/_ext/tmux_layout.py: a `tmux-layout` directive taking a screen size and a named layout (even-vertical/horizontal, main-vertical/ horizontal, tiled) plus per-pane shell commands. It computes filling pane geometry and emits one inline SVG: panes tile with single dividers, text top-left, pygments-highlighted in the site's code font and colours (light/dark) via furo's own .highlight rules and `fill: currentColor`. Command builtins stay default-coloured; a gp prompt leads each line. Pure Python, theme-adaptive, no JS, SPA-safe - Add docs/_static/css/gp-tmux-layout.css: pane background = the pygments code background, code font-size, kerning/letter-spacing matching `pre` - Convert the examples (short-hand, 2/3/4 panes) from block-beta to tmux-layout; register the extension in conf.py - Add tests/test_docs_tmux_layout.py: NamedTuple-parametrized coverage of the geometry (fills the screen), highlighting, pane splitting, size parsing, SVG structure, and the setup() contract --- docs/_ext/tmux_layout.py | 366 ++++++++++++++++++++++++++++ docs/_static/css/gp-tmux-layout.css | 74 ++++++ docs/conf.py | 1 + docs/configuration/examples.md | 78 +++--- tests/test_docs_tmux_layout.py | 202 +++++++++++++++ 5 files changed, 682 insertions(+), 39 deletions(-) create mode 100644 docs/_ext/tmux_layout.py create mode 100644 docs/_static/css/gp-tmux-layout.css create mode 100644 tests/test_docs_tmux_layout.py diff --git a/docs/_ext/tmux_layout.py b/docs/_ext/tmux_layout.py new file mode 100644 index 0000000000..4ae13c7bba --- /dev/null +++ b/docs/_ext/tmux_layout.py @@ -0,0 +1,366 @@ +"""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 ```` 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 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 = '' # noqa: RUF001 + +_LAYOUTS = ( + "even-vertical", + "even-horizontal", + "main-vertical", + "main-horizontal", + "tiled", +) +_LEXER = BashLexer() + + +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 ````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'{escaped}' if css else escaped) + return "".join(spans) + + +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 = render_layout([["echo a"], ["echo b"]], "even-vertical", (40, 12)) + >>> svg.startswith("', + ] + # 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'' + 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'', + ) + if bottom < h: # internal horizontal divider + parts.append( + f'', + ) + parts.append( + f'', + ) + # Pygments colours come from furo's own `.highlight` rules (theme-matched, + # light/dark) via `fill: currentColor`; a ignores its background. + parts.append('') + for rect, commands in zip(rects, panes, strict=True): + tx = rect.x * _CW + _PAD + baseline = rect.y * _CH + _PAD + _FONT + for i, line in enumerate(commands): + body = _highlight(line) + if not body: + continue + ty = baseline + i * _LINE_H + parts.append( + f'{_PROMPT}{body}', + ) + parts.append("") + 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'
{node["svg"]}'] + if caption: + parts.append(f"
{html.escape(caption)}
") + parts.append("
") + 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, + } diff --git a/docs/_static/css/gp-tmux-layout.css b/docs/_static/css/gp-tmux-layout.css new file mode 100644 index 0000000000..8718724809 --- /dev/null +++ b/docs/_static/css/gp-tmux-layout.css @@ -0,0 +1,74 @@ +/* + * tmux window layouts (docs/_ext/tmux_layout.py). + * + * Plain inline SVG: it inherits furo's CSS custom properties and adapts to + * light/dark via body[data-theme] with no JavaScript and no second render. + * + * Shell highlighting reuses furo's own Pygments rules: the command text is + * wrapped in , so `.highlight .nb`, `.highlight .gp` + * (the prompt), etc. apply, and SVG takes its colour from `color` via + * `fill: currentColor` — so the panes match the site's console blocks exactly, + * font and colours, in both light and dark. The viewBox is in pixels (one tmux + * cell = _CW x _CH), so at intrinsic size the text renders at the code font + * size. + */ + +.gp-tmux-layout-wrapper { + margin: 1.5rem 0; + text-align: center; +} + +.gp-tmux-layout { + max-width: min(100%, 480px); + height: auto; + display: block; + margin: 0 auto; +} + +/* + * Pane background mirrors the Pygments code-block background, with the same + * light/dark switching furo uses for `.highlight` (pygments_style and + * pygments_dark_style backgrounds). + */ +.gp-tmux-layout .pane { + fill: #f8fafc; +} + +body[data-theme="dark"] .gp-tmux-layout .pane { + fill: #272822; +} + +@media (prefers-color-scheme: dark) { + body:not([data-theme="light"]) .gp-tmux-layout .pane { + fill: #272822; + } +} + +/* Single-stroke window border and pane dividers. */ +.gp-tmux-layout .window { + fill: none; + stroke: var(--color-background-border, #cccccc); + stroke-width: 1; +} + +.gp-tmux-layout .divider { + stroke: var(--color-background-border, #cccccc); + stroke-width: 1; +} + +.gp-tmux-layout text { + font-family: var(--font-stack--monospace, "SFMono-Regular", Menlo, monospace); + font-size: var(--code-font-size, 0.8125rem); + /* Match the site's code blocks (pre): even monospace advance, no kerning. */ + font-kerning: none; + text-rendering: optimizeSpeed; + letter-spacing: normal; + /* Colour comes from furo's .highlight / .highlight .nb / .gp rules. */ + fill: currentColor; +} + +.gp-tmux-layout-wrapper > figcaption { + text-align: center; + font-size: 0.9em; + margin-top: 0.5rem; +} diff --git a/docs/conf.py b/docs/conf.py index 95f4ecfeb2..9217bea552 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,6 +35,7 @@ "sphinx_autodoc_api_style", "aafig", "mermaid_inline", + "tmux_layout", "sphinx_autodoc_argparse.exemplar", ], # Route a plain ```mermaid fence to the mermaid_inline directive (the colon diff --git a/docs/configuration/examples.md b/docs/configuration/examples.md index 2c5aa6835c..9ab5777308 100644 --- a/docs/configuration/examples.md +++ b/docs/configuration/examples.md @@ -7,17 +7,17 @@ tmuxp has a short-hand syntax for those who wish to keep their workspace punctual. -::::{sidebar} short hand - -```mermaid -block-beta - columns 1 - p1["'did you know'

'you can inline'"] - p2["'single commands'"] - p3["'for panes'"] -``` - -:::: +:::{tmux-layout} +:size: 36x12 +:layout: even-vertical + +echo 'did you know' +echo 'you can inline' +--- +echo 'single commands' +--- +echo 'for panes' +::: ````{tab} YAML @@ -65,16 +65,14 @@ Note `''` counts as an empty carriage return. ## 2 panes -::::{sidebar} 2 pane +:::{tmux-layout} +:size: 30x10 +:layout: even-vertical -```mermaid -block-beta - columns 1 - a["$ pwd"] - b["$ pwd"] -``` - -:::: +pwd +--- +pwd +::: ````{tab} YAML @@ -96,16 +94,16 @@ block-beta ## 3 panes -::::{sidebar} 3 panes +:::{tmux-layout} +:size: 30x12 +:layout: main-horizontal -```mermaid -block-beta - columns 2 - top["$ pwd"]:2 - bl["$ pwd"] br["$ pwd"] -``` - -:::: +pwd +--- +pwd +--- +pwd +::: ````{tab} YAML @@ -127,16 +125,18 @@ block-beta ## 4 panes -::::{sidebar} 4 panes - -```mermaid -block-beta - columns 2 - a["$ pwd"] b["$ pwd"] - c["$ pwd"] d["$ pwd"] -``` - -:::: +:::{tmux-layout} +:size: 30x12 +:layout: tiled + +pwd +--- +pwd +--- +pwd +--- +pwd +::: ````{tab} YAML diff --git a/tests/test_docs_tmux_layout.py b/tests/test_docs_tmux_layout.py new file mode 100644 index 0000000000..d787eae874 --- /dev/null +++ b/tests/test_docs_tmux_layout.py @@ -0,0 +1,202 @@ +"""Tests for the tmux-layout directive (``docs/_ext/tmux_layout.py``).""" + +from __future__ import annotations + +import importlib.util +import types +import typing as t +from pathlib import Path + +import pytest + +_EXT_PATH = Path(__file__).resolve().parent.parent / "docs" / "_ext" / "tmux_layout.py" +_spec = importlib.util.spec_from_file_location("tmux_layout", _EXT_PATH) +assert _spec is not None +assert _spec.loader is not None +tl = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(tl) + + +class ArrangeCase(t.NamedTuple): + """A named layout with a pane count and the geometry it should produce.""" + + test_id: str + layout: str + n: int + expect_count: int + + +_ARRANGE_CASES: list[ArrangeCase] = [ + ArrangeCase("even-vertical-3", "even-vertical", 3, 3), + ArrangeCase("even-horizontal-2", "even-horizontal", 2, 2), + ArrangeCase("main-vertical-3", "main-vertical", 3, 3), + ArrangeCase("main-horizontal-3", "main-horizontal", 3, 3), + ArrangeCase("tiled-4", "tiled", 4, 4), + ArrangeCase("tiled-3-spans-last-row", "tiled", 3, 3), + ArrangeCase("single-pane", "even-vertical", 1, 1), +] + + +@pytest.mark.parametrize( + "case", + _ARRANGE_CASES, + ids=[c.test_id for c in _ARRANGE_CASES], +) +def test_arrange_fills_screen(case: ArrangeCase) -> None: + """Each arrangement returns the right panes and tiles the screen exactly.""" + width, height = 80, 24 + rects = tl.arrange(case.layout, case.n, width, height) + assert len(rects) == case.expect_count + # Non-overlapping panes that fill the screen sum to its area. + area = sum(r.w * r.h for r in rects) + assert area == pytest.approx(width * height) + # Every pane stays within the screen. + for r in rects: + assert r.x >= -1e-6 + assert r.y >= -1e-6 + assert r.x + r.w <= width + 1e-6 + assert r.y + r.h <= height + 1e-6 + + +def test_arrange_unknown_layout_raises() -> None: + """An unknown layout name is a clear error.""" + with pytest.raises(tl.TmuxLayoutError, match="unknown layout"): + tl.arrange("spiral", 2, 80, 24) + + +class HighlightCase(t.NamedTuple): + """A shell command and the Pygments class its first token should carry.""" + + test_id: str + command: str + expect_class: str + + +_HIGHLIGHT_CASES: list[HighlightCase] = [ + HighlightCase("single-quoted-string", "echo 'hi there'", 'class="s1"'), + HighlightCase("comment", "# a note", 'class="c1"'), +] + + +@pytest.mark.parametrize( + "case", + _HIGHLIGHT_CASES, + ids=[c.test_id for c in _HIGHLIGHT_CASES], +) +def test_highlight_emits_pygments_classes(case: HighlightCase) -> None: + """``_highlight`` wraps shell tokens in Pygments-classed tspans.""" + out = tl._highlight(case.command) + assert case.expect_class in out + assert " None: + """A command builtin (echo/pwd) is left in the default colour, no nb class.""" + assert 'class="nb"' not in tl._highlight("echo hello") + assert 'class="nb"' not in tl._highlight("pwd") + + +def test_highlight_escapes_markup() -> None: + """Angle brackets in a command are HTML-escaped, not emitted as tags.""" + out = tl._highlight("echo ") + assert "" not in out + assert "<a>" in out + + +class SplitCase(t.NamedTuple): + """Directive content lines and the per-pane command lists they yield.""" + + test_id: str + content: list[str] + expect: list[list[str]] + + +_SPLIT_CASES: list[SplitCase] = [ + SplitCase("two-panes", ["echo a", "---", "echo b"], [["echo a"], ["echo b"]]), + SplitCase( + "multiline-pane", + ["echo a", "echo b", "---", "echo c"], + [["echo a", "echo b"], ["echo c"]], + ), + SplitCase( + "blank-lines-dropped", ["echo a", "", "---", "echo b"], [["echo a"], ["echo b"]] + ), +] + + +@pytest.mark.parametrize( + "case", + _SPLIT_CASES, + ids=[c.test_id for c in _SPLIT_CASES], +) +def test_split_panes(case: SplitCase) -> None: + """``_split_panes`` splits on ``---`` and keeps each pane's commands.""" + assert tl._split_panes(case.content) == case.expect + + +class SizeCase(t.NamedTuple): + """A ``:size:`` string and the (cols, rows) it parses to.""" + + test_id: str + size: str + expect: tuple[int, int] + + +_SIZE_CASES: list[SizeCase] = [ + SizeCase("plain", "64x18", (64, 18)), + SizeCase("uppercase-x", "80X24", (80, 24)), +] + + +@pytest.mark.parametrize( + "case", + _SIZE_CASES, + ids=[c.test_id for c in _SIZE_CASES], +) +def test_parse_size(case: SizeCase) -> None: + """``_parse_size`` reads a ``WxH`` screen size.""" + assert tl._parse_size(case.size) == case.expect + + +def test_parse_size_invalid_raises() -> None: + """A malformed size is a clear error.""" + with pytest.raises(tl.TmuxLayoutError, match="invalid size"): + tl._parse_size("not-a-size") + + +def test_render_layout_structure() -> None: + """The rendered SVG has one window, one rect per pane, and highlighting.""" + svg = tl.render_layout([["echo a"], ["echo b"]], "even-vertical", (40, 12)) + assert svg.startswith("") + + +def test_setup_registers_components() -> None: + """``setup`` registers the node, directive, css, and is parallel-safe.""" + recorded: dict[str, list[t.Any]] = {"nodes": [], "directives": [], "css": []} + + def add_node(node: object, **kwargs: object) -> None: + recorded["nodes"].append(node) + + def add_directive(name: str, cls: object) -> None: + recorded["directives"].append((name, cls)) + + def add_css_file(name: str, **kwargs: object) -> None: + recorded["css"].append(name) + + app = types.SimpleNamespace( + add_node=add_node, + add_directive=add_directive, + add_css_file=add_css_file, + ) + meta = tl.setup(t.cast("t.Any", app)) + + assert meta["parallel_read_safe"] is True + assert meta["parallel_write_safe"] is True + assert tl.tmux_layout in recorded["nodes"] + assert ("tmux-layout", tl.TmuxLayoutDirective) in recorded["directives"] + assert "css/gp-tmux-layout.css" in recorded["css"] From 88b115d75c73f65f9f94951c8beb25c36ca9f8b1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 29 Jun 2026 19:02:34 -0500 Subject: [PATCH 20/20] docs(CHANGES) Themed diagrams across the docs why: Record the v1.74.0 docs update for the changelog. what: - Add a Documentation entry for the build-time, theme-aware diagrams on the workspace-builders and examples pages --- CHANGES | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGES b/CHANGES index 1c006c2e5d..25cc587db1 100644 --- a/CHANGES +++ b/CHANGES @@ -44,6 +44,16 @@ $ tmuxp@next load yoursession _Notes on the upcoming release will go here._ +### Documentation + +#### Themed diagrams across the docs (#1071) + +The docs now render their diagrams as theme-aware inline graphics that follow the +site's light/dark palette and code styling, replacing the old static ASCII art. +{ref}`workspace-builders` gains a load → build → attach flow diagram and opens +with a concept-first overview, and {ref}`examples` shows each pane example as a +faithful tmux window layout. + ## tmuxp 1.73.0 (2026-06-28) tmuxp 1.73.0 makes the workspace build step pluggable and tunable. A workspace can now build through a third-party builder selected by registered entry-point name or Python import path, and a new `workspace_builder_options` catalog controls the pane-readiness wait per workspace. The built-in builder stays the default, so existing workspaces keep working — though the new `pane_readiness: auto` default skips the prompt wait on non-zsh shells. See {ref}`custom-workspace-builders` for the guide.